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
@@ -333,7 +333,7 @@ impl ExternalMountProvider for LocalFsMountProvider {
&self,
parent: &NodeId,
name: &str,
mut body: MountByteStream,
mut body: MountByteStream<'_>,
) -> Result<MountStat, DomainError> {
self.ensure_writable()?;
validate_name(name)?;
@@ -535,7 +535,7 @@ mod tests {
assert!(d.is_dir);
// write into it
let body: MountByteStream =
let body: MountByteStream<'static> =
Box::pin(stream::once(async { Ok(Bytes::from_static(b"data")) }));
let f = p
.write_stream(&NodeId("folder".into()), "x.txt", body)
@@ -744,7 +744,7 @@ mod tests {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("f.txt"), b"old-and-longer").unwrap();
let p = provider(dir.path());
let body: MountByteStream =
let body: MountByteStream<'static> =
Box::pin(stream::once(async { Ok(Bytes::from_static(b"new")) }));
let s = p
.write_stream(&NodeId("".into()), "f.txt", body)
@@ -762,7 +762,7 @@ mod tests {
use futures::stream;
let dir = tempdir().unwrap();
let p = provider(dir.path());
let body: MountByteStream = Box::pin(stream::iter(vec![
let body: MountByteStream<'static> = Box::pin(stream::iter(vec![
Ok(Bytes::from_static(b"foo")),
Ok(Bytes::from_static(b"bar")),
Ok(Bytes::from_static(b"baz")),
@@ -783,7 +783,7 @@ mod tests {
use futures::stream;
let dir = tempdir().unwrap();
let p = provider(dir.path());
let body: MountByteStream = Box::pin(stream::iter(vec![
let body: MountByteStream<'static> = Box::pin(stream::iter(vec![
Ok(Bytes::from_static(b"partial")),
Err(std::io::Error::other("boom")),
]));
@@ -937,7 +937,7 @@ mod tests {
use futures::stream;
let dir = tempdir().unwrap();
let p = provider(dir.path());
let body: MountByteStream = Box::pin(stream::empty());
let body: MountByteStream<'static> = Box::pin(stream::empty());
let s = p
.write_stream(&NodeId("".into()), "empty.txt", body)
.await
@@ -1001,7 +1001,8 @@ mod tests {
std::fs::create_dir(dir.path().join("dest")).unwrap();
let p = LocalFsMountProvider::new(dir.path(), true).unwrap();
use futures::stream;
let body: MountByteStream = Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) }));
let body: MountByteStream<'static> =
Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) }));
assert!(
p.write_stream(&NodeId("".into()), "n.txt", body)
.await