feat(mounts): P2 — read-write REST for external mounts

Adds full CRUD on mount contents, mirroring the P1 read pattern (handlers/
services classify; authorization stays in the service via the mount-root
folder grant; the provider does the I/O).

- mkdir / rename / delete / move-within branch inside FolderService and
  FileManagementService (router injected into both)
- streaming upload via a new ExternalUploadService: the upload handler detects a
  mount destination BEFORE the CAS ingest and streams the multipart body
  straight to the provider (no BLAKE3/dedup). `write_stream` now takes a
  lifetime-bound boxed stream so the borrowing multipart field can be passed
  without buffering.
- deletes on mounts are permanent (no trash): the trash-first folder handler
  routes `ext:` ids straight to the provider delete; file delete goes through the
  branched delete_and_cleanup
- cross-backend move/copy (mount ↔ native, or between mounts) is forbidden
  (UnsupportedOperation); the mount root itself cannot be renamed/moved/deleted
- every mutation emits a `target:"audit" event="external_mount.write"` line
- shared mount_dto builders synthesize FolderDto/FileDto from a provider MountStat

Tests: 529 unit + integration tests for mkdir/rename/delete, file rename/delete,
streaming upload, cross-boundary forbid, and stranger-denied — all against real
Postgres + a real provider (testcontainers).
This commit is contained in:
Bradley Nelson
2026-06-25 00:30:10 -06:00
parent 3c31695579
commit 8e3e31da4d
10 changed files with 714 additions and 12 deletions
+99
View File
@@ -0,0 +1,99 @@
//! Builders that synthesize `FolderDto` / `FileDto` from a provider [`MountStat`].
//!
//! Mount entries have no `storage.folders`/`storage.files` row, so the normal
//! `FolderDto::from(Folder)` path doesn't apply. These helpers produce the same
//! DTO shape from a provider stat plus the mount config, with a synthetic `ext:`
//! id and a virtual etag. Shared by the folder/file services and the handlers.
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::external_mount_ports::MountStat;
use crate::application::services::mount_registry::MountConfig;
use crate::domain::services::external_mount_id::{
encode_child_id, virtual_file_etag, virtual_folder_etag,
};
/// Final path segment of a node id (the display name).
fn node_name(node_id: &str) -> &str {
node_id.rsplit('/').next().unwrap_or(node_id)
}
/// Emit the structured audit line for a mount mutation (per AGENTS.md). Every
/// write op (upload / mkdir / rename / delete / move) calls this.
pub fn audit_mount_write(action: &str, cfg: &MountConfig, caller_id: Uuid, node_id: &str) {
tracing::info!(
target: "audit",
event = "external_mount.write",
action,
mount_id = %cfg.mount_id,
caller_id = %caller_id,
node_id = %node_id,
reason = "external_mount_op",
"👮🏻‍♂️ external mount mutation",
);
}
/// The id-string of a mount entry's parent: the parent's `ext:` id, or the
/// mount-root folder UUID when the entry is a direct child of the root.
pub fn mount_parent_id(cfg: &MountConfig, node_id: &str) -> String {
match node_id.rsplit_once('/') {
Some((parent, _)) => encode_child_id(cfg.mount_id, parent),
None => cfg.mount_id.to_string(),
}
}
/// Build a `FolderDto` for a mount directory from its stat. `parent_id` is the
/// id-string of the containing directory (mount-root UUID or an `ext:` id).
pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FolderDto {
FolderDto {
etag: virtual_folder_etag(stat.modified_at),
id: encode_child_id(cfg.mount_id, stat.node_id.clone()),
name: node_name(stat.node_id.as_str()).to_owned(),
path: String::new(),
parent_id: Some(parent_id.to_owned()),
owner_id: Some(cfg.owner_id.to_string()),
drive_id: cfg.drive_id,
created_at: stat.created_at,
modified_at: stat.modified_at,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
}
}
/// Build a `FileDto` for a mount file from its stat. Virtual files have no blob
/// hash (`content_hash` empty) and a size+mtime etag.
pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FileDto {
let name = node_name(stat.node_id.as_str());
let mime = stat.mime_type.as_str();
FileDto {
id: encode_child_id(cfg.mount_id, stat.node_id.clone()),
name: name.to_owned(),
path: String::new(),
size: stat.size,
mime_type: Arc::from(mime),
folder_id: Some(parent_id.to_owned()),
created_at: stat.created_at,
modified_at: stat.modified_at,
icon_class: Arc::from(icon_class_for(name, mime)),
icon_special_class: Arc::from(icon_special_class_for(name, mime)),
category: Arc::from(category_for(name, mime)),
size_formatted: format_file_size(stat.size),
owner_id: Some(cfg.owner_id.to_string()),
sort_date: None,
content_hash: String::new(),
etag: virtual_file_etag(stat.size, stat.modified_at),
created_by: None,
updated_by: None,
}
}