| 211 |
211 |
|
/// One year with immutable directive — Cloudflare and browsers cache indefinitely.
|
| 212 |
212 |
|
pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
|
| 213 |
213 |
|
|
|
214 |
+ |
/// Capability proof required by every `StorageBackend` delete method.
|
|
215 |
+ |
///
|
|
216 |
+ |
/// Direct S3 deletion is sealed off from route handlers: the delete methods
|
|
217 |
+ |
/// take `&S3DeleteAuthority`, so the accidental `s3.delete_object(key)` from a
|
|
218 |
+ |
/// handler no longer compiles. Route code must instead enqueue through
|
|
219 |
+ |
/// `pending_s3_deletions` (e.g. `routes::storage::enqueue_s3_orphan`), whose
|
|
220 |
+ |
/// worker applies the `is_s3_key_live` guard before deleting — closing the
|
|
221 |
+ |
/// chronic where a handler blind-deleted a key a live row still referenced
|
|
222 |
+ |
/// (Run #18 CHRONIC B′).
|
|
223 |
+ |
///
|
|
224 |
+ |
/// Minting is `pub(crate)` and confined by convention to the durable-deletion
|
|
225 |
+ |
/// paths — the scheduler deletion worker + cleanup (`scheduler/cleanup.rs`) and
|
|
226 |
+ |
/// the malware-quarantine scan worker (`scanning/worker.rs`). The build-time
|
|
227 |
+ |
/// guard test `routes_never_delete_s3_directly` fails if any file under
|
|
228 |
+ |
/// `src/routes/` names a delete method or mints an authority, so the seal can't
|
|
229 |
+ |
/// silently erode.
|
|
230 |
+ |
pub struct S3DeleteAuthority(());
|
|
231 |
+ |
|
|
232 |
+ |
impl S3DeleteAuthority {
|
|
233 |
+ |
/// Mint a deletion authority. Restricted to the sanctioned durable-deletion
|
|
234 |
+ |
/// paths; see the type docs. Route handlers cannot reach a sanctioned path,
|
|
235 |
+ |
/// and the guard test enforces that they don't mint one anyway.
|
|
236 |
+ |
pub(crate) fn new() -> Self {
|
|
237 |
+ |
S3DeleteAuthority(())
|
|
238 |
+ |
}
|
|
239 |
+ |
}
|
|
240 |
+ |
|
| 214 |
241 |
|
/// Abstract storage backend — implemented by `S3Client` (production) and
|
| 215 |
242 |
|
/// `InMemoryStorage` (tests). Routes access storage through this trait.
|
| 216 |
243 |
|
#[async_trait::async_trait]
|
| 229 |
256 |
|
/// chunks directly.
|
| 230 |
257 |
|
async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
|
| 231 |
258 |
|
async fn upload_object(&self, s3_key: &str, content_type: &str, data: Vec<u8>, cache_control: Option<&str>) -> Result<()>;
|
| 232 |
|
- |
async fn delete_object(&self, s3_key: &str) -> Result<()>;
|
|
259 |
+ |
/// Delete an object. Requires an [`S3DeleteAuthority`] — route handlers
|
|
260 |
+ |
/// cannot mint one, so they must enqueue through `pending_s3_deletions`
|
|
261 |
+ |
/// instead of deleting directly (Run #18 CHRONIC B′).
|
|
262 |
+ |
async fn delete_object(&self, auth: &S3DeleteAuthority, s3_key: &str) -> Result<()>;
|
| 233 |
263 |
|
/// Delete a batch of objects in a single S3 `DeleteObjects` request
|
| 234 |
264 |
|
/// (up to 1000 keys/call). Default loops `delete_object` so test backends
|
| 235 |
265 |
|
/// don't have to implement it, but production should override.
|
| 236 |
|
- |
async fn delete_objects(&self, keys: &[String]) -> Result<()> {
|
|
266 |
+ |
async fn delete_objects(&self, auth: &S3DeleteAuthority, keys: &[String]) -> Result<()> {
|
| 237 |
267 |
|
for k in keys {
|
| 238 |
|
- |
if let Err(e) = self.delete_object(k).await {
|
|
268 |
+ |
if let Err(e) = self.delete_object(auth, k).await {
|
| 239 |
269 |
|
tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed");
|
| 240 |
270 |
|
}
|
| 241 |
271 |
|
}
|
| 242 |
272 |
|
Ok(())
|
| 243 |
273 |
|
}
|
| 244 |
274 |
|
/// Delete all objects under a key prefix. Default logs a warning (no-op).
|
| 245 |
|
- |
async fn delete_prefix(&self, _prefix: &str) -> Result<()> {
|
|
275 |
+ |
async fn delete_prefix(&self, _auth: &S3DeleteAuthority, _prefix: &str) -> Result<()> {
|
| 246 |
276 |
|
tracing::warn!("delete_prefix called on a storage backend that does not implement it");
|
| 247 |
277 |
|
Ok(())
|
| 248 |
278 |
|
}
|
| 540 |
570 |
|
/// behavior: we don't reject names containing `..`, we just guarantee the
|
| 541 |
571 |
|
/// output has no path separators. S3 keys are namespaced by user/item ID
|
| 542 |
572 |
|
/// upstream, so a flat literal here can't escape the user's prefix.
|
| 543 |
|
- |
fn sanitize_filename(filename: &str) -> String {
|
|
573 |
+ |
///
|
|
574 |
+ |
/// `pub(crate)` so confirm handlers store a filename that matches the tail of
|
|
575 |
+ |
/// the key `generate_media_key` produced, rather than re-deriving a weaker
|
|
576 |
+ |
/// filter that drops the empty-basename fallback (Run #18 Storage B9).
|
|
577 |
+ |
pub(crate) fn sanitize_filename(filename: &str) -> String {
|
| 544 |
578 |
|
let sanitized: String = filename
|
| 545 |
579 |
|
.chars()
|
| 546 |
580 |
|
.filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
|
| 678 |
712 |
|
self.upload_object(s3_key, content_type, data, cache_control).await
|
| 679 |
713 |
|
}
|
| 680 |
714 |
|
|
| 681 |
|
- |
async fn delete_object(&self, s3_key: &str) -> Result<()> {
|
|
715 |
+ |
async fn delete_object(&self, _auth: &S3DeleteAuthority, s3_key: &str) -> Result<()> {
|
|
716 |
+ |
// Authority proven by the caller; delegate to the inherent impl.
|
| 682 |
717 |
|
self.delete_object(s3_key).await
|
| 683 |
718 |
|
}
|
| 684 |
719 |
|
|
| 685 |
|
- |
async fn delete_objects(&self, keys: &[String]) -> Result<()> {
|
|
720 |
+ |
async fn delete_objects(&self, _auth: &S3DeleteAuthority, keys: &[String]) -> Result<()> {
|
| 686 |
721 |
|
self.delete_objects(keys).await
|
| 687 |
722 |
|
}
|
| 688 |
723 |
|
|
| 689 |
|
- |
async fn delete_prefix(&self, prefix: &str) -> Result<()> {
|
|
724 |
+ |
async fn delete_prefix(&self, _auth: &S3DeleteAuthority, prefix: &str) -> Result<()> {
|
| 690 |
725 |
|
self.inner.delete_prefix(prefix).await
|
| 691 |
726 |
|
.map_err(AppError::Storage)
|
| 692 |
727 |
|
}
|
| 1117 |
1152 |
|
assert!(key.ends_with("tutorial.mp4"));
|
| 1118 |
1153 |
|
}
|
| 1119 |
1154 |
|
}
|
|
1155 |
+ |
|
|
1156 |
+ |
/// Build-time enforcement of CHRONIC B′ (Run #18): route handlers must never
|
|
1157 |
+ |
/// delete S3 objects directly, nor mint an [`S3DeleteAuthority`]. Direct
|
|
1158 |
+ |
/// deletion is for the sanctioned durable-deletion paths (`scheduler/cleanup.rs`,
|
|
1159 |
+ |
/// `scanning/worker.rs`) only; handlers enqueue through `pending_s3_deletions`.
|
|
1160 |
+ |
///
|
|
1161 |
+ |
/// The type system already makes the accidental `s3.delete_object(key)`
|
|
1162 |
+ |
/// uncompilable (the delete methods require an authority handlers can't reach).
|
|
1163 |
+ |
/// This test closes the deliberate-circumvention gap: it fails the build if any
|
|
1164 |
+ |
/// file under `src/routes/` names a delete method or the authority type, so the
|
|
1165 |
+ |
/// seal cannot silently erode in a future handler.
|
|
1166 |
+ |
#[cfg(test)]
|
|
1167 |
+ |
mod delete_seal_guard {
|
|
1168 |
+ |
use std::path::Path;
|
|
1169 |
+ |
|
|
1170 |
+ |
#[test]
|
|
1171 |
+ |
fn routes_never_delete_s3_directly() {
|
|
1172 |
+ |
let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes");
|
|
1173 |
+ |
let mut offenders = Vec::new();
|
|
1174 |
+ |
walk(&routes_dir, &mut |path, contents| {
|
|
1175 |
+ |
for (i, line) in contents.lines().enumerate() {
|
|
1176 |
+ |
// Skip comment/doc lines (they legitimately mention the API).
|
|
1177 |
+ |
if line.trim_start().starts_with("//") {
|
|
1178 |
+ |
continue;
|
|
1179 |
+ |
}
|
|
1180 |
+ |
if line.contains(".delete_object(")
|
|
1181 |
+ |
|| line.contains(".delete_objects(")
|
|
1182 |
+ |
|| line.contains(".delete_prefix(")
|
|
1183 |
+ |
|| line.contains("S3DeleteAuthority")
|
|
1184 |
+ |
{
|
|
1185 |
+ |
offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
|
|
1186 |
+ |
}
|
|
1187 |
+ |
}
|
|
1188 |
+ |
});
|
|
1189 |
+ |
assert!(
|
|
1190 |
+ |
offenders.is_empty(),
|
|
1191 |
+ |
"CHRONIC B' seal violated — route code must enqueue via \
|
|
1192 |
+ |
routes::storage::enqueue_s3_orphan, never delete S3 directly or mint an \
|
|
1193 |
+ |
S3DeleteAuthority. Offending lines:\n{}",
|
|
1194 |
+ |
offenders.join("\n")
|
|
1195 |
+ |
);
|
|
1196 |
+ |
}
|
|
1197 |
+ |
|
|
1198 |
+ |
fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
|
|
1199 |
+ |
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
1200 |
+ |
return;
|
|
1201 |
+ |
};
|
|
1202 |
+ |
for entry in entries.flatten() {
|
|
1203 |
+ |
let path = entry.path();
|
|
1204 |
+ |
if path.is_dir() {
|
|
1205 |
+ |
walk(&path, f);
|
|
1206 |
+ |
} else if path.extension().is_some_and(|e| e == "rs")
|
|
1207 |
+ |
&& let Ok(contents) = std::fs::read_to_string(&path)
|
|
1208 |
+ |
{
|
|
1209 |
+ |
f(&path, &contents);
|
|
1210 |
+ |
}
|
|
1211 |
+ |
}
|
|
1212 |
+ |
}
|
|
1213 |
+ |
}
|