Files
Oxicloud/src/application/services/external_mount_router.rs
T
Bradley Nelson d8229650ef feat(mounts): P3 — WebDAV/NextCloud path resolution + browse/download
Mounts are now browsable and downloadable over both WebDAV surfaces
(/webdav/ and NextCloud /remote.php/dav) and NextCloud clients. The work
funnels through the path-based service methods all three surfaces already use,
so both WebDAV handlers gain mount support with no handler changes.

- get_folder_by_path / get_file_by_path resolve a path that descends past a
  mount root to a synthetic ext: DTO (the mount root itself stays a real row)
- list_folders_paginated_with_perms / list_files_batch_with_perms branch to the
  provider, so PROPFIND Depth:1 enumerates mount directory contents in both
  WebDAV handlers
- get_file_stream / get_file_range_stream branch ext: file ids to the provider,
  so WebDAV GET streams mount content (Pin<Box<dyn Stream>> re-boxed)
- router injected into FileRetrievalService; new MountRouter::find_path delegates
  to the registry's (drive_id, mount_path) index
- WebDAV mkdir/delete/move/rename by path work via path→ext:id→the P2 service
  methods (no extra wiring)

Fixes a leading-slash normalization bug in the registry path index: materialized
folder paths arrive both as `Personal/Media` and `/Personal/Media`; keys and
lookups now normalize the leading slash (would have broken real WebDAV paths).

Known follow-up: WebDAV PUT (upload/update by path) still ingests to the CAS;
streaming a WebDAV PUT straight to the provider needs a pre-ingest branch in the
two WebDAV PUT handlers (mirrors the REST upload branch).

Integration test: get_folder_by_path/get_file_by_path resolution, PROPFIND
Depth:1 folder+file listing, and content streaming on a real mount + Postgres.
2026-06-25 00:51:16 -06:00

193 lines
6.7 KiB
Rust

