//! `FilesServiceImpl` — wires the `FilesRepo` underneath the //! `picloud_shared::FilesService` trait scripts see via the Rhai //! bridge. //! //! Layers added here (vs the raw repo), mirroring `KvServiceImpl`: //! 1. Collection validation (empty + path-traversal) and field / //! size-cap validation at the SDK boundary. //! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run //! `authz::require(...)`; when it's `None` (public HTTP) we skip. //! Cross-app isolation is unaffected — every repo call is keyed by //! `cx.app_id`, never an argument. //! 3. `ServiceEvent` emission after each mutation (`create` / //! `update` / `delete`). The payload is the file **metadata**, not //! the blob bytes (files are too big for trigger payloads). use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ validate_files_collection, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService, NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter, }; use uuid::Uuid; use crate::authz::{self, AuthzRepo, Capability}; use crate::files_repo::{FileUpdated, FilesRepo, FilesRepoError}; pub struct FilesServiceImpl { repo: Arc, authz: Arc, events: Arc, max_file_size_bytes: usize, } impl FilesServiceImpl { #[must_use] pub fn new( repo: Arc, authz: Arc, events: Arc, max_file_size_bytes: usize, ) -> Self { Self { repo, authz, events, max_file_size_bytes, } } async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> { if let Some(ref principal) = cx.principal { authz::require(&*self.authz, principal, Capability::AppFilesRead(cx.app_id)) .await .map_err(|_| FilesError::Forbidden)?; } Ok(()) } async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> { if let Some(ref principal) = cx.principal { authz::require( &*self.authz, principal, Capability::AppFilesWrite(cx.app_id), ) .await .map_err(|_| FilesError::Forbidden)?; } Ok(()) } /// Best-effort `ServiceEvent` emission. A failed emit is logged but /// never rolls back the (already-durable) file write. async fn emit( &self, cx: &SdkCallCx, op: &'static str, collection: &str, meta: &FileMeta, old: Option<&FileMeta>, ) { let payload = serde_json::to_value(meta).ok(); let old_payload = old.and_then(|m| serde_json::to_value(m).ok()); if let Err(e) = self .events .emit( cx, ServiceEvent { source: "files", op, collection: Some(collection.to_string()), key: Some(meta.id.to_string()), payload, old_payload, }, ) .await { tracing::warn!(error = %e, source = "files", op, "event emit failed"); } } } /// Parse a script-supplied id. Invalid UUIDs aren't an error shape the /// SDK exposes — for reads/deletes they simply mean "no such file". fn parse_id(id: &str) -> Option { Uuid::parse_str(id).ok() } impl From for FilesError { fn from(e: FilesRepoError) -> Self { match e { FilesRepoError::Corrupted => Self::Corrupted, FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c), other => Self::Backend(other.to_string()), } } } #[async_trait] impl FilesService for FilesServiceImpl { async fn create( &self, cx: &SdkCallCx, collection: &str, new: NewFile, ) -> Result { validate_files_collection(collection)?; self.check_write(cx).await?; new.validate(self.max_file_size_bytes)?; let meta = self.repo.create(cx.app_id, collection, new).await?; self.emit(cx, "create", collection, &meta, None).await; Ok(meta.id) } async fn head( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result, FilesError> { validate_files_collection(collection)?; self.check_read(cx).await?; let Some(uuid) = parse_id(id) else { return Ok(None); }; Ok(self.repo.head(cx.app_id, collection, uuid).await?) } async fn get( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result>, FilesError> { validate_files_collection(collection)?; self.check_read(cx).await?; let Some(uuid) = parse_id(id) else { return Ok(None); }; Ok(self.repo.get(cx.app_id, collection, uuid).await?) } async fn update( &self, cx: &SdkCallCx, collection: &str, id: &str, upd: FileUpdate, ) -> Result<(), FilesError> { validate_files_collection(collection)?; self.check_write(cx).await?; upd.validate(self.max_file_size_bytes)?; let Some(uuid) = parse_id(id) else { return Err(FilesError::NotFound); }; match self.repo.update(cx.app_id, collection, uuid, upd).await? { Some(FileUpdated { new, prev }) => { self.emit(cx, "update", collection, &new, Some(&prev)).await; Ok(()) } None => Err(FilesError::NotFound), } } async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result { validate_files_collection(collection)?; self.check_write(cx).await?; let Some(uuid) = parse_id(id) else { return Ok(false); }; match self.repo.delete(cx.app_id, collection, uuid).await? { Some(meta) => { // On delete, the top-level metadata AND `prev` both carry // the deleted row (per docs/v1.1.x design + the brief). self.emit(cx, "delete", collection, &meta, Some(&meta)) .await; Ok(true) } None => Ok(false), } } async fn list( &self, cx: &SdkCallCx, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result { validate_files_collection(collection)?; self.check_read(cx).await?; Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?) } } // ---------------------------------------------------------------------------- // Tests — in-memory FilesRepo so unit tests need neither Postgres nor a // filesystem. The on-disk atomic-write / checksum mechanics are covered // by the tempdir tests in `files_repo.rs`. // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::authz::{AuthzError, AuthzRepo}; use async_trait::async_trait; use chrono::Utc; use picloud_shared::{ AdminUserId, AppId, AppRole, EmitError, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, ServiceEvent, UserId, }; use std::collections::BTreeMap; use std::sync::Mutex as StdMutex; use tokio::sync::Mutex; /// In-memory FilesRepo keyed by (app, collection, id). Stores the /// metadata + bytes together so cross-app isolation and round-trips /// can be checked without disk. #[derive(Default)] struct InMemoryFilesRepo { #[allow(clippy::type_complexity)] data: Mutex)>>, } fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(bytes); let out = h.finalize(); let mut s = String::new(); for b in out { use std::fmt::Write as _; let _ = write!(s, "{b:02x}"); } s } #[async_trait] impl FilesRepo for InMemoryFilesRepo { async fn create( &self, app_id: AppId, collection: &str, new: NewFile, ) -> Result { let id = Uuid::new_v4(); let now = Utc::now(); let meta = FileMeta { id, collection: collection.to_string(), name: new.name.clone(), content_type: new.content_type.clone(), size: new.data.len() as u64, checksum: sha256_hex(&new.data), created_at: now, updated_at: now, }; self.data.lock().await.insert( (app_id, collection.to_string(), id), (meta.clone(), new.data), ); Ok(meta) } async fn head( &self, app_id: AppId, collection: &str, id: Uuid, ) -> Result, FilesRepoError> { Ok(self .data .lock() .await .get(&(app_id, collection.to_string(), id)) .map(|(m, _)| m.clone())) } async fn get( &self, app_id: AppId, collection: &str, id: Uuid, ) -> Result>, FilesRepoError> { Ok(self .data .lock() .await .get(&(app_id, collection.to_string(), id)) .map(|(_, b)| b.clone())) } async fn update( &self, app_id: AppId, collection: &str, id: Uuid, upd: FileUpdate, ) -> Result, FilesRepoError> { let mut data = self.data.lock().await; let key = (app_id, collection.to_string(), id); let Some((prev_meta, _)) = data.get(&key).cloned() else { return Ok(None); }; let now = Utc::now(); let new_meta = FileMeta { id, collection: collection.to_string(), name: upd.name.clone().unwrap_or_else(|| prev_meta.name.clone()), content_type: upd .content_type .clone() .unwrap_or_else(|| prev_meta.content_type.clone()), size: upd.data.len() as u64, checksum: sha256_hex(&upd.data), created_at: prev_meta.created_at, updated_at: now, }; data.insert(key, (new_meta.clone(), upd.data)); Ok(Some(FileUpdated { new: new_meta, prev: prev_meta, })) } async fn delete( &self, app_id: AppId, collection: &str, id: Uuid, ) -> Result, FilesRepoError> { Ok(self .data .lock() .await .remove(&(app_id, collection.to_string(), id)) .map(|(m, _)| m)) } async fn list( &self, app_id: AppId, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result { let data = self.data.lock().await; let after = cursor.and_then(|c| Uuid::parse_str(c).ok()); let mut metas: Vec = data .iter() .filter(|((a, c, _), _)| *a == app_id && c == collection) .map(|(_, (m, _))| m.clone()) .filter(|m| after.is_none_or(|a| m.id > a)) .collect(); metas.sort_by_key(|m| m.id); let take = (limit.max(1)) as usize; let next_cursor = if metas.len() > take { metas.truncate(take); metas.last().map(|m| m.id.to_string()) } else { None }; Ok(FilesListPage { files: metas, next_cursor, }) } } /// Captures emitted events so tests can assert on fan-out shape. #[derive(Default)] struct CapturingEmitter { events: StdMutex>, } #[async_trait] impl ServiceEventEmitter for CapturingEmitter { async fn emit(&self, _cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { self.events.lock().unwrap().push(event); Ok(()) } } #[derive(Default)] struct DenyingAuthzRepo; #[async_trait] impl AuthzRepo for DenyingAuthzRepo { async fn membership( &self, _user_id: UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(None) } } #[derive(Default)] struct EditorAuthzRepo; #[async_trait] impl AuthzRepo for EditorAuthzRepo { async fn membership( &self, _user_id: UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(Some(AppRole::Editor)) } } fn anon_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, script_id: ScriptId::new(), principal: None, execution_id: ExecutionId::new(), request_id: RequestId::new(), trigger_depth: 0, root_execution_id: ExecutionId::new(), is_dead_letter_handler: false, event: None, } } fn member_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { principal: Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Member, scopes: None, app_binding: None, }), ..anon_cx(app_id) } } fn svc_with(authz: Arc, emitter: Arc) -> FilesServiceImpl { FilesServiceImpl::new( Arc::new(InMemoryFilesRepo::default()), authz, emitter, 10 * 1024 * 1024, ) } fn svc() -> FilesServiceImpl { svc_with( Arc::new(DenyingAuthzRepo), Arc::new(CapturingEmitter::default()), ) } fn new_file(name: &str, data: &[u8]) -> NewFile { NewFile { name: name.to_string(), content_type: "application/octet-stream".to_string(), data: data.to_vec(), } } #[tokio::test] async fn create_then_get_head_round_trips() { let files = svc(); let cx = anon_cx(AppId::new()); let id = files .create(&cx, "avatars", new_file("a.bin", b"hello")) .await .unwrap(); let bytes = files.get(&cx, "avatars", &id.to_string()).await.unwrap(); assert_eq!(bytes, Some(b"hello".to_vec())); let meta = files .head(&cx, "avatars", &id.to_string()) .await .unwrap() .unwrap(); assert_eq!(meta.name, "a.bin"); assert_eq!(meta.size, 5); assert_eq!(meta.checksum, sha256_hex(b"hello")); } #[tokio::test] async fn get_and_head_missing_return_none() { let files = svc(); let cx = anon_cx(AppId::new()); let missing = Uuid::new_v4().to_string(); assert_eq!(files.get(&cx, "c", &missing).await.unwrap(), None); assert!(files.head(&cx, "c", &missing).await.unwrap().is_none()); // Non-UUID id is also "missing", not an error. assert_eq!(files.get(&cx, "c", "not-a-uuid").await.unwrap(), None); } #[tokio::test] async fn update_replaces_content_and_keeps_metadata_when_omitted() { let files = svc(); let cx = anon_cx(AppId::new()); let id = files .create(&cx, "c", new_file("v1.txt", b"one")) .await .unwrap(); files .update( &cx, "c", &id.to_string(), FileUpdate { data: b"two!!".to_vec(), name: None, content_type: None, }, ) .await .unwrap(); let meta = files .head(&cx, "c", &id.to_string()) .await .unwrap() .unwrap(); assert_eq!(meta.name, "v1.txt"); // kept assert_eq!(meta.size, 5); assert_eq!( files.get(&cx, "c", &id.to_string()).await.unwrap(), Some(b"two!!".to_vec()) ); } #[tokio::test] async fn update_missing_throws_not_found() { let files = svc(); let cx = anon_cx(AppId::new()); let err = files .update( &cx, "c", &Uuid::new_v4().to_string(), FileUpdate { data: b"x".to_vec(), name: None, content_type: None, }, ) .await .unwrap_err(); assert!(matches!(err, FilesError::NotFound)); } #[tokio::test] async fn delete_returns_was_present() { let files = svc(); let cx = anon_cx(AppId::new()); let id = files.create(&cx, "c", new_file("f", b"x")).await.unwrap(); assert!(files.delete(&cx, "c", &id.to_string()).await.unwrap()); assert!(!files.delete(&cx, "c", &id.to_string()).await.unwrap()); assert!(!files.delete(&cx, "c", "not-a-uuid").await.unwrap()); } #[tokio::test] async fn empty_collection_rejected() { let files = svc(); let cx = anon_cx(AppId::new()); let err = files .create(&cx, "", new_file("f", b"x")) .await .unwrap_err(); assert!(matches!(err, FilesError::InvalidCollection(_))); } #[tokio::test] async fn traversal_collection_rejected() { let files = svc(); let cx = anon_cx(AppId::new()); for bad in ["../etc", "a/b", "a..b", "x\0y"] { let err = files .create(&cx, bad, new_file("f", b"x")) .await .unwrap_err(); assert!( matches!(err, FilesError::InvalidCollection(_)), "expected reject for {bad:?}" ); } } #[tokio::test] async fn missing_required_fields_have_field_specific_messages() { let files = svc(); let cx = anon_cx(AppId::new()); // name let err = files .create( &cx, "c", NewFile { name: " ".into(), content_type: "text/plain".into(), data: b"x".to_vec(), }, ) .await .unwrap_err(); assert!(matches!(err, FilesError::MissingField("name"))); // content_type let err = files .create( &cx, "c", NewFile { name: "f".into(), content_type: String::new(), data: b"x".to_vec(), }, ) .await .unwrap_err(); assert!(matches!(err, FilesError::MissingField("content_type"))); // data let err = files .create( &cx, "c", NewFile { name: "f".into(), content_type: "text/plain".into(), data: vec![], }, ) .await .unwrap_err(); assert!(matches!(err, FilesError::MissingField("data"))); } #[tokio::test] async fn name_and_content_type_length_caps_enforced() { let files = svc(); let cx = anon_cx(AppId::new()); let long_name = "x".repeat(256); let err = files .create(&cx, "c", new_file(&long_name, b"x")) .await .unwrap_err(); assert!(matches!(err, FilesError::NameTooLong(256))); let err = files .create( &cx, "c", NewFile { name: "f".into(), content_type: "x".repeat(128), data: b"x".to_vec(), }, ) .await .unwrap_err(); assert!(matches!(err, FilesError::ContentTypeTooLong(128))); } #[tokio::test] async fn per_file_size_cap_enforced() { let files = FilesServiceImpl::new( Arc::new(InMemoryFilesRepo::default()), Arc::new(DenyingAuthzRepo), Arc::new(CapturingEmitter::default()), 8, // tiny cap ); let cx = anon_cx(AppId::new()); let err = files .create(&cx, "c", new_file("big", b"123456789")) .await .unwrap_err(); assert!(matches!(err, FilesError::TooLarge { limit: 8, .. })); } #[tokio::test] async fn cross_app_isolation() { let files = svc(); let app_a = AppId::new(); let app_b = AppId::new(); let cx_a = anon_cx(app_a); let cx_b = anon_cx(app_b); let id = files .create(&cx_a, "shared", new_file("f", b"from-a")) .await .unwrap(); // app B cannot see app A's file by id. assert_eq!( files.get(&cx_b, "shared", &id.to_string()).await.unwrap(), None ); assert!(files .head(&cx_b, "shared", &id.to_string()) .await .unwrap() .is_none()); let page_b = files.list(&cx_b, "shared", None, 100).await.unwrap(); assert!(page_b.files.is_empty()); // app A still sees it. assert!(files .get(&cx_a, "shared", &id.to_string()) .await .unwrap() .is_some()); } #[tokio::test] async fn anonymous_cx_skips_authz() { let files = svc(); // DenyingAuthzRepo let cx = anon_cx(AppId::new()); // No principal → no authz check, even with a denying repo. files.create(&cx, "c", new_file("f", b"x")).await.unwrap(); } #[tokio::test] async fn member_without_role_is_forbidden() { let files = svc(); // DenyingAuthzRepo let cx = member_cx(AppId::new()); let err = files .create(&cx, "c", new_file("f", b"x")) .await .unwrap_err(); assert!(matches!(err, FilesError::Forbidden)); } #[tokio::test] async fn member_with_editor_role_allowed() { let files = svc_with( Arc::new(EditorAuthzRepo), Arc::new(CapturingEmitter::default()), ); let cx = member_cx(AppId::new()); files.create(&cx, "c", new_file("f", b"x")).await.unwrap(); } #[tokio::test] async fn mutations_emit_events_with_correct_prev() { let emitter = Arc::new(CapturingEmitter::default()); let files = svc_with(Arc::new(DenyingAuthzRepo), emitter.clone()); let cx = anon_cx(AppId::new()); let id = files.create(&cx, "c", new_file("f", b"one")).await.unwrap(); files .update( &cx, "c", &id.to_string(), FileUpdate { data: b"two".to_vec(), name: None, content_type: None, }, ) .await .unwrap(); files.delete(&cx, "c", &id.to_string()).await.unwrap(); let events = emitter.events.lock().unwrap(); assert_eq!(events.len(), 3); // create: prev is None assert_eq!(events[0].op, "create"); assert_eq!(events[0].source, "files"); assert!(events[0].old_payload.is_none()); assert!(events[0].payload.is_some()); // update: prev is the prior metadata assert_eq!(events[1].op, "update"); assert!(events[1].old_payload.is_some()); // delete: prev is the deleted metadata (payload == old_payload) assert_eq!(events[2].op, "delete"); assert_eq!(events[2].payload, events[2].old_payload); assert!(events[2].payload.is_some()); } #[tokio::test] async fn list_cursor_paginates() { let files = svc(); let cx = anon_cx(AppId::new()); for i in 0..5 { files .create(&cx, "c", new_file(&format!("f{i}"), b"x")) .await .unwrap(); } let p1 = files.list(&cx, "c", None, 2).await.unwrap(); assert_eq!(p1.files.len(), 2); assert!(p1.next_cursor.is_some()); let p2 = files .list(&cx, "c", p1.next_cursor.as_deref(), 2) .await .unwrap(); assert_eq!(p2.files.len(), 2); let p3 = files .list(&cx, "c", p2.next_cursor.as_deref(), 2) .await .unwrap(); assert_eq!(p3.files.len(), 1); assert!(p3.next_cursor.is_none()); } }