Skip to main content

max / makenotwork

audit Run 15 Phase 3: storage fail-open fixes - OTA re-upload resets scan_status to 'pending' in the ON CONFLICT DO UPDATE, so re-uploaded bytes to an existing release/target/arch key are re-scanned before artifact_download will serve them (was: stayed 'clean'). - CDN-served image kinds (item/project covers, gallery carousels, content-insertion clips) now carry a per-row scan_status (migration 162, default 'pending', existing rows backfilled 'clean'). The worker stamps the trust-overlaid verdict by s3_key (set_cdn_image_scan_status_by_key, symmetric with the quarantine purge); rendering is gated to 'clean' only, so pending AND held images are hidden until the scan clears (fail-closed, no data loss). Fan playback of insertions uses a clean-only resolver; creator management views and storage-accounting counts are unchanged. - clamav buffered scan path routes a mid-stream clamd disconnect (EPIPE on StreamMaxLength) to the same fail-closed incomplete-scan hold as the streaming path, instead of an Err that was mapped to fail-open.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 00:58 UTC
Signed with PGP, not checked
Commit: 2389eaaabc453ada4a229c11348af5a73a007054
Parent: bf61ca7
18 files changed, +540 insertions, -46 deletions
@@ -953,6 +953,7 @@
953 953 pwyw_enabled,
954 954 pwyw_min_cents,
955 955 scan_status: db::FileScanStatus::Clean,
956 + cover_scan_status: "clean".to_string(),
956 957 release_announced_at: None,
957 958 publish_at: None,
958 959 mt_thread_id: None,
@@ -990,6 +991,7 @@
990 991 description: None,
991 992 project_type: db::ProjectType::General,
992 993 cover_image_url: None,
994 + cover_scan_status: "clean".to_string(),
993 995 theme_id: None,
994 996 is_public: true,
995 997 created_at: chrono::Utc::now(),
@@ -165,6 +165,11 @@
165 165
166 166 /// List all placements for an item, joined with insertion metadata.
167 167 /// Ordered by position (pre_roll, mid_roll by offset, post_roll) then sort_order.
168 + ///
169 + /// This is the creator's placement-management view (it lists every placement the
170 + /// creator configured, including ones whose clip is still pending/held), so it is
171 + /// NOT scan-gated. The fan-facing playback path uses
172 + /// [`list_playable_placements_for_item`], which hides un-cleared clips.
168 173 #[tracing::instrument(skip_all)]
169 174 pub async fn list_placements_for_item(
170 175 pool: &PgPool,
@@ -198,6 +203,44 @@
198 203 Ok(rows)
199 204 }
200 205
206 + /// Fan-facing playback resolver: like [`list_placements_for_item`] but gated to
207 + /// insertions whose scan cleared (`i.scan_status = 'clean'`). A pending or held
208 + /// clip is never spliced into a fan's stream — the fail-closed gate for the
209 + /// gate-less, CDN-served insertion kind. Used only by the public media player
210 + /// (`build_segments_json`); creator management goes through the ungated variant.
211 + #[tracing::instrument(skip_all)]
212 + pub async fn list_playable_placements_for_item(
213 + pool: &PgPool,
214 + item_id: ItemId,
215 + ) -> Result<Vec<DbPlacementWithInsertion>> {
216 + let rows = sqlx::query_as::<_, DbPlacementWithInsertion>(
217 + r#"
218 + SELECT
219 + p.id, p.item_id, p.insertion_id, p.position, p.offset_ms, p.sort_order, p.created_at,
220 + i.title AS insertion_title,
221 + i.duration_ms AS insertion_duration_ms,
222 + i.storage_key AS insertion_storage_key
223 + FROM content_insertion_placements p
224 + JOIN content_insertions i ON i.id = p.insertion_id
225 + WHERE p.item_id = $1 AND i.scan_status = 'clean'
226 + ORDER BY
227 + CASE p.position
228 + WHEN 'pre_roll' THEN 0
229 + WHEN 'mid_roll' THEN 1
230 + WHEN 'post_roll' THEN 2
231 + END,
232 + p.offset_ms NULLS LAST,
233 + p.sort_order
234 + LIMIT 100
235 + "#,
236 + )
237 + .bind(item_id)
238 + .fetch_all(pool)
239 + .await?;
240 +
241 + Ok(rows)
242 + }
243 +
201 244 /// Delete a single placement by ID. Returns true if deleted.
202 245 #[tracing::instrument(skip_all)]
203 246 pub async fn delete_placement(
@@ -37,9 +37,13 @@
37 37 /// List an item's gallery images in display order.
38 38 #[tracing::instrument(skip_all)]
39 39 pub async fn list_for_item<'e>(executor: impl PgExecutor<'e>, item_id: ItemId) -> Result<Vec<GalleryImage>> {
40 + // Fail-closed render gate: only images whose scan cleared are carouselled.
41 + // Pending/held rows stay hidden. The per-entity cap (`count_for_item`) still
42 + // counts every row, so a held image cannot be re-uploaded around the limit.
40 43 let rows = sqlx::query_as::<_, GalleryImage>(
41 44 "SELECT id, s3_key, image_url, alt, position, file_size_bytes \
42 - FROM item_images WHERE item_id = $1 ORDER BY position, created_at",
45 + FROM item_images WHERE item_id = $1 AND scan_status = 'clean' \
46 + ORDER BY position, created_at",
43 47 )
44 48 .bind(item_id)
45 49 .fetch_all(executor)
@@ -151,9 +155,12 @@
151 155 /// List a project's gallery images in display order.
152 156 #[tracing::instrument(skip_all)]
153 157 pub async fn list_for_project<'e>(executor: impl PgExecutor<'e>, project_id: ProjectId) -> Result<Vec<GalleryImage>> {
158 + // Fail-closed render gate: only cleared images are carouselled (see
159 + // `list_for_item`). The cap count (`count_for_project`) still counts all rows.
154 160 let rows = sqlx::query_as::<_, GalleryImage>(
155 161 "SELECT id, s3_key, image_url, alt, position, file_size_bytes \
156 - FROM project_images WHERE project_id = $1 ORDER BY position, created_at",
162 + FROM project_images WHERE project_id = $1 AND scan_status = 'clean' \
163 + ORDER BY position, created_at",
157 164 )
158 165 .bind(project_id)
159 166 .fetch_all(executor)
@@ -204,7 +204,8 @@
204 204 INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size)
205 205 VALUES ($1, $2, $3, $4, $5)
206 206 ON CONFLICT (release_id, target, arch)
207 - DO UPDATE SET s3_key = EXCLUDED.s3_key, file_size = EXCLUDED.file_size
207 + DO UPDATE SET s3_key = EXCLUDED.s3_key, file_size = EXCLUDED.file_size,
208 + scan_status = 'pending'
208 209 RETURNING *
209 210 "#,
210 211 )
@@ -302,6 +302,7 @@
302 302 p.description,
303 303 p.project_type,
304 304 p.cover_image_url,
305 + p.cover_scan_status,
305 306 p.is_public,
306 307 p.created_at,
307 308 p.updated_at,
@@ -90,6 +90,45 @@
90 90 Ok(removed)
91 91 }
92 92
93 + /// Set the per-row `scan_status` on every CDN-served image surface keyed by
94 + /// `s3_key`, in one transaction. The symmetric read-side counterpart of
95 + /// [`purge_cdn_image_rows_by_key`]: where the purge DELETEs (or NULLs) a
96 + /// quarantined reference, this stamps a non-quarantine terminal verdict —
97 + /// `clean` (renders) or `held` (stays hidden) — so the fail-closed gate can
98 + /// distinguish "not yet scanned" (`pending`) from "scanned and cleared".
99 + ///
100 + /// Touches the same surfaces as the purge: the gallery tables
101 + /// (`item_images.s3_key`, `project_images.s3_key`,
102 + /// `content_insertions.storage_key`) and the cover columns on `items` /
103 + /// `projects` (`cover_s3_key`). Wrapped in a single transaction so a mid-loop
104 + /// failure can't leave the reference set half-stamped (some surfaces cleared to
105 + /// `clean` while others stay `pending`). Returns total rows affected.
106 + #[tracing::instrument(skip_all)]
107 + pub async fn set_cdn_image_scan_status_by_key(
108 + db: &PgPool,
109 + s3_key: &str,
110 + status: FileScanStatus,
111 + ) -> Result<u64, sqlx::Error> {
112 + let mut tx = db.begin().await?;
113 + let mut affected = 0u64;
114 + for sql in [
115 + "UPDATE item_images SET scan_status = $1 WHERE s3_key = $2",
116 + "UPDATE project_images SET scan_status = $1 WHERE s3_key = $2",
117 + "UPDATE content_insertions SET scan_status = $1 WHERE storage_key = $2",
118 + "UPDATE items SET cover_scan_status = $1 WHERE cover_s3_key = $2",
119 + "UPDATE projects SET cover_scan_status = $1 WHERE cover_s3_key = $2",
120 + ] {
121 + affected += sqlx::query(sql)
122 + .bind(status)
123 + .bind(s3_key)
124 + .execute(&mut *tx)
125 + .await?
126 + .rows_affected();
127 + }
128 + tx.commit().await?;
129 + Ok(affected)
130 + }
131 +
93 132 pub async fn insert_scan_result(
94 133 db: &PgPool,
95 134 s3_key: &str,
@@ -109,24 +109,23 @@
109 109 .await
110 110 .map_err(|e| format!("Failed to send INSTREAM command: {}", e))?;
111 111
112 - // Send data in chunks: each chunk prefixed with 4-byte big-endian length
112 + // Send data in chunks: each chunk prefixed with 4-byte big-endian length.
113 + // If clamd drops the connection mid-stream (e.g. it hit StreamMaxLength), a
114 + // write fails with EPIPE. Route that to the same fail-closed incomplete-scan
115 + // hold the streaming path uses (`incomplete_after_clamd_dropped`) instead of
116 + // returning an Err — an Err here was mapped to a clamav "degraded" error and
117 + // then fail-OPEN, the opposite posture from the streaming twin (Run 15).
113 118 for chunk in data.chunks(CHUNK_SIZE) {
114 119 let len = (chunk.len() as u32).to_be_bytes();
115 - stream
116 - .write_all(&len)
117 - .await
118 - .map_err(|e| format!("Failed to write chunk length: {}", e))?;
119 - stream
120 - .write_all(chunk)
121 - .await
122 - .map_err(|e| format!("Failed to write chunk data: {}", e))?;
120 + if stream.write_all(&len).await.is_err() || stream.write_all(chunk).await.is_err() {
121 + return Ok(incomplete_after_clamd_dropped(&mut stream).await);
122 + }
123 123 }
124 124
125 125 // Send zero-length chunk to signal end of data
126 - stream
127 - .write_all(&0u32.to_be_bytes())
128 - .await
129 - .map_err(|e| format!("Failed to send end marker: {}", e))?;
126 + if stream.write_all(&0u32.to_be_bytes()).await.is_err() {
127 + return Ok(incomplete_after_clamd_dropped(&mut stream).await);
128 + }
130 129
131 130 // Read response (capped at 16 KB — verdicts are typically under 100 bytes)
132 131 let mut response = Vec::with_capacity(256);