//! Classifies file/folder ids into native vs. external-mount handling.
//!
//! This is the single, cheap hook the service layer calls before any
//! `Uuid::parse_str`, so synthetic `ext:` ids and mount-root UUIDs branch to the
//! provider while everything else flows to the PostgreSQL repositories unchanged.
use std::sync::Arc;
use uuid::Uuid;
use crate::application::services::mount_registry::{MountConfig, MountRegistry};
use crate::domain::services::external_mount_id::{NodeId, is_external_id, parse_child_id};
/// The result of classifying an id.
pub enum ResolvedId {
/// Plain native resource (UUID not registered as a mount root, or an
/// unrecognized id). Handle exactly as today.
Regular,
/// A real UUID that IS a mount root. Listing/metadata branch to the provider;
/// the row itself still exists natively.
MountRoot { cfg: Arc<MountConfig> },
/// A synthetic id addressing an entry inside a mount.
MountChild {
cfg: Arc<MountConfig>,
node_id: NodeId,
},
}
/// Thin, cloneable classifier over the mount registry.
#[derive(Clone)]
pub struct MountRouter {
registry: Arc<MountRegistry>,
}
impl MountRouter {
/// Construct from the shared registry.
pub fn new(registry: Arc<MountRegistry>) -> Self {
Self { registry }
}
/// Borrow the underlying registry (for path-based resolution / admin reload).
pub fn registry(&self) -> &Arc<MountRegistry> {
&self.registry
}
/// Fast path: are there no mounts at all? Lets callers skip classification.
pub fn is_empty(&self) -> bool {
self.registry.is_empty()
}
/// Classify an id. Never parses a provider `node_id` — only the envelope.
pub fn classify(&self, id: &str) -> ResolvedId {
if is_external_id(id) {
if let Some(child) = parse_child_id(id)
&& let Some(cfg) = self.registry.get(&child.mount_id)
{
return ResolvedId::MountChild {
cfg,
node_id: child.node_id,
};
}
// Malformed or dangling `ext:` id — fall through to Regular so it
// surfaces a clean NotFound downstream rather than hitting the repos.
return ResolvedId::Regular;
}
if let Ok(uuid) = Uuid::parse_str(id)
&& let Some(cfg) = self.registry.get(&uuid)
{
return ResolvedId::MountRoot { cfg };
}
ResolvedId::Regular
}
/// True when `id` addresses anything inside a mount (root or child).
pub fn is_mount_id(&self, id: &str) -> bool {
matches!(
self.classify(id),
ResolvedId::MountRoot { .. } | ResolvedId::MountChild { .. }
)
}
/// Path-based lookup for the protocol surfaces (WebDAV / NextCloud): does
/// `internal_path` descend into a mount within `drive_id`? Returns the mount
/// config plus the remainder relpath (empty when the path IS the mount root).
pub fn find_path(
&self,
drive_id: uuid::Uuid,
internal_path: &str,
) -> Option<(Arc<MountConfig>, String)> {
self.registry.find_mount_for_path(drive_id, internal_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::ports::external_mount_ports::{
ExternalMountRecord, ExternalMountRepositoryPort,
};
use crate::domain::errors::DomainError;
use crate::domain::services::external_mount_id::encode_child_id;
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
use async_trait::async_trait;
use tempfile::TempDir;
struct FakeRepo(Vec<ExternalMountRecord>);
#[async_trait]
impl ExternalMountRepositoryPort for FakeRepo {
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError> {
Ok(self.0.clone())
}
}
async fn router_with_mount(mount_id: Uuid, dir: &TempDir) -> MountRouter {
let repo = FakeRepo(vec![ExternalMountRecord {
mount_folder_id: mount_id,
kind: "local_fs".to_string(),
config: serde_json::json!({ "path": dir.path().to_str().unwrap() }),
name: "M".to_string(),
owner_id: Uuid::new_v4(),
read_only: false,
drive_id: Uuid::new_v4(),
mount_path: "Personal/M".to_string(),
}]);
let reg = Arc::new(MountRegistry::empty());
reg.reload(&repo, &DefaultMountProviderFactory::new()).await;
MountRouter::new(reg)
}
#[test]
fn empty_registry_classifies_everything_regular() {
let router = MountRouter::new(Arc::new(MountRegistry::empty()));
assert!(router.is_empty());
assert!(matches!(
router.classify(&Uuid::new_v4().to_string()),
ResolvedId::Regular
));
assert!(matches!(
router.classify("ext:deadbeef:dG9rZW4"),
ResolvedId::Regular
));
assert!(matches!(router.classify("garbage"), ResolvedId::Regular));
}
#[tokio::test]
async fn classifies_mount_root_uuid() {
let dir = TempDir::new().unwrap();
let mount_id = Uuid::new_v4();
let router = router_with_mount(mount_id, &dir).await;
match router.classify(&mount_id.to_string()) {
ResolvedId::MountRoot { cfg } => assert_eq!(cfg.mount_id, mount_id),
_ => panic!("expected MountRoot"),
}
assert!(router.is_mount_id(&mount_id.to_string()));
}
#[tokio::test]
async fn classifies_ext_child_id() {
let dir = TempDir::new().unwrap();
let mount_id = Uuid::new_v4();
let router = router_with_mount(mount_id, &dir).await;
let child = encode_child_id(mount_id, "docs/a.txt");
match router.classify(&child) {
ResolvedId::MountChild { cfg, node_id } => {
assert_eq!(cfg.mount_id, mount_id);
assert_eq!(node_id.as_str(), "docs/a.txt");
}
_ => panic!("expected MountChild"),
}
assert!(router.is_mount_id(&child));
}
#[tokio::test]
async fn ext_id_for_unregistered_mount_is_regular() {
let dir = TempDir::new().unwrap();
let mount_id = Uuid::new_v4();
let router = router_with_mount(mount_id, &dir).await;
// A well-formed ext: id but for a DIFFERENT (unknown) mount → Regular,
// so it 404s downstream rather than hitting the repos.
let dangling = encode_child_id(Uuid::new_v4(), "x");
assert!(matches!(router.classify(&dangling), ResolvedId::Regular));
// A plain (non-mount) UUID is also Regular.
assert!(matches!(
router.classify(&Uuid::new_v4().to_string()),
ResolvedId::Regular
));
}
}