An app's OWN store had no ceiling of any kind — only the group-SHARED
collections did, which is the inversion of what you'd expect. And
`authz::script_gate` returns `Ok(())` when there is no principal, so a
PUBLIC UNAUTHENTICATED route could reach `files::collection(..).create(..)`
and write blobs in a loop until the disk filled. `PICLOUD_FILES_MAX_FILE_SIZE_BYTES`
caps ONE file at 100 MB; nothing capped the count. Same for KV keys and docs
against Postgres.
Ceilings: `PICLOUD_APP_KV_MAX_ROWS` / `PICLOUD_APP_DOCS_MAX_ROWS` (100k) and
`PICLOUD_APP_FILES_MAX_TOTAL_BYTES` (10 GiB).
They are enforced DIFFERENTLY from the group ones, on purpose — porting the
group design as-is would have been a serious regression:
* KV/docs check a ROW count, on INSERT only, with NO lock. `kv::set` is the
hottest write path in the system; taking a per-app advisory lock on every
set would serialize an app's entire data plane, and a `SUM(...)` scan would
make write cost grow with the app's stored size. What the lock buys is
small — unlocked overshoot is bounded by write concurrency (32) against a
ceiling of 100k, ~0.03%. These are anti-DoS rails, not billing. An update
adds no row and is bounded by the per-value cap, so it pays nothing at all.
* No per-app BYTE ceiling for KV/docs: every value is already capped at
`PICLOUD_KV_MAX_VALUE_BYTES`, so `max_rows x max_value_bytes` is ALREADY a
finite bound. A second scan would buy nothing.
* FILES are the exception and DO lock + sum: one blob may be 100 MB, so 32
racing uploads could overshoot by gigabytes of real disk, and a file write
is heavy enough that the lock and the SUM are lost in the noise. Checked on
create AND update — an update that skipped the ceiling would be a free
bypass (the same bug just fixed for group files: grow a 1-byte file to
100 MB, repeat).
`group_quota` is renamed `quota`: it now serves both owners, and the module
doc is where the two enforcement strategies are contrasted.
Pinned by tests/atomic_write.rs: the key ceiling refuses a new key while
still allowing an update at the ceiling (rejecting updates would brick an app
the moment it filled up — worse than the DoS the rail exists to stop), and 10
concurrent uploads cannot exceed the disk ceiling, nor can an update grow past it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
876 lines
27 KiB
Rust
876 lines
27 KiB
Rust
//! `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::{
|
|
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesError,
|
|
FilesListPage, FilesService, NewFile, SdkCallCx, ServiceEventEmitter,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use crate::atomic_write::{BestEffortFilesWriter, FilesWriter, PostgresFilesWriter};
|
|
use crate::authz::{self, AuthzRepo, Capability};
|
|
use crate::files_repo::{FilesRepo, FilesRepoError};
|
|
|
|
pub struct FilesServiceImpl {
|
|
repo: Arc<dyn FilesRepo>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
writer: Arc<dyn FilesWriter>,
|
|
max_file_size_bytes: usize,
|
|
}
|
|
|
|
impl FilesServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
repo: Arc<dyn FilesRepo>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
events: Arc<dyn ServiceEventEmitter>,
|
|
max_file_size_bytes: usize,
|
|
) -> Self {
|
|
Self {
|
|
writer: Arc::new(BestEffortFilesWriter::new(repo.clone(), events)),
|
|
repo,
|
|
authz,
|
|
max_file_size_bytes,
|
|
}
|
|
}
|
|
|
|
/// Swap the best-effort writer for the transactional one: the metadata row
|
|
/// and the trigger fan-out then commit together, so an outbox failure rolls
|
|
/// the metadata back (and unlinks the blob) instead of silently losing the
|
|
/// event. The host always calls this; the in-memory unit tests do not.
|
|
#[must_use]
|
|
pub fn with_atomic_writes(
|
|
mut self,
|
|
pool: sqlx::PgPool,
|
|
root: std::path::PathBuf,
|
|
max_total_bytes: u64,
|
|
) -> Self {
|
|
self.writer = Arc::new(PostgresFilesWriter::new(pool, root, max_total_bytes));
|
|
self
|
|
}
|
|
|
|
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
|
|
authz::script_gate(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::AppFilesRead(cx.app_id),
|
|
|| FilesError::Forbidden,
|
|
FilesError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
|
|
authz::script_gate(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::AppFilesWrite(cx.app_id),
|
|
|| FilesError::Forbidden,
|
|
FilesError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
Uuid::parse_str(id).ok()
|
|
}
|
|
|
|
impl From<FilesRepoError> 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,
|
|
mut new: NewFile,
|
|
) -> Result<Uuid, FilesError> {
|
|
validate_files_collection(collection)?;
|
|
self.check_write(cx).await?;
|
|
new.validate(self.max_file_size_bytes)?;
|
|
// Audit 2026-06-11 C-2 — coerce dangerous render types to
|
|
// application/octet-stream after the shape checks pass.
|
|
new.content_type = sanitize_stored_content_type(&new.content_type);
|
|
self.writer.create(cx, collection, new).await
|
|
}
|
|
|
|
async fn head(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: &str,
|
|
) -> Result<Option<FileMeta>, 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<Option<Vec<u8>>, 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,
|
|
mut upd: FileUpdate,
|
|
) -> Result<(), FilesError> {
|
|
validate_files_collection(collection)?;
|
|
self.check_write(cx).await?;
|
|
upd.validate(self.max_file_size_bytes)?;
|
|
// Audit 2026-06-11 C-2 — sanitize after the shape checks pass.
|
|
if let Some(ct) = upd.content_type.as_deref() {
|
|
upd.content_type = Some(sanitize_stored_content_type(ct));
|
|
}
|
|
let Some(uuid) = parse_id(id) else {
|
|
return Err(FilesError::NotFound);
|
|
};
|
|
if self.writer.update(cx, collection, uuid, upd).await? {
|
|
Ok(())
|
|
} else {
|
|
Err(FilesError::NotFound)
|
|
}
|
|
}
|
|
|
|
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
|
|
validate_files_collection(collection)?;
|
|
self.check_write(cx).await?;
|
|
let Some(uuid) = parse_id(id) else {
|
|
return Ok(false);
|
|
};
|
|
self.writer.delete(cx, collection, uuid).await
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<FilesListPage, FilesError> {
|
|
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 crate::files_repo::FileUpdated;
|
|
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<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
|
}
|
|
|
|
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<FileMeta, FilesRepoError> {
|
|
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<Option<FileMeta>, 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<Option<Vec<u8>>, 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<Option<FileUpdated>, 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<Option<FileMeta>, 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<FilesListPage, FilesRepoError> {
|
|
let data = self.data.lock().await;
|
|
let after = cursor.and_then(|c| Uuid::parse_str(c).ok());
|
|
let mut metas: Vec<FileMeta> = 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<Vec<ServiceEvent>>,
|
|
}
|
|
|
|
#[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<Option<AppRole>, AuthzError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct EditorAuthzRepo;
|
|
#[async_trait]
|
|
impl AuthzRepo for EditorAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_user_id: UserId,
|
|
_app_id: AppId,
|
|
) -> Result<Option<AppRole>, 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<dyn AuthzRepo>, emitter: Arc<CapturingEmitter>) -> 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 dangerous_render_content_type_is_coerced_on_create() {
|
|
// Audit 2026-06-11 C-2 closure.
|
|
let files = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
let id = files
|
|
.create(
|
|
&cx,
|
|
"c",
|
|
NewFile {
|
|
name: "evil.html".into(),
|
|
content_type: "text/html".into(),
|
|
data: b"<script>alert(1)</script>".to_vec(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let meta = files
|
|
.head(&cx, "c", &id.to_string())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(meta.content_type, "application/octet-stream");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dangerous_render_content_type_is_coerced_on_update() {
|
|
let files = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
let id = files
|
|
.create(
|
|
&cx,
|
|
"c",
|
|
NewFile {
|
|
name: "a.bin".into(),
|
|
content_type: "application/octet-stream".into(),
|
|
data: b"x".to_vec(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
files
|
|
.update(
|
|
&cx,
|
|
"c",
|
|
&id.to_string(),
|
|
FileUpdate {
|
|
data: b"y".to_vec(),
|
|
name: None,
|
|
content_type: Some("image/svg+xml".into()),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let meta = files
|
|
.head(&cx, "c", &id.to_string())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(meta.content_type, "application/octet-stream");
|
|
}
|
|
|
|
#[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")));
|
|
// Empty `data` is NO LONGER rejected (v1.1.6 relaxation) — see
|
|
// `empty_file_round_trips`.
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_file_round_trips() {
|
|
// v1.1.6: a zero-byte blob is a valid stored state (sentinels,
|
|
// placeholders). Create with empty data, then read it back.
|
|
let files = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
let id = files
|
|
.create(
|
|
&cx,
|
|
"c",
|
|
NewFile {
|
|
name: "empty.bin".into(),
|
|
content_type: "application/octet-stream".into(),
|
|
data: vec![],
|
|
},
|
|
)
|
|
.await
|
|
.expect("empty file create should succeed");
|
|
let bytes = files.get(&cx, "c", &id.to_string()).await.unwrap();
|
|
assert_eq!(bytes, Some(Vec::new()));
|
|
let meta = files
|
|
.head(&cx, "c", &id.to_string())
|
|
.await
|
|
.unwrap()
|
|
.expect("metadata present");
|
|
assert_eq!(meta.size, 0);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|