Files
Oxicloud/src/domain/services/external_mount_id.rs
T
Bradley Nelson 3c31695579 feat(mounts): external file mounts P1 — pluggable provider + read-only REST
Adds the foundation for external file mounts: admin-configured backends
(raw host filesystem in v1; sftp/webdav/… as future provider kinds) surfaced
as a folder inside a user's drive. Mount contents are virtual/live-passthrough
— read straight from the backend, never stored in storage.files — and are a
deliberately separate, limited storage type (no dedup/sharing/trash/search).
The feature is dark by default (OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false).

P1 scope (this PR): data model, the pluggable provider abstraction, and the
read-only REST surface (mount listing + download). Read-write (P2),
WebDAV/NextCloud path resolution (P3), and the admin UI (P4) follow.

Core model
- Mount root = a real storage.folders row; authorization for everything inside
  collapses onto that folder UUID (ltree-ancestry grant cascade).
- Children are virtual, addressed by ext:<mount_id>:<base64url(node_id)> where
  node_id is provider-owned and opaque to the rest of the system.
- A lock-free (arc-swap) MountRegistry maps mount-root UUID -> provider; a thin
  MountRouter::classify() is the single cheap hook handlers call before parsing
  an id as a UUID. With no mounts configured it always returns Regular, so
  existing code paths are unchanged.

Added
- migrations/20260805000000_external_mounts.sql (storage.external_mounts, kind + config JSONB)
- domain/services/external_mount_id (id envelope + virtual etags)
- application/ports/external_mount_ports (ExternalMountProvider, MountProviderFactory, repo port)
- infrastructure local_fs_mount_provider (tokio::fs, symlink-escape-safe) + factory
- application MountRegistry + MountRouter, pg ExternalMountRepository
- DI wiring (AppState.mount_router), FeaturesConfig.enable_external_mounts
- listing branch (FolderService::list_mount_dir_with_perms + folder_handler) and
  download branch (FileRetrievalService stat/open mount methods + file_handler)

Authorization stays in the service layer (authz.require(Resource::Folder(mount_id)));
handlers only classify. Cross-backend operations are out of scope for P1.

Tests: 529 unit tests + 5 testcontainers integration tests (real Postgres 17),
including end-to-end authorization (owner allowed, stranger denied). Line
coverage of the new modules is 84–100% (cargo-llvm-cov). Known gap:
file_handler::download_mount_file (HTTP glue) needs a full-app test (P4).
2026-06-24 23:52:01 -06:00

259 lines
8.9 KiB
Rust

