diff --git a/crates/manager-core/src/files_api.rs b/crates/manager-core/src/files_api.rs index dd4f90c..d63f0a4 100644 --- a/crates/manager-core/src/files_api.rs +++ b/crates/manager-core/src/files_api.rs @@ -22,7 +22,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; -use picloud_shared::{AppId, Principal}; +use picloud_shared::{validate_files_collection, AppId, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; @@ -88,11 +88,13 @@ async fn list_files( Capability::AppFilesRead(app_id), ) .await?; - if q.collection.trim().is_empty() { - return Err(FilesApiError::Invalid( - "collection must not be empty".into(), - )); - } + // Audit 2026-06-11 (F-FS-002) — the admin endpoints hit the repo + // directly (not via FilesServiceImpl, which validates), so a + // traversal-shaped collection would otherwise only be caught by the + // repo's `guard_collection` as an opaque 500. Validate here for a + // clean 422 and as the outer layer of the belt-and-suspenders guard. + validate_files_collection(&q.collection) + .map_err(|e| FilesApiError::Invalid(e.to_string()))?; let page = s .files .list( @@ -149,6 +151,7 @@ async fn get_file( Capability::AppFilesRead(app_id), ) .await?; + validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?; let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?; let meta = s .files @@ -193,6 +196,7 @@ async fn delete_file( Capability::AppFilesWrite(app_id), ) .await?; + validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?; let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?; if s.files.delete(app_id, &collection, id).await?.is_none() { return Err(FilesApiError::NotFound); diff --git a/crates/manager-core/src/files_repo.rs b/crates/manager-core/src/files_repo.rs index d20225c..7579102 100644 --- a/crates/manager-core/src/files_repo.rs +++ b/crates/manager-core/src/files_repo.rs @@ -350,6 +350,7 @@ impl FilesRepo for FsFilesRepo { collection: &str, id: Uuid, ) -> Result, FilesRepoError> { + Self::guard_collection(collection)?; let row: Option = sqlx::query_as( "SELECT id, collection, name, content_type, size_bytes, \ checksum_sha256, created_at, updated_at \ @@ -369,6 +370,7 @@ impl FilesRepo for FsFilesRepo { collection: &str, id: Uuid, ) -> Result>, FilesRepoError> { + Self::guard_collection(collection)?; let row: Option<(String,)> = sqlx::query_as( "SELECT checksum_sha256 FROM files \ WHERE app_id = $1 AND collection = $2 AND id = $3", @@ -434,6 +436,7 @@ impl FilesRepo for FsFilesRepo { collection: &str, id: Uuid, ) -> Result, FilesRepoError> { + Self::guard_collection(collection)?; // SELECT + DELETE in one tx; unlink afterwards (outside the tx). let mut tx = self.pool.begin().await?; let row: Option = sqlx::query_as( @@ -479,6 +482,7 @@ impl FilesRepo for FsFilesRepo { cursor: Option<&str>, limit: u32, ) -> Result { + Self::guard_collection(collection)?; let limit = if limit == 0 { FILES_LIST_DEFAULT_LIMIT } else {