//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection //! registry underneath the `picloud_shared::GroupFilesService` trait that scripts //! reach via the `files::shared_collection("name")` Rhai handle (§11.6). //! //! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`, //! reads-open / writes-authed authz) with the files surface (collection //! path-validation, field + size-cap validation, content-type sanitization). No //! event emission in the MVP (the "group trigger has no app to watch" deferral). use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage, GroupFilesError, GroupFilesService, GroupId, NewFile, SdkCallCx, }; use uuid::Uuid; use crate::authz::{self, AuthzRepo, Capability}; use crate::files_repo::FileUpdated; use crate::group_collection_repo::GroupCollectionResolver; use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError}; /// The registry `kind` this service resolves. const KIND_FILES: &str = "files"; pub struct GroupFilesServiceImpl { repo: Arc, resolver: Arc, authz: Arc, max_file_size_bytes: usize, } impl GroupFilesServiceImpl { #[must_use] pub fn new( repo: Arc, resolver: Arc, authz: Arc, max_file_size_bytes: usize, ) -> Self { Self { repo, resolver, authz, max_file_size_bytes, } } /// The structural boundary: resolve the collection to its owning group /// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no /// ancestor group declares it. async fn owning_group( &self, cx: &SdkCallCx, collection: &str, ) -> Result { self.resolver .resolve_owning_group(cx.app_id, collection, KIND_FILES) .await .map_err(|e| GroupFilesError::Backend(e.to_string()))? .ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string())) } async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> { authz::script_gate( &*self.authz, cx, Capability::GroupFilesRead(group_id), || GroupFilesError::Forbidden, GroupFilesError::Backend, ) .await } async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> { // Fails closed for an anonymous principal — shared mutation always needs // an authenticated editor+ on the owning group. authz::script_gate_require_principal( &*self.authz, cx, Capability::GroupFilesWrite(group_id), || GroupFilesError::Forbidden, GroupFilesError::Backend, ) .await } } /// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they /// simply mean "no such file" (mirrors `files_service::parse_id`). fn parse_id(id: &str) -> Option { Uuid::parse_str(id).ok() } impl From for GroupFilesError { fn from(e: GroupFilesRepoError) -> Self { match e { GroupFilesRepoError::Corrupted => Self::Corrupted, GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c), other => Self::Backend(other.to_string()), } } } #[async_trait] impl GroupFilesService for GroupFilesServiceImpl { async fn create( &self, cx: &SdkCallCx, collection: &str, mut new: NewFile, ) -> Result { validate_files_collection(collection)?; let group_id = self.owning_group(cx, collection).await?; new.validate(self.max_file_size_bytes)?; // Coerce dangerous render types to application/octet-stream after the // shape checks pass (same as app files, audit 2026-06-11 C-2). new.content_type = sanitize_stored_content_type(&new.content_type); self.check_write(cx, group_id).await?; Ok(self.repo.create(group_id, collection, new).await?.id) } async fn head( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result, GroupFilesError> { validate_files_collection(collection)?; let group_id = self.owning_group(cx, collection).await?; self.check_read(cx, group_id).await?; let Some(uuid) = parse_id(id) else { return Ok(None); }; Ok(self.repo.head(group_id, collection, uuid).await?) } async fn get( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result>, GroupFilesError> { validate_files_collection(collection)?; let group_id = self.owning_group(cx, collection).await?; self.check_read(cx, group_id).await?; let Some(uuid) = parse_id(id) else { return Ok(None); }; Ok(self.repo.get(group_id, collection, uuid).await?) } async fn update( &self, cx: &SdkCallCx, collection: &str, id: &str, mut upd: FileUpdate, ) -> Result<(), GroupFilesError> { validate_files_collection(collection)?; let group_id = self.owning_group(cx, collection).await?; upd.validate(self.max_file_size_bytes)?; if let Some(ct) = upd.content_type.as_deref() { upd.content_type = Some(sanitize_stored_content_type(ct)); } self.check_write(cx, group_id).await?; let Some(uuid) = parse_id(id) else { return Err(GroupFilesError::NotFound); }; match self.repo.update(group_id, collection, uuid, upd).await? { Some(FileUpdated { .. }) => Ok(()), None => Err(GroupFilesError::NotFound), } } async fn delete( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result { validate_files_collection(collection)?; let group_id = self.owning_group(cx, collection).await?; self.check_write(cx, group_id).await?; let Some(uuid) = parse_id(id) else { return Ok(false); }; Ok(self .repo .delete(group_id, collection, uuid) .await? .is_some()) } async fn list( &self, cx: &SdkCallCx, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result { validate_files_collection(collection)?; let group_id = self.owning_group(cx, collection).await?; self.check_read(cx, group_id).await?; Ok(self.repo.list(group_id, collection, cursor, limit).await?) } } // ---------------------------------------------------------------------------- // Tests — in-memory repo + a fake resolver so unit tests don't need Postgres or // 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 crate::group_files_repo::GroupFilesRepoError; use chrono::Utc; use picloud_shared::{ AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, UserId, }; use std::collections::HashMap; use tokio::sync::Mutex; #[derive(Default)] struct InMemoryGroupFilesRepo { #[allow(clippy::type_complexity)] data: Mutex)>>, } fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta { FileMeta { id, collection: collection.to_string(), name: new.name.clone(), content_type: new.content_type.clone(), size: new.data.len() as u64, checksum: String::new(), created_at: Utc::now(), updated_at: Utc::now(), } } #[async_trait] impl GroupFilesRepo for InMemoryGroupFilesRepo { async fn create( &self, group_id: GroupId, collection: &str, new: NewFile, ) -> Result { let id = Uuid::new_v4(); let m = meta(id, collection, &new); self.data.lock().await.insert( (group_id, collection.to_string(), id), (m.clone(), new.data), ); Ok(m) } async fn head( &self, group_id: GroupId, collection: &str, id: Uuid, ) -> Result, GroupFilesRepoError> { Ok(self .data .lock() .await .get(&(group_id, collection.to_string(), id)) .map(|(m, _)| m.clone())) } async fn get( &self, group_id: GroupId, collection: &str, id: Uuid, ) -> Result>, GroupFilesRepoError> { Ok(self .data .lock() .await .get(&(group_id, collection.to_string(), id)) .map(|(_, b)| b.clone())) } async fn update( &self, group_id: GroupId, collection: &str, id: Uuid, upd: FileUpdate, ) -> Result, GroupFilesRepoError> { let mut data = self.data.lock().await; let key = (group_id, collection.to_string(), id); let Some((prev, _)) = data.get(&key).cloned() else { return Ok(None); }; let mut m = prev.clone(); m.size = upd.data.len() as u64; if let Some(n) = &upd.name { m.name = n.clone(); } data.insert(key, (m.clone(), upd.data)); Ok(Some(FileUpdated { new: m, prev })) } async fn delete( &self, group_id: GroupId, collection: &str, id: Uuid, ) -> Result, GroupFilesRepoError> { Ok(self .data .lock() .await .remove(&(group_id, collection.to_string(), id)) .map(|(m, _)| m)) } async fn list( &self, group_id: GroupId, collection: &str, _cursor: Option<&str>, _limit: u32, ) -> Result { let files = self .data .lock() .await .iter() .filter(|((g, c, _), _)| *g == group_id && c == collection) .map(|(_, (m, _))| m.clone()) .collect(); Ok(FilesListPage { files, next_cursor: None, }) } } #[derive(Default)] struct FakeResolver { map: HashMap<(AppId, String), GroupId>, } #[async_trait] impl GroupCollectionResolver for FakeResolver { async fn resolve_owning_group( &self, app_id: AppId, name: &str, _kind: &str, ) -> Result, sqlx::Error> { Ok(self.map.get(&(app_id, name.to_lowercase())).copied()) } } #[derive(Default)] struct DenyingAuthzRepo; #[async_trait] impl AuthzRepo for DenyingAuthzRepo { async fn membership( &self, _user_id: UserId, _app_id: AppId, ) -> Result, AuthzError> { Ok(None) } } fn cx_with(app_id: AppId, principal: Option) -> SdkCallCx { SdkCallCx { app_id, script_id: ScriptId::new(), principal, execution_id: ExecutionId::new(), request_id: RequestId::new(), trigger_depth: 0, root_execution_id: ExecutionId::new(), is_dead_letter_handler: false, event: None, } } fn owner() -> Principal { Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Owner, scopes: None, app_binding: None, } } fn svc(resolver: FakeResolver) -> GroupFilesServiceImpl { GroupFilesServiceImpl::new( Arc::new(InMemoryGroupFilesRepo::default()), Arc::new(resolver), Arc::new(DenyingAuthzRepo), 10 * 1024 * 1024, ) } 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 unrelated_app_gets_collection_not_shared() { let app_a = AppId::new(); let app_b = AppId::new(); let group = GroupId::new(); let mut resolver = FakeResolver::default(); resolver.map.insert((app_a, "assets".into()), group); let files = svc(resolver); let cx_a = cx_with(app_a, Some(owner())); let id = files .create(&cx_a, "assets", new_file("a.txt", b"hi")) .await .unwrap(); assert!(files .get(&cx_a, "assets", &id.to_string()) .await .unwrap() .is_some()); let cx_b = cx_with(app_b, Some(owner())); let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err(); assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets")); } #[tokio::test] async fn reads_open_writes_require_auth() { let app = AppId::new(); let group = GroupId::new(); let mut resolver = FakeResolver::default(); resolver.map.insert((app, "assets".into()), group); let files = svc(resolver); let owner_cx = cx_with(app, Some(owner())); let id = files .create(&owner_cx, "assets", new_file("a.txt", b"hi")) .await .unwrap(); // Anonymous READ (get) is allowed. let anon = cx_with(app, None); let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap(); assert_eq!(bytes, Some(b"hi".to_vec())); // Anonymous WRITE (create) fails closed. let err = files .create(&anon, "assets", new_file("x.txt", b"x")) .await .unwrap_err(); assert!(matches!(err, GroupFilesError::Forbidden)); } #[tokio::test] async fn oversize_blob_rejected() { let app = AppId::new(); let group = GroupId::new(); let mut resolver = FakeResolver::default(); resolver.map.insert((app, "assets".into()), group); let files = GroupFilesServiceImpl::new( Arc::new(InMemoryGroupFilesRepo::default()), Arc::new(resolver), Arc::new(DenyingAuthzRepo), 8, // tiny cap ); let cx = cx_with(app, Some(owner())); let err = files .create(&cx, "assets", new_file("big", b"123456789")) .await .unwrap_err(); assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. })); } }