feat(modules): group-files schema + owner-relative path helpers + repo (§11.6 files C1)

Extends the §11.6 shared-collection machinery to the filesystem-backed
`files` blob store (after KV/0053 and docs/0054).

- 0055_group_files.sql: widen the group_collections kind CHECK to admit
  'files'; add a group-keyed `group_files` metadata table mirroring
  `files` (0018), CASCADE on group delete.
- files_repo: generalize the four path/IO free functions
  (shard_dir_at / final_path_at / write_atomic_at / read_verify_at) to
  take an owner-relative dir instead of `app_id`, and make them (plus the
  cursor helpers) pub(crate). The security-sensitive atomic-write +
  checksum-on-read mechanics now have a single source shared by the app
  and group repos. App behavior is unchanged (apps shard at `<app_id>/`).
- group_files_repo: FsGroupFilesRepo keyed by group_id, blobs at
  `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a
  `groups/` infix disjoint from the per-app subtree, so the existing
  recursive orphan sweeper covers it with zero change.

Schema snapshot blessed (55 migrations); app-files fs tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 19:35:34 +02:00
parent f552238586
commit 779d906d75
5 changed files with 524 additions and 30 deletions

View File

@@ -211,7 +211,7 @@ impl FsFilesRepo {
}
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
final_path_at(&self.config.root, app_id, collection, id)
final_path_at(&self.config.root, &app_owner_dir(app_id), collection, id)
}
fn write_atomic(
@@ -221,29 +221,58 @@ impl FsFilesRepo {
id: Uuid,
bytes: &[u8],
) -> Result<String, FilesRepoError> {
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
write_atomic_at(
&self.config.root,
&app_owner_dir(app_id),
collection,
id,
bytes,
)
}
}
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
/// The owner-relative subdirectory of an **app**'s blobs under `<root>/files/`:
/// just `<app_id>`. Group-shared blobs (§11.6) shard at `groups/<group_id>`
/// instead (see `group_files_repo::group_owner_dir`) — a disjoint subtree under
/// the same `files/` base, so the orphan sweeper covers both with one walk.
pub(crate) fn app_owner_dir(app_id: AppId) -> PathBuf {
PathBuf::from(app_id.into_inner().to_string())
}
/// `<root>/files/<owner_rel>/<collection>/<id[0:2]>/`. `owner_rel` is built
/// by the caller from a server-generated id (`<app_id>` or `groups/<group_id>`)
/// — never user input — and `collection` is path-guarded one layer up, so no
/// component can escape the `files/` base.
pub(crate) fn shard_dir_at(
root: &Path,
owner_rel: &Path,
collection: &str,
id_str: &str,
) -> PathBuf {
root.join("files")
.join(app_id.into_inner().to_string())
.join(owner_rel)
.join(collection)
.join(&id_str[..2])
}
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
pub(crate) fn final_path_at(
root: &Path,
owner_rel: &Path,
collection: &str,
id: Uuid,
) -> PathBuf {
let id_str = id.to_string();
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
}
/// Steps 26 of the atomic-write protocol. Returns the lowercase hex
/// SHA-256 of the bytes (computed in a single pass over the in-memory
/// buffer — the file is never re-read). Free function so the fs
/// mechanics are unit-testable without a Postgres pool.
fn write_atomic_at(
/// buffer — the file is never re-read). `pub(crate)` + owner-relative so the
/// app and group-shared (§11.6) repos share this single source for the
/// security-sensitive write+checksum mechanics, unit-testable without a pool.
pub(crate) fn write_atomic_at(
root: &Path,
app_id: AppId,
owner_rel: &Path,
collection: &str,
id: Uuid,
bytes: &[u8],
@@ -251,7 +280,7 @@ fn write_atomic_at(
use std::io::Write as _;
let id_str = id.to_string();
let dir = shard_dir_at(root, app_id, collection, &id_str);
let dir = shard_dir_at(root, owner_rel, collection, &id_str);
create_dir_all_secure(&dir)?;
// Single-pass checksum over the in-memory buffer.
@@ -278,15 +307,16 @@ fn write_atomic_at(
/// Read + checksum-verify the bytes at the given path-set. Free
/// function mirror of the `get` read path. Returns `Corrupted` when the
/// bytes are missing or don't match `expected_checksum`.
fn read_verify_at(
/// bytes are missing or don't match `expected_checksum`. `pub(crate)` +
/// owner-relative — shared with the group-files (§11.6) repo.
pub(crate) fn read_verify_at(
root: &Path,
app_id: AppId,
owner_rel: &Path,
collection: &str,
id: Uuid,
expected_checksum: &str,
) -> Result<Vec<u8>, FilesRepoError> {
let path = final_path_at(root, app_id, collection, id);
let path = final_path_at(root, owner_rel, collection, id);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
@@ -383,7 +413,13 @@ impl FilesRepo for FsFilesRepo {
let Some((stored_checksum,)) = row else {
return Ok(None);
};
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
let bytes = read_verify_at(
&self.config.root,
&app_owner_dir(app_id),
collection,
id,
&stored_checksum,
)?;
Ok(Some(bytes))
}
@@ -555,11 +591,11 @@ fn hex_lower(bytes: &[u8]) -> String {
s
}
fn encode_cursor(last_id: Uuid) -> String {
pub(crate) fn encode_cursor(last_id: Uuid) -> String {
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
pub(crate) fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| FilesRepoError::InvalidCursor)?;
@@ -672,13 +708,13 @@ mod tests {
let id = Uuid::new_v4();
let bytes = b"hello picloud files".to_vec();
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
let checksum = write_atomic_at(&root, &app_owner_dir(app), "avatars", id, &bytes).unwrap();
// Single-pass checksum matches an independent hash of the bytes.
let mut h = Sha256::new();
h.update(&bytes);
assert_eq!(checksum, hex_lower(&h.finalize()));
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
let read = read_verify_at(&root, &app_owner_dir(app), "avatars", id, &checksum).unwrap();
assert_eq!(read, bytes);
std::fs::remove_dir_all(&root).ok();
@@ -689,13 +725,13 @@ mod tests {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
let checksum = write_atomic_at(&root, &app_owner_dir(app), "c", id, b"original").unwrap();
// Mutate the bytes behind the repo's back.
let path = final_path_at(&root, app, "c", id);
let path = final_path_at(&root, &app_owner_dir(app), "c", id);
std::fs::write(&path, b"tampered").unwrap();
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, &checksum).unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
@@ -707,7 +743,7 @@ mod tests {
let app = AppId::new();
let id = Uuid::new_v4();
// No write — the file never existed.
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, "deadbeef").unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
}
@@ -717,10 +753,10 @@ mod tests {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
@@ -739,7 +775,7 @@ mod tests {
let app = AppId::new();
let id = Uuid::new_v4();
let id_str = id.to_string();
let path = final_path_at(&root, app, "col", id);
let path = final_path_at(&root, &app_owner_dir(app), "col", id);
let shard = &id_str[..2];
assert!(path
.to_string_lossy()
@@ -753,9 +789,9 @@ mod tests {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
std::fs::remove_dir_all(&root).ok();