//! External mount identifiers (domain value objects).
//!
//! Files and folders *below* an external mount root have no database row. They
//! are addressed by a synthetic id that wraps a provider-owned node identity:
//!
//! ```text
//! ext:<mount_id>:<base64url(node_id)>
//! ```
//!
//! * `<mount_id>` is the mount-root folder's UUID (simple form, no hyphens) — the
//! only part the system interprets, used to find the provider in the registry.
//! * `<node_id>` is **assigned and owned by the provider** and is **opaque** to the
//! rest of the system. Most providers use the entry's path (`local_fs`, `sftp`,
//! `webdav`); a provider with a stronger stable handle (inode, object id, href)
//! may use that. It is base64url-encoded (no padding) so it survives URLs and
//! WebDAV hrefs and never collides with the `:` / `/` separators.
//!
//! The system NEVER parses or validates a `node_id` — it only splits the envelope
//! and hands the decoded bytes back to the provider verbatim.
use std::fmt;
use std::str::FromStr;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use uuid::Uuid;
/// The `ext:` scheme prefix that marks a synthetic external-mount id.
pub const EXTERNAL_ID_PREFIX: &str = "ext:";
/// A provider-owned, opaque node identity for one entry inside a mount.
///
/// The system treats this as opaque bytes. For path-based providers it is the
/// POSIX path relative to the mount root (no leading `/`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct NodeId(pub String);
impl NodeId {
/// Borrow the inner string.
pub fn as_str(&self) -> &str {
&self.0
}
/// Consume into the inner string.
pub fn into_string(self) -> String {
self.0
}
}
impl From<String> for NodeId {
fn from(s: String) -> Self {
NodeId(s)
}
}
impl From<&str> for NodeId {
fn from(s: &str) -> Self {
NodeId(s.to_owned())
}
}
impl fmt::Display for NodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// The decoded parts of a synthetic external-mount child id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountChildId {
/// The mount-root folder UUID (envelope; system-interpreted).
pub mount_id: Uuid,
/// The provider-owned node identity (opaque payload).
pub node_id: NodeId,
}
impl MountChildId {
/// Build a child id from a mount root and a provider node id.
pub fn new(mount_id: Uuid, node_id: impl Into<NodeId>) -> Self {
Self {
mount_id,
node_id: node_id.into(),
}
}
}
/// Renders as `ext:<mount_id>:<base64url(node_id)>`.
impl fmt::Display for MountChildId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{EXTERNAL_ID_PREFIX}{}:{}",
self.mount_id.simple(),
URL_SAFE_NO_PAD.encode(self.node_id.0.as_bytes())
)
}
}
/// Error returned when a string is not a well-formed external-mount child id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseMountChildIdError;
impl fmt::Display for ParseMountChildIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("not a valid external mount id (expected ext:<mount_id>:<token>)")
}
}
impl std::error::Error for ParseMountChildIdError {}
impl FromStr for MountChildId {
type Err = ParseMountChildIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// ext:<mount_id>:<token> — split into exactly three logical parts.
let rest = s
.strip_prefix(EXTERNAL_ID_PREFIX)
.ok_or(ParseMountChildIdError)?;
let (mount_part, token) = rest.split_once(':').ok_or(ParseMountChildIdError)?;
let mount_id = Uuid::parse_str(mount_part).map_err(|_| ParseMountChildIdError)?;
let bytes = URL_SAFE_NO_PAD
.decode(token)
.map_err(|_| ParseMountChildIdError)?;
let node = String::from_utf8(bytes).map_err(|_| ParseMountChildIdError)?;
Ok(MountChildId {
mount_id,
node_id: NodeId(node),
})
}
}
/// Cheap check: does this id use the external-mount scheme?
///
/// Used at the top of service methods to route `ext:` ids away from
/// `Uuid::parse_str` and the PostgreSQL repositories.
pub fn is_external_id(id: &str) -> bool {
id.starts_with(EXTERNAL_ID_PREFIX)
}
/// Encode a child id string from its parts.
pub fn encode_child_id(mount_id: Uuid, node_id: impl Into<NodeId>) -> String {
MountChildId::new(mount_id, node_id).to_string()
}
/// Parse a child id string into its parts, or `None` when not `ext:`-shaped.
pub fn parse_child_id(id: &str) -> Option<MountChildId> {
id.parse().ok()
}
/// ETag for a virtual file (no blob hash): `ext-{size:x}-{modified_at}`.
///
/// Combines size and mtime so it changes on any content edit without reading
/// the file. Mirrors the native `{blob_hash[..16]}-{modified_at}` shape closely
/// enough for conditional requests.
pub fn virtual_file_etag(size: u64, modified_at: u64) -> String {
format!("ext-{size:x}-{modified_at}")
}
/// ETag for a virtual folder: `ext-{modified_at}` (directory mtime).
pub fn virtual_folder_etag(modified_at: u64) -> String {
format!("ext-{modified_at}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_simple_path() {
let mount = Uuid::new_v4();
let id = encode_child_id(mount, "docs/report.txt");
assert!(is_external_id(&id));
let parsed = parse_child_id(&id).expect("parse");
assert_eq!(parsed.mount_id, mount);
assert_eq!(parsed.node_id.as_str(), "docs/report.txt");
}
#[test]
fn round_trips_via_fromstr_display() {
let mount = Uuid::new_v4();
let child = MountChildId::new(mount, "a/b/c.bin");
let rendered = child.to_string();
let parsed: MountChildId = rendered.parse().expect("parse");
assert_eq!(child, parsed);
}
#[test]
fn token_survives_separators_and_unicode() {
// node ids containing ':' '/' and non-ascii must survive the envelope.
let mount = Uuid::new_v4();
let tricky = "weird: name/with:colons/café.txt";
let id = encode_child_id(mount, tricky);
// The encoded form must not be ambiguous to the splitter: exactly two
// colons (the `ext:` scheme and the `<mount_id>:` separator); the
// base64url token never contains `:` or `/`.
assert_eq!(id.matches(':').count(), 2, "only scheme + mount separators");
let parsed = parse_child_id(&id).expect("parse");
assert_eq!(parsed.node_id.as_str(), tricky);
}
#[test]
fn rejects_non_external_ids() {
assert!(parse_child_id("not-an-ext-id").is_none());
assert!(parse_child_id(&Uuid::new_v4().to_string()).is_none());
assert!(parse_child_id("ext:not-a-uuid:dG9rZW4").is_none());
assert!(!is_external_id(&Uuid::new_v4().to_string()));
}
#[test]
fn rejects_malformed_envelopes() {
// Missing the second colon (no token separator).
assert!(parse_child_id("ext:abc").is_none());
// `ext:` with a valid uuid but a non-base64url token.
let u = Uuid::new_v4().simple().to_string();
assert!(parse_child_id(&format!("ext:{u}:!!!not-base64!!!")).is_none());
// Empty string / bare scheme.
assert!(parse_child_id("").is_none());
assert!(parse_child_id("ext:").is_none());
// is_external_id is a pure prefix check.
assert!(is_external_id("ext:anything"));
assert!(!is_external_id("EXT:upper"));
}
#[test]
fn round_trips_empty_node_id() {
// The mount root's own node id is empty; it must still round-trip
// (e.g. if ever encoded), encoding to a token-less-but-present form.
let mount = Uuid::new_v4();
let id = encode_child_id(mount, "");
let parsed = parse_child_id(&id).expect("parse empty node");
assert_eq!(parsed.mount_id, mount);
assert_eq!(parsed.node_id.as_str(), "");
}
#[test]
fn round_trips_long_and_nested_paths() {
let mount = Uuid::new_v4();
let deep = "a/".repeat(64) + "leaf.bin";
let id = encode_child_id(mount, deep.clone());
assert_eq!(parse_child_id(&id).unwrap().node_id.as_str(), deep);
}
#[test]
fn node_id_conversions() {
assert_eq!(NodeId::from("x").as_str(), "x");
assert_eq!(NodeId::from(String::from("y")).into_string(), "y");
assert_eq!(NodeId::default().as_str(), "");
}
#[test]
fn etags_change_with_inputs() {
assert_ne!(virtual_file_etag(10, 100), virtual_file_etag(11, 100));
assert_ne!(virtual_file_etag(10, 100), virtual_file_etag(10, 101));
assert_ne!(virtual_folder_etag(100), virtual_folder_etag(101));
// Format is stable and documented.
assert_eq!(virtual_file_etag(255, 16), "ext-ff-16");
assert_eq!(virtual_folder_etag(42), "ext-42");
}
}