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:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user