Files
buzz/crates/buzz-media/src/storage.rs
Kalvin C 8a2c9af2db feat(deletion): add durable whole-community deletion (#4425)
## Summary

Adds a durable, operator-controlled V1 for deleting an entire Buzz
community without deleting another tenant's data.

The workflow is exposed through `buzz-admin deletions`:

- `sweep` records independent fleet storage-taxonomy observations
- `submit`, `list`, `inspect`, and `approve` manage a deletion request
- `unblock` resumes a fail-closed request after an operator records
remediation identity and reason
- `run` and `drain` execute bounded work

Requests advance through a PostgreSQL-backed state machine and stop at
`retention_pending` after logical deletion has been independently
verified across PostgreSQL, object storage, and Redis.

This PR ships the engine and CLI, not a continuously running worker or
Kubernetes packaging. For V1, a cluster/VM administrator invokes
`/usr/local/bin/buzz-admin` from the existing relay image, for example
with `kubectl exec` or an equivalent container/VM exec path.

## What whole-community V1 removes

For the target community, V1 removes:

- rows from the allowlisted community-scoped PostgreSQL catalog,
including members, profiles, authored events and bodies, DMs, reactions,
mentions, memberships, tokens, workflows, moderation, audit, feedback,
and rate-limit state
- media sidecars and upload-attribution records under
`_meta/<community>/` and `_uploads/<community>/`
- Git repository pointers under `repos/<community>/`
- Redis keys under `buzz:<community>:*`

The community row survives as a permanent tombstone, and deletion
control-plane records remain as evidence of the request, approval,
execution, and result.

## Safety model

Deletion is not a broad `DELETE CASCADE` followed by optimistic cleanup.
The destructive boundaries are durable and fail closed.

### 1. Inventory and approval

- `submit` resolves the target and freezes the schema plus summary-only
storage inventory.
- Approval is bound to the exact request, community, and frozen
inventory digest.
- Unsupported manifest versions, malformed keys inside the target's
owned prefixes, live scoped-table/write-fence coverage drift,
frozen-inventory mismatch, and approval mismatch block execution rather
than guessing. Migration and catalog revision numbers are not
authorization gates; the executor validates the live safety shape
instead.
- Storage inventory is server-side prefix scoped to exactly:
  - `_meta/<community>/`
  - `_uploads/<community>/`
  - `repos/<community>/`
- The deletion path never lists the whole shared bucket and has no
arbitrary per-community object cap. Its listing work is proportional to
the target community's bindings, not total fleet storage.
- Fleet-wide taxonomy sweeps remain independent observability. They
report unknown writer shapes but do not gate deletion submission,
fencing, or destructive progress. Maintainers must add deletion taxonomy
coverage whenever a new community-owned object-key class is introduced;
writer-coverage tests bind the current media and Git writers to that
contract.

### 2. Quiesce, fence, and destructive freeze

- Writes continue through submission, inventory, and approval. They stop
when execution moves the target into `quiescing` and then establishes
the durable fence.
- Already-admitted external effects finish under heartbeated
serving-write leases; the exact admitted lease may renew while the
community is quiescing, but new lease acquisition is rejected. The
executor drains admitted leases before destructive work.
- Invite minting after quiescing begins fails as typed `AccessDenied`
(HTTP 503 at the relay boundary) before an invite can be persisted.
- Database triggers enforce the community write fence across the
complete catalog of community-scoped tables. Startup/readiness and
destructive execution validate that catalog so a newly added but
unfenced table cannot silently escape.
- **Named isolation assumption — fresh write snapshot.** Every writer
transaction that can reach a community-fenced relation must use
PostgreSQL `READ COMMITTED`; each guarded write therefore observes a
statement snapshot no older than acquisition of the community deletion
lock. `REPEATABLE READ` and `SERIALIZABLE` can retain a pre-fence
snapshot and are unsupported for writers. The writer pool refuses
non-`READ COMMITTED` sessions at connection setup, and both SQL fence
functions reject an explicit per-transaction isolation override with
SQLSTATE `25000`. Configuration-delivered bad isolation can surface
through SQLx as a pool-acquire timeout because every `after_connect`
attempt is rejected; the precise `community writes require READ
COMMITTED isolation` reason remains observable when the SQL guard is
reached. Read-only replica transactions are outside this assumption.
- Holding the shared advisory lock until the guarded write executes is a
separate liveness condition: under `READ COMMITTED`, releasing it early
does not permit resurrection because the trigger rechecks the fence, but
it can turn a fleet sweep into a statement-wide SQLSTATE `55000` abort.
- After the fence closes writers, storage is re-enumerated into chunked
side-table rows. Per-prefix counts and digests bind those concrete keys
to the destructive manifest.
- Manifest chunk insertion, update, and deletion are protected after
freeze. This closes the race where an unbound key could otherwise appear
after the manifest was committed.

### 3. Checkpointed destruction

- Target-owned object bindings are deleted from the frozen destructive
manifest in bounded batches with durable progress.
- The concrete key list lives in chunked side-table rows rather than one
request-row JSON value. It supports large communities, resumable
execution, and terminal cleanup.
- Missing objects are accepted as idempotent crash-window outcomes;
malformed ownership, changed evidence, and unexplained target-prefix
drift fail closed.
- PostgreSQL purging remains scoped by `community_id`, including the
guarded NIP-RS hard-delete path discovered with real Desktop kind
`30078` read-state data.
- Redis cleanup explicitly scans and `UNLINK`s only
`buzz:<community_id>:*`. Natural expiry is insufficient because some
keys, including tunnel generation counters used as fencing state, are
deliberately persistent.

### 4. Independent verification

- PostgreSQL logical absence is checked after purge.
- The three target-owned storage prefixes are freshly inventoried again
and must be empty.
- Redis requires two complete empty namespace scans.
- Only after all three stores pass does the request advance through
`logically_verified` to `retention_pending`.

## What V1 deliberately does not erase

### Shared content-addressed storage

Per-community deletion removes bindings, metadata, attribution records,
and Git pointers. It does **not** physically delete fleet-shared CAS
bytes that another community may still reference:

- media blobs and thumbnails
- Git manifests, packs, and indexes (`manifests/`, `packs/`, and `idx/`)

Safe reclamation requires a separate fleet-wide reachability and
retention GC. Unknown keys elsewhere in the shared bucket do not block
one community's deletion; malformed or unrecognized keys inside that
community's three owned prefixes still fail closed.

### External retained copies

The online logical-deletion proof does not erase object
versions/replicas, database backups/WAL, CDN copies, provider retention
copies, or observability exports. Those require their own retention and
purge controls.

### Member-only erasure

This PR erases a whole community. It does not implement the different
operation "erase one npub while preserving the community."

Removing membership or accepting NIP-09 is not member erasure. A
member-only workflow would need to find and selectively remove or redact
authored event content and pubkeys, profile data, DMs, reactions,
mentions, memberships/roles, tokens, workflows/subscriptions, upload
attribution, moderation/audit history, repository attribution, and
identity embedded in tags or JSON. It would also need explicit rules for
ownership transfer, surviving replies and thread metadata, audit-chain
integrity, immutable Git history, and shared-CAS reachability. That
requires a pubkey-level fence and selective graph rewrite; it is a
separate deletion product, not a safe extension of this whole-tenant
worker.

## In scope

- migration `0029_community_deletion.sql`: requests, approvals, leases,
manifest chunks, checkpoints, tombstones, and the universal write-fence
catalog
- durable executor leases, generations, heartbeats, retry/block state,
and resumable stage transitions
- operator-driven `sweep`, `submit`, `list`, `inspect`, `approve`,
`unblock`, `run`, and `drain` commands
- serving-path fences for database writes and external effects across
event ingest, media, Git, workflow, push, invites, mesh/tunnel, and
related paths
- target-prefix-only storage inventory, summary manifests, post-fence
destructive chunks, and bounded batch deletion
- exact community Redis namespace purge and two-pass absence
verification
- cross-community isolation, crash/resume, manifest-integrity,
writer-taxonomy, and schema/migration regressions
- desired-state `schema/schema.sql` support without requiring a SQLx
migration ledger

## Deferred / not covered

- dedicated Helm/chart worker Deployment, service account, secrets,
probes, resources, and network policy
- autonomous `buzz-admin deletions worker` poll loop and worker-only
health server
- least-privilege separation among migration, relay-serving, and
destructive execution roles
- fleet-wide shared-CAS physical GC
- backup/provider/CDN/observability retention completion
- member-only erasure
- provider-native conditional-delete improvements
- a general force-continue escape hatch; permanent safety failures
remain fail closed unless an operator remediates the cause and records
an audited `unblock`

The removed continuous-worker implementation remains deferred; no remote
follow-up branch is claimed by this PR.

## Validation

### Current PR head and repository state

Current pushed head: `359d8402ee15f049768f54156f67b953c7a7e2ed`, rebased
onto `cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1` (`origin/main` at push
time). The complete PR diff is now 47 files, 9,834 additions, and 517
deletions.

The bespoke source-scanner stack was removed to keep this PR scoped to
community deletion. Tyler/team requested the underlying fenced-write
safety behavior, not `ast-grep`,
`crates/buzz-db/tests/community_fenced_writes.rs`, its 27 fixtures, or
the new `scripts/lints/community_*.yml` rules. Those scanner-specific
files, dependencies, Hermit links, and runner wiring are absent from the
current tree. The production database write fence, startup/destructive
live-catalog validation, and deletion behavior remain.

Source validation on this exact SHA passed:

- `cargo fmt --all -- --check`
- `bash -n scripts/run-tests.sh`
- `cargo nextest run -p buzz-db --all-targets`: 102 passed, 173 skipped,
0 failed
- `cargo nextest run -p buzz-deletion --all-targets`: 10 passed, 9
skipped, 0 failed
- `cargo nextest run -p buzz-admin --all-targets`: 1 passed, 0 failed
- affected-package/all-target Clippy with warnings denied
- lockfile consistency
- Helm 3.16.4 lint and all 44 chart unit tests
- Helm region controls using that fixture: default
`BUZZ_S3_REGION=us-east-1`, explicit `eu-west-2` override, and
blank-region schema rejection

The prior Kubernetes battery below was run against
`928992237358a3294621ac0280830b77155abc04`. It remains useful evidence
for the patch-equivalent production deletion implementation, but it is
**not** claimed as exact-SHA evidence for current head
`359d8402ee15f049768f54156f67b953c7a7e2ed`; the current cleanup removes
only scanner/test/tooling infrastructure. CI restarted for the new head
after the rebase and is pending. Human review remains
`CHANGES_REQUESTED`.

### Prior-head live Kubernetes deletion and safety gates

The full program used one immutable image, real PostgreSQL, Redis,
MinIO, and a three-relay Kubernetes release:

- source: `928992237358a3294621ac0280830b77155abc04` (**prior head**)
- image: `buzz-e2e:sha-928992237358`
- immutable image digest:
`sha256:a1a204f4618ac22d9e210be5e5290645a15d79831ae30b0e44379357c8e4a895`
- evidence root:
`/tmp/buzz-e2e/20260807T033025Z-928992237358-full-gates/`
- evidence-manifest digest:
`82875c5bc9bea7370b796a7aef3457b3a1c8306c84c59e0f7388bbb5ad30e865`

Passed gates at that prior head:

- **Chart/operator region:** default `us-east-1`, explicit nondefault
propagation, blank-region schema rejection, live in-pod environment, and
an in-pod taxonomy sweep over 18 objects with zero unknown.
- **Fenced writers and lifecycle:** open-write/fence ordering;
100-attempt anti-starvation; invite, push matcher, and exhausted-reaper
bystander isolation; non-`READ-COMMITTED` rejection; manifest/tombstone
contracts; eight-failure stage block and audited `unblock`.
- **Destructive lifecycle:** submit → approve → run →
`retention_pending`; PostgreSQL tombstone and Redis/S3 verification
true; zero retries/errors; terminal reruns rejected with exit 5.
- **Fresh 10,001-object crash boundary:** exactly two chunks (10,000 +
1). The executor deleted chunk 0 from MinIO while its PostgreSQL stamp
was row-lock-blocked, was killed with `SIGKILL`, left one object and
both stamps absent, then resumed the same request under generation 2 to
zero objects and terminal state.
- **Independent dead-owner recovery:** a dedicated executor claimed
generation 1, blocked before effects, and was killed through containerd
with `SIGKILL` (no TERM cleanup). The request remained owned and
unreclaimable before lease expiry; a successor claimed generation 2
after 60 seconds and completed with two attempts and zero retries.
- **Three-pod socket isolation:** ordinary NIP-42 and joined
huddle-audio target witnesses on every replica received exact `1008 /
community deleted`; healthy-tenant witnesses on those pods remained
live; deleted-host reconnect returned HTTP 404.
- **Health/provenance:** all replicas independently returned ready and
retained the exact image digest before/after destructive runs and an
audio-enabled rolling restart; PostgreSQL, Redis, and MinIO were healthy
at close.

Instrument corrections were retained as evidence rather than counted as
product failures: a foreground PostgreSQL forward caused an initial
`PoolTimedOut`; Kubernetes pod deletion exercised graceful TERM rather
than dead-owner recovery; shell-background socket witnesses died with
their parent; and the first image build hit the corporate TLS proxy.
Detached forwarding/witnesses, containerd `SIGKILL`, and the configured
internal CA/Artifactory mirror produced the discriminating runs without
weakening product security.

### Prior-head cleanup

For the prior-head Kubernetes run, the Helm release was removed,
namespace absence was verified, run-owned Screen sessions were absent,
and that source worktree remained clean. The evidence manifest was
independently recomputed and every indexed artifact passed `shasum -a
256 -c`. The current `359d8402` source worktree is also clean after the
scanner-only cleanup and push.

---------

Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
2026-08-12 09:25:58 -07:00

593 lines
23 KiB
Rust

//! S3/MinIO storage client.
use std::path::Path;
use std::pin::Pin;
use buzz_core::tenant::{CommunityId, TenantContext};
use crate::config::{MediaConfig, S3AddressingStyle};
use crate::error::MediaError;
use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::{Deserialize, Serialize};
/// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`.
pub type ByteStream = Pin<Box<dyn futures_core::Stream<Item = Result<Bytes, MediaError>> + Send>>;
/// S3-compatible object storage client.
pub struct MediaStorage {
bucket: Box<Bucket>,
}
impl MediaStorage {
/// Create a new storage client from config.
///
/// Credential selection:
/// - If both `s3_access_key` and `s3_secret_key` are non-empty, use them as
/// static credentials (MinIO/local/dev, or any static-key deployment).
/// - Otherwise, fall back to the AWS default credential chain via
/// [`Credentials::default`]: environment, shared profile, web-identity
/// token (IRSA on EKS — `AssumeRoleWithWebIdentity`), container, and
/// instance-metadata providers, in that order. This lets the relay use
/// the pod's IAM role without long-lived static keys.
pub fn new(config: &MediaConfig) -> Result<Self, MediaError> {
let region = Region::Custom {
region: config.s3_region.clone(),
endpoint: config.s3_endpoint.clone(),
};
let creds = match (
config.s3_access_key.is_empty(),
config.s3_secret_key.is_empty(),
) {
(false, false) => Credentials::new(
Some(&config.s3_access_key),
Some(&config.s3_secret_key),
None,
None,
None,
),
(true, true) => {
// No static keys configured: resolve from the AWS credential chain
// (IRSA web-identity, env, profile, instance metadata).
Credentials::default()
}
_ => {
return Err(MediaError::StorageError(
"s3_access_key and s3_secret_key must be configured together, or both empty to use the AWS credential chain"
.to_string(),
));
}
}
.map_err(|e| MediaError::StorageError(e.to_string()))?;
let bucket = Bucket::new(&config.s3_bucket, region, creds)
.map_err(|e| MediaError::StorageError(e.to_string()))?;
let bucket = match config.s3_addressing_style {
S3AddressingStyle::Path => bucket.with_path_style(),
S3AddressingStyle::Virtual => bucket,
};
Ok(Self { bucket })
}
/// Store an object from a byte slice.
///
/// Used for images, sidecars, and thumbnails. For large video files use
/// [`put_file`] to avoid loading the entire blob into RAM.
pub async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), MediaError> {
self.bucket
.put_object_with_content_type(key, bytes, content_type)
.await?;
Ok(())
}
/// Stream a file from disk into S3 without loading it into RAM.
///
/// Uses rust-s3's `put_object_stream_with_content_type` which reads from
/// the file incrementally via an 8 MiB `BufReader`. The full file is never
/// held in memory simultaneously. Intended for video blobs (up to 500 MB).
pub async fn put_file(
&self,
key: &str,
path: &Path,
content_type: &str,
) -> Result<(), MediaError> {
const BUF: usize = 8 * 1024 * 1024; // 8 MiB read buffer
let file = tokio::fs::File::open(path)
.await
.map_err(|e| MediaError::Io(e.to_string()))?;
let mut reader = tokio::io::BufReader::with_capacity(BUF, file);
self.bucket
.put_object_stream_with_content_type(&mut reader, key, content_type)
.await?;
Ok(())
}
/// Retrieve an object's bytes.
pub async fn get(&self, key: &str) -> Result<Vec<u8>, MediaError> {
match self.bucket.get_object(key).await {
Ok(response) => Ok(response.to_vec()),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Retrieve a byte range from an object via S3-native `Range` GET.
///
/// `start` and `end` are inclusive byte offsets. Only the requested slice
/// is transferred from S3 — the full object is never loaded into RAM.
/// Intended for HTTP 206 range responses on large video blobs.
pub async fn get_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, MediaError> {
match self.bucket.get_object_range(key, start, Some(end)).await {
Ok(response) => Ok(response.to_vec()),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Stream an object's bytes from S3 without loading into RAM.
///
/// Returns a pinned stream of `Result<Bytes, MediaError>` chunks.
/// The full object is never buffered — intended for streaming large
/// blobs (video) directly into HTTP responses via `Body::from_stream()`.
pub async fn get_stream(&self, key: &str) -> Result<ByteStream, MediaError> {
let response = self
.bucket
.get_object_stream(key)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
if response.status_code == 404 {
return Err(MediaError::NotFound);
}
let stream = futures_util::StreamExt::map(response.bytes, |chunk| {
chunk.map_err(|e| MediaError::StorageError(e.to_string()))
});
Ok(Box::pin(stream))
}
/// Check if an object exists. Returns false on 404.
pub async fn head(&self, key: &str) -> Result<bool, MediaError> {
match self.bucket.head_object(key).await {
Ok(_) => Ok(true),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Delete an object. Returns an error on failure — callers decide whether to propagate.
pub async fn delete(&self, key: &str) -> Result<(), MediaError> {
self.bucket
.delete_object(key)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
Ok(())
}
/// HEAD with metadata — returns Content-Length (size).
pub async fn head_with_metadata(&self, key: &str) -> Result<Option<BlobHeadMeta>, MediaError> {
match self.bucket.head_object(key).await {
Ok((result, _)) => Ok(Some(BlobHeadMeta {
size: result.content_length.unwrap_or(0) as u64,
})),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Detect whether the bucket has ever had versioning enabled.
///
/// rust-s3 exposes no GetBucketVersioning, so this writes and inspects a
/// short-lived fleet probe object instead: versioning-enabled (and
/// versioning-suspended) buckets stamp new writes with a version id.
/// Deletion refuses versioned buckets because bulk deletes without a
/// VersionId would only insert delete markers, not prove logical absence.
pub async fn bucket_versioning_detected(&self) -> Result<bool, MediaError> {
let key = format!("probe/deletion-versioning-{}", uuid::Uuid::new_v4());
self.put(&key, b"buzz deletion versioning probe", "text/plain")
.await?;
let inspected = self.bucket.head_object(&key).await;
let removed = self.bucket.delete_object(&key).await;
let (head, _) = inspected.map_err(|e| MediaError::StorageError(e.to_string()))?;
removed.map_err(|e| MediaError::StorageError(e.to_string()))?;
Ok(head.version_id.is_some())
}
/// Bulk-delete up to one manifest chunk of keys via S3 `DeleteObjects`.
///
/// Never fails on per-key outcomes: they are folded into
/// [`BulkDeleteOutcome`] so the caller owns retry/fail-closed policy.
/// Historical MinIO releases report already-absent keys as
/// `NoSuchKey`/`NoSuchVersion` errors instead of deleted; both map to
/// `already_missing` to keep checkpointed retry idempotent.
pub async fn delete_objects(&self, keys: &[String]) -> Result<BulkDeleteOutcome, MediaError> {
if keys.is_empty() {
return Ok(BulkDeleteOutcome::default());
}
let identifiers = keys
.iter()
.map(|key| s3::serde_types::ObjectIdentifier::new(key.clone()))
.collect::<Vec<_>>();
let result = self
.bucket
.delete_objects(identifiers)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
Ok(fold_bulk_delete_result(result))
}
/// Build the community-scoped sidecar key for a given sha256 (bare hash).
///
/// Raw media bytes remain shared content-addressed CAS (`{sha}.{ext}`), but
/// the metadata sidecar is the tenant read gate. A blob in another
/// community must never be observable through a global `_meta/{sha}.json`
/// lookup.
pub fn sidecar_key(community: CommunityId, sha256: &str) -> String {
format!("_meta/{community}/{sha256}.json")
}
/// Build the community-scoped sidecar key from the resolved request tenant.
pub fn ctx_sidecar_key(ctx: &TenantContext, sha256: &str) -> String {
Self::sidecar_key(ctx.community(), sha256)
}
/// Read community-scoped sidecar JSON for a given sha256 (bare hash).
pub async fn get_sidecar(
&self,
ctx: &TenantContext,
sha256: &str,
) -> Result<BlobMeta, MediaError> {
let key = Self::ctx_sidecar_key(ctx, sha256);
let resp = self.bucket.get_object(&key).await?;
let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?;
Ok(meta)
}
/// Write community-scoped sidecar JSON for a given sha256 (bare hash).
///
/// `ctx` must be the server-resolved request tenant. Callers must never
/// derive the community from client-supplied blob metadata, URLs, or event
/// tags; this sidecar key is the tenant read gate for otherwise shared CAS
/// bytes.
pub async fn put_sidecar(
&self,
ctx: &TenantContext,
sha256: &str,
meta: &BlobMeta,
) -> Result<(), MediaError> {
let key = Self::ctx_sidecar_key(ctx, sha256);
let meta_json = serde_json::to_vec(meta)?;
self.put(&key, &meta_json, "application/json").await
}
/// Convenience: read just the MIME type from the community sidecar.
///
/// Returns `None` for both absent sidecars and storage read failures. Public
/// read handlers intentionally collapse that distinction to 404 so an
/// A-bound request cannot distinguish a B-only blob from a missing blob.
pub async fn read_sidecar_mime(&self, ctx: &TenantContext, sha256_ext: &str) -> Option<String> {
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
self.get_sidecar(ctx, sha256)
.await
.ok()
.map(|m| m.mime_type)
}
/// Probe object-store connectivity and bucket access.
pub async fn ping(&self) -> Result<(), MediaError> {
self.list_page(None, 1).await.map(|_| ())
}
/// One page of a full-bucket listing, for the storage sweep. Wraps
/// rust-s3's manual `list_page` (NOT the auto-paginating `list`, which
/// has no cap) and converts the result into the storage-agnostic
/// [`crate::bucket_index::Page`] shape the pure fold consumes.
///
/// `max_keys` bounds one HTTP response, not the sweep's total object
/// cap — the caller (`fold_bucket_listing`) enforces the cumulative cap
/// across pages.
pub async fn list_page(
&self,
continuation_token: Option<String>,
max_keys: usize,
) -> Result<crate::bucket_index::Page, MediaError> {
self.list_prefix_page("", continuation_token, max_keys)
.await
}
/// One page of a prefix-scoped listing.
///
/// Deletion enumerates the target community's exact key prefixes with
/// this instead of listing the whole fleet bucket: cost stays
/// O(tenant objects) regardless of fleet size. `ListObjectsV2` returns
/// keys in ascending UTF-8 binary order, which callers rely on for
/// streaming key-stream digests.
pub async fn list_prefix_page(
&self,
prefix: &str,
continuation_token: Option<String>,
max_keys: usize,
) -> Result<crate::bucket_index::Page, MediaError> {
let (result, _status) = self
.bucket
.list_page(
prefix.to_string(),
None,
continuation_token,
None,
Some(max_keys),
)
.await?;
Ok(crate::bucket_index::Page {
objects: result
.contents
.into_iter()
.map(|obj| (obj.key, obj.size))
.collect(),
next_continuation_token: result.next_continuation_token,
is_truncated: result.is_truncated,
})
}
}
/// Per-key outcomes of one bulk `DeleteObjects` call.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BulkDeleteOutcome {
/// Keys the backend reported deleted (S3 reports already-missing keys as
/// deleted too — the API is idempotent by design).
pub deleted: u64,
/// Keys reported absent via legacy MinIO `NoSuchKey`/`NoSuchVersion`
/// per-key errors; equivalent to deleted for retry purposes.
pub already_missing: u64,
/// Keys whose deletion produced a version artifact (delete marker or
/// version id) — evidence of bucket versioning, which deletion must
/// fail closed on.
pub versioned_keys: Vec<String>,
/// Remaining per-key failures as `(key, code, message)`.
pub failed: Vec<(String, String, String)>,
}
fn fold_bulk_delete_result(result: s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome {
let mut outcome = BulkDeleteOutcome::default();
for deleted in result.deleted {
if deleted.delete_marker == Some(true)
|| deleted.delete_marker_version_id.is_some()
|| deleted.version_id.is_some()
{
outcome.versioned_keys.push(deleted.key);
} else {
outcome.deleted += 1;
}
}
for error in result.errors {
if error.code == "NoSuchKey" || error.code == "NoSuchVersion" {
outcome.already_missing += 1;
} else {
outcome.failed.push((error.key, error.code, error.message));
}
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// The bulk-delete fold is the retry-idempotence contract: legacy MinIO
/// absent-key errors count as success, version artifacts are surfaced for
/// fail-closed handling, and anything else stays a per-key failure.
#[test]
fn bulk_delete_fold_maps_absent_keys_and_version_artifacts() {
use s3::serde_types::{DeleteError, DeleteObjectsResult, DeletedObject};
let deleted_object = |key: &str, marker: bool| DeletedObject {
key: key.to_string(),
version_id: None,
delete_marker: marker.then_some(true),
delete_marker_version_id: marker.then(|| "v1".to_string()),
};
let delete_error = |key: &str, code: &str, message: &str| DeleteError {
key: key.to_string(),
code: code.to_string(),
message: message.to_string(),
version_id: None,
};
let result = DeleteObjectsResult {
deleted: vec![
deleted_object("plain", false),
deleted_object("marked", true),
],
errors: vec![
delete_error("gone", "NoSuchKey", "absent"),
delete_error("gone-version", "NoSuchVersion", "absent"),
delete_error("denied", "AccessDenied", "nope"),
],
};
let outcome = fold_bulk_delete_result(result);
assert_eq!(outcome.deleted, 1);
assert_eq!(outcome.already_missing, 2);
assert_eq!(outcome.versioned_keys, vec!["marked".to_string()]);
assert_eq!(
outcome.failed,
vec![(
"denied".to_string(),
"AccessDenied".to_string(),
"nope".to_string()
)]
);
}
fn tenant(n: u128) -> TenantContext {
TenantContext::resolved(
CommunityId::from_uuid(uuid::Uuid::from_u128(n)),
"media.example",
)
}
fn storage_config(access: &str, secret: &str) -> crate::config::MediaConfig {
crate::config::MediaConfig {
s3_endpoint: "http://localhost:9000".to_string(),
s3_access_key: access.to_string(),
s3_secret_key: secret.to_string(),
s3_bucket: "buzz-media".to_string(),
s3_region: "us-west-2".to_string(),
s3_addressing_style: S3AddressingStyle::Path,
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_file_bytes: 104_857_600,
public_base_url: "http://localhost:3000/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
}
}
/// Static keys present: builds a client without touching the AWS
/// credential chain (no env/metadata access), and the signing region
/// comes from config rather than a hardcoded "us-east-1".
#[test]
fn static_keys_build_client_with_configured_region() {
let storage = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret"))
.expect("static creds should build a client");
match storage.bucket.region {
Region::Custom { ref region, .. } => assert_eq!(region, "us-west-2"),
other => panic!("expected Custom region, got {other:?}"),
}
}
#[test]
fn client_constructor_applies_both_addressing_styles() {
let path = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret"))
.expect("path-style client");
assert!(path.bucket.is_path_style());
assert_eq!(path.bucket.url(), "http://localhost:9000/buzz-media");
let mut virtual_config = storage_config("buzz_dev", "buzz_dev_secret");
virtual_config.s3_addressing_style = S3AddressingStyle::Virtual;
let virtual_hosted = MediaStorage::new(&virtual_config).expect("virtual-hosted client");
assert!(virtual_hosted.bucket.is_subdomain_style());
assert_eq!(
virtual_hosted.bucket.url(),
"http://buzz-media.localhost:9000"
);
}
#[test]
fn partial_static_keys_are_rejected() {
let err = match MediaStorage::new(&storage_config("buzz_dev", "")) {
Ok(_) => panic!("partial static creds must not silently use credential chain"),
Err(err) => err,
};
assert!(
err.to_string().contains("must be configured together"),
"unexpected error: {err}"
);
let err = match MediaStorage::new(&storage_config("", "buzz_dev_secret")) {
Ok(_) => panic!("partial static creds must not silently use credential chain"),
Err(err) => err,
};
assert!(
err.to_string().contains("must be configured together"),
"unexpected error: {err}"
);
}
#[test]
fn tenant_key_writers_are_covered_by_deletion_taxonomy() {
let ctx = tenant(1);
let community = *ctx.community().as_uuid();
let sha = "a".repeat(64);
let event_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let sidecar = MediaStorage::ctx_sidecar_key(&ctx, &sha);
let upload = crate::upload_record::upload_record_key(&ctx, &sha, event_id);
let prefixes = crate::bucket_index::tenant_prefixes(community);
for key in [sidecar, upload] {
assert!(
prefixes.iter().any(|prefix| key.starts_with(prefix)),
"tenant writer key {key} is outside deletion prefixes"
);
assert!(
crate::bucket_index::is_tenant_owned_key(community, &key),
"tenant writer key {key} is not recognized by deletion taxonomy"
);
}
}
#[test]
fn sidecar_keys_are_community_scoped() {
let a = tenant(1);
let b = tenant(2);
let sha = "f".repeat(64);
assert_eq!(
MediaStorage::ctx_sidecar_key(&a, &sha),
format!("_meta/{}/{sha}.json", a.community())
);
assert_ne!(
MediaStorage::ctx_sidecar_key(&a, &sha),
MediaStorage::ctx_sidecar_key(&b, &sha)
);
assert_ne!(
MediaStorage::ctx_sidecar_key(&a, &sha),
format!("_meta/{sha}.json")
);
}
/// Mutate-bite shape for the media substrate: same CAS bytes/hash can be
/// known in A and B, but the sidecar is the read/existence gate. If the
/// community segment is dropped from `sidecar_key`, B's metadata overwrites
/// A's in this map and A observes B's MIME (wrong answer, not absence).
#[test]
fn same_sha_sidecars_do_not_bleed_between_communities() {
let a = tenant(1);
let b = tenant(2);
let sha = "a".repeat(64);
let mut sidecars = HashMap::new();
sidecars.insert(MediaStorage::ctx_sidecar_key(&a, &sha), "image/png");
sidecars.insert(MediaStorage::ctx_sidecar_key(&b, &sha), "video/mp4");
assert_eq!(
sidecars[&MediaStorage::ctx_sidecar_key(&a, &sha)],
"image/png"
);
assert_eq!(
sidecars[&MediaStorage::ctx_sidecar_key(&b, &sha)],
"video/mp4"
);
}
}
/// Metadata returned by HEAD — just enough for BUD-01 response headers.
pub struct BlobHeadMeta {
pub size: u64,
}
/// Full blob metadata — stored as sidecar JSON in `_meta/{community}/{sha256}.json`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BlobMeta {
/// Pixel dimensions ("WxH").
pub dim: String,
/// Blurhash string.
pub blurhash: String,
/// Full URL to thumbnail.
pub thumb_url: String,
/// File extension (e.g. "jpg").
pub ext: String,
/// MIME type (e.g. "image/jpeg").
pub mime_type: String,
/// File size in bytes.
pub size: u64,
/// Unix timestamp when the blob was first uploaded.
#[serde(default)]
pub uploaded_at: i64,
/// Video duration in seconds. `None` for non-video blobs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_secs: Option<f64>,
}