1use std::{
4 fs,
5 io::Write,
6 path::{Path, PathBuf},
7 time::{Duration, SystemTime, UNIX_EPOCH},
8};
9
10use imagegen_bridge_core::{ArtifactCollisionPolicy, BridgeError, ErrorCode, OutputFormat};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use tempfile::NamedTempFile;
14use uuid::Uuid;
15
16use crate::{ImageLimits, ImageMetadata, inspect_image};
17
18const OWNERSHIP_DIRECTORY: &str = ".imagegen-bridge-ownership";
19const MAX_MARKER_BYTES: u64 = 4 * 1024;
20const MAX_SIDECAR_BYTES: u64 = 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct StoredArtifact {
25 pub id: String,
27 pub name: String,
29 pub path: PathBuf,
31 pub metadata: ImageMetadata,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct StoredSidecar {
38 pub name: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct StoredArtifactContent {
45 pub id: String,
47 pub name: String,
49 pub bytes: Vec<u8>,
51 pub metadata: ImageMetadata,
53}
54
55#[derive(Debug, Clone, Copy, Default)]
57pub struct ArtifactPublication<'a> {
58 pub directory: Option<&'a str>,
60 pub filename: Option<&'a str>,
62 pub collision: ArtifactCollisionPolicy,
64}
65
66#[derive(Debug, Clone)]
68pub struct ArtifactStore {
69 root: PathBuf,
70 ownership_root: PathBuf,
71 limits: ImageLimits,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct RetentionPolicy {
77 pub max_age: Duration,
79 pub max_artifacts: Option<usize>,
81 pub max_scan_entries: usize,
83}
84
85impl Default for RetentionPolicy {
86 fn default() -> Self {
87 Self {
88 max_age: Duration::from_secs(7 * 24 * 60 * 60),
89 max_artifacts: None,
90 max_scan_entries: 100_000,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
97pub struct CleanupReport {
98 pub scanned: usize,
100 pub deleted: usize,
102 pub skipped: usize,
104 pub scan_limit_reached: bool,
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
110pub struct ArtifactRepairReport {
111 pub scanned: usize,
113 pub healthy: usize,
115 pub orphaned_records: usize,
117 pub missing_sidecars: usize,
119 pub repaired: usize,
121 pub skipped: usize,
123 pub scan_limit_reached: bool,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum ArtifactRepairMode {
130 Audit,
132 Apply,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138struct OwnershipRecord {
139 version: u8,
140 id: String,
141 name: String,
142 created_at: u64,
143 sha256: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 sidecar_name: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 sidecar_sha256: Option<String>,
148}
149
150struct OwnedCandidate {
151 marker: PathBuf,
152 artifact: PathBuf,
153 sidecar: Option<PathBuf>,
154 record: OwnershipRecord,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158enum SidecarState {
159 None,
160 Missing,
161 Valid,
162 Invalid,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166enum OwnedPathState {
167 Missing,
168 Regular(u64),
169 Invalid,
170}
171
172impl ArtifactStore {
173 pub fn new(root: impl Into<PathBuf>, limits: ImageLimits) -> Result<Self, BridgeError> {
175 let root = root.into();
176 fs::create_dir_all(&root)
177 .map_err(|error| artifact_error(format!("could not create artifact root: {error}")))?;
178 let root = fs::canonicalize(root)
179 .map_err(|error| artifact_error(format!("could not open artifact root: {error}")))?;
180 if !root.is_dir() {
181 return Err(artifact_error("artifact root is not a directory"));
182 }
183 let ownership_path = root.join(OWNERSHIP_DIRECTORY);
184 fs::create_dir_all(&ownership_path).map_err(|error| {
185 artifact_error(format!("could not create artifact ownership root: {error}"))
186 })?;
187 if fs::symlink_metadata(&ownership_path)
188 .map_err(|error| {
189 artifact_error(format!(
190 "could not inspect artifact ownership root: {error}"
191 ))
192 })?
193 .file_type()
194 .is_symlink()
195 {
196 return Err(artifact_error(
197 "artifact ownership root must not be a symbolic link",
198 ));
199 }
200 let ownership_root = fs::canonicalize(ownership_path).map_err(|error| {
201 artifact_error(format!("could not open artifact ownership root: {error}"))
202 })?;
203 if !ownership_root.is_dir()
204 || ownership_root.parent() != Some(root.as_path())
205 || !ownership_root.starts_with(&root)
206 || ownership_root.file_name().and_then(|value| value.to_str())
207 != Some(OWNERSHIP_DIRECTORY)
208 {
209 return Err(artifact_error(
210 "artifact ownership root must be a real child directory",
211 ));
212 }
213 Ok(Self {
214 root,
215 ownership_root,
216 limits,
217 })
218 }
219
220 pub fn publish(
222 &self,
223 bytes: &[u8],
224 filename_prefix: Option<&str>,
225 expected_format: Option<OutputFormat>,
226 ) -> Result<StoredArtifact, BridgeError> {
227 self.publish_with_options(
228 bytes,
229 filename_prefix,
230 expected_format,
231 ArtifactPublication::default(),
232 )
233 }
234
235 pub fn publish_with_options(
237 &self,
238 bytes: &[u8],
239 filename_prefix: Option<&str>,
240 expected_format: Option<OutputFormat>,
241 publication: ArtifactPublication<'_>,
242 ) -> Result<StoredArtifact, BridgeError> {
243 let metadata = inspect_image(bytes, self.limits).map_err(|error| BridgeError {
244 code: ErrorCode::Artifact,
245 ..error
246 })?;
247 if expected_format.is_some_and(|expected| expected != metadata.format) {
248 return Err(artifact_error(
249 "generated image format does not match the effective request",
250 ));
251 }
252
253 let id = Uuid::now_v7().to_string();
254 let (directory, portable_directory) = self.publication_directory(publication.directory)?;
255 if let Some(filename) = publication.filename
256 && !safe_filename(filename, metadata.format)
257 {
258 return Err(artifact_error(
259 "output filename must be a safe component with a matching image extension",
260 ));
261 }
262 let requested_name = publication.filename.map_or_else(
263 || {
264 let prefix = sanitize_prefix(filename_prefix.unwrap_or("image"));
265 format!("{prefix}-{id}.{}", extension(metadata.format))
266 },
267 |filename| filename_with_extension(filename, metadata.format),
268 );
269 let (destination, filename) = publish_noclobber(
270 &directory,
271 &requested_name,
272 bytes,
273 publication.collision,
274 publication.filename.is_some(),
275 )?;
276 if let Err(error) = sync_directory(&directory) {
277 let _ = fs::remove_file(&destination);
278 return Err(error);
279 }
280
281 let name = portable_directory.map_or_else(
282 || filename.clone(),
283 |directory| format!("{directory}/{filename}"),
284 );
285
286 let record = OwnershipRecord {
287 version: 1,
288 id: id.clone(),
289 name: name.clone(),
290 created_at: unix_timestamp(SystemTime::now())?,
291 sha256: metadata.sha256.clone(),
292 sidecar_name: None,
293 sidecar_sha256: None,
294 };
295 if let Err(error) = self.publish_ownership(&record) {
296 let _ = fs::remove_file(&destination);
297 let _ = sync_directory(&self.root);
298 return Err(error);
299 }
300
301 Ok(StoredArtifact {
302 id,
303 name,
304 path: destination,
305 metadata,
306 })
307 }
308
309 pub fn attach_metadata(
311 &self,
312 artifact_id: &str,
313 artifact_name: &str,
314 encoded: &[u8],
315 ) -> Result<StoredSidecar, BridgeError> {
316 if Uuid::parse_str(artifact_id).is_err() || !safe_relative(artifact_name) {
317 return Err(artifact_error("artifact identity is invalid"));
318 }
319 if encoded.is_empty()
320 || u64::try_from(encoded.len()).unwrap_or(u64::MAX) > MAX_SIDECAR_BYTES
321 || !matches!(
322 serde_json::from_slice::<serde_json::Value>(encoded),
323 Ok(serde_json::Value::Object(_))
324 )
325 {
326 return Err(artifact_error(
327 "artifact metadata must be a bounded JSON object",
328 ));
329 }
330 let marker = self.ownership_root.join(format!("{artifact_id}.json"));
331 let mut candidate = self
332 .read_candidate(&marker)
333 .map_err(|()| artifact_error("artifact ownership record is invalid"))?;
334 if candidate.record.name != artifact_name
335 || candidate.record.sidecar_name.is_some()
336 || candidate.record.sidecar_sha256.is_some()
337 || self.verify_candidate(&candidate).is_err()
338 {
339 return Err(artifact_error(
340 "artifact metadata cannot be attached to this artifact",
341 ));
342 }
343 let directory = candidate
344 .artifact
345 .parent()
346 .ok_or_else(|| artifact_error("artifact directory is invalid"))?;
347 let filename = format!("metadata-{artifact_id}.json");
348 let destination = directory.join(&filename);
349 let mut temporary = NamedTempFile::new_in(directory)
350 .map_err(|_| artifact_error("could not create metadata sidecar"))?;
351 temporary
352 .write_all(encoded)
353 .and_then(|()| temporary.as_file().sync_all())
354 .map_err(|_| artifact_error("could not write metadata sidecar"))?;
355 temporary
356 .persist_noclobber(&destination)
357 .map_err(|_| artifact_error("could not publish metadata sidecar without overwrite"))?;
358 sync_directory(directory)?;
359
360 let portable_name = Path::new(artifact_name)
361 .parent()
362 .filter(|parent| !parent.as_os_str().is_empty())
363 .and_then(Path::to_str)
364 .map_or_else(|| filename.clone(), |parent| format!("{parent}/{filename}"));
365 candidate.record.sidecar_name = Some(portable_name.clone());
366 candidate.record.sidecar_sha256 =
367 Some(base16ct::lower::encode_string(&Sha256::digest(encoded)));
368 if let Err(error) = self.replace_ownership(&candidate.record) {
369 let _ = fs::remove_file(&destination);
370 let _ = sync_directory(directory);
371 return Err(error);
372 }
373 Ok(StoredSidecar {
374 name: portable_name,
375 })
376 }
377
378 pub fn read(&self, artifact_id: &str) -> Result<StoredArtifactContent, BridgeError> {
380 if Uuid::parse_str(artifact_id).is_err() {
381 return Err(artifact_error("artifact identity is invalid"));
382 }
383 let marker = self.ownership_root.join(format!("{artifact_id}.json"));
384 let candidate = self
385 .read_candidate(&marker)
386 .map_err(|()| artifact_error("artifact was not found or is invalid"))?;
387 self.verify_candidate(&candidate)
388 .map_err(|()| artifact_error("artifact was not found or is invalid"))?;
389 let bytes = fs::read(&candidate.artifact)
390 .map_err(|_| artifact_error("artifact could not be read"))?;
391 let metadata = inspect_image(&bytes, self.limits)
392 .map_err(|_| artifact_error("artifact failed delivery verification"))?;
393 Ok(StoredArtifactContent {
394 id: candidate.record.id,
395 name: candidate.record.name,
396 bytes,
397 metadata,
398 })
399 }
400
401 fn publication_directory(
402 &self,
403 relative: Option<&str>,
404 ) -> Result<(PathBuf, Option<String>), BridgeError> {
405 let Some(relative) = relative else {
406 return Ok((self.root.clone(), None));
407 };
408 if !safe_relative(relative) {
409 return Err(artifact_error(
410 "output directory is not a safe relative path",
411 ));
412 }
413 let mut directory = self.root.clone();
414 for component in relative.split('/') {
415 directory.push(component);
416 match fs::symlink_metadata(&directory) {
417 Ok(metadata) if metadata.file_type().is_dir() => {}
418 Ok(_) => {
419 return Err(artifact_error(
420 "output directory component must not be a file or symlink",
421 ));
422 }
423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
424 fs::create_dir(&directory).map_err(|_| {
425 artifact_error("could not create output directory component")
426 })?;
427 }
428 Err(_) => return Err(artifact_error("could not inspect output directory")),
429 }
430 }
431 let canonical = fs::canonicalize(&directory)
432 .map_err(|_| artifact_error("could not open output directory"))?;
433 if !canonical.starts_with(&self.root) || canonical == self.ownership_root {
434 return Err(artifact_error("output directory escapes the artifact root"));
435 }
436 Ok((canonical, Some(relative.to_owned())))
437 }
438
439 #[must_use]
441 pub fn root(&self) -> &Path {
442 &self.root
443 }
444
445 pub fn cleanup(
447 &self,
448 policy: RetentionPolicy,
449 now: SystemTime,
450 ) -> Result<CleanupReport, BridgeError> {
451 if policy.max_scan_entries == 0 {
452 return Err(artifact_error(
453 "retention scan limit must be greater than zero",
454 ));
455 }
456 self.verify_ownership_root()?;
457 let now = unix_timestamp(now)?;
458 let cutoff = now.saturating_sub(policy.max_age.as_secs());
459 let mut report = CleanupReport::default();
460 let mut candidates = Vec::new();
461 for entry in fs::read_dir(&self.ownership_root)
462 .map_err(|error| artifact_error(format!("could not scan ownership records: {error}")))?
463 {
464 if report.scanned >= policy.max_scan_entries {
465 report.scan_limit_reached = true;
466 break;
467 }
468 report.scanned += 1;
469 let Ok(entry) = entry else {
470 report.skipped += 1;
471 continue;
472 };
473 match self.read_candidate(&entry.path()) {
474 Ok(candidate) if self.verify_candidate(&candidate).is_ok() => {
475 candidates.push(candidate);
476 }
477 Err(()) | Ok(_) => report.skipped += 1,
478 }
479 }
480
481 candidates.sort_by(|left, right| {
482 right
483 .record
484 .created_at
485 .cmp(&left.record.created_at)
486 .then_with(|| right.record.id.cmp(&left.record.id))
487 });
488 for (index, candidate) in candidates.into_iter().enumerate() {
489 let exceeds_count = policy.max_artifacts.is_some_and(|maximum| index >= maximum);
490 let expired = candidate.record.created_at <= cutoff;
491 if !expired && !exceeds_count {
492 continue;
493 }
494 if self.remove_verified(&candidate).is_ok() {
495 report.deleted += 1;
496 } else {
497 report.skipped += 1;
498 }
499 }
500 Ok(report)
501 }
502
503 pub fn repair_orphans(
511 &self,
512 max_scan_entries: usize,
513 mode: ArtifactRepairMode,
514 ) -> Result<ArtifactRepairReport, BridgeError> {
515 if max_scan_entries == 0 {
516 return Err(artifact_error(
517 "artifact repair scan limit must be greater than zero",
518 ));
519 }
520 self.verify_ownership_root()?;
521 let mut report = ArtifactRepairReport::default();
522 for entry in fs::read_dir(&self.ownership_root)
523 .map_err(|error| artifact_error(format!("could not scan ownership records: {error}")))?
524 {
525 if report.scanned >= max_scan_entries {
526 report.scan_limit_reached = true;
527 break;
528 }
529 report.scanned += 1;
530 let Ok(entry) = entry else {
531 report.skipped += 1;
532 continue;
533 };
534 let Ok(candidate) = self.read_candidate(&entry.path()) else {
535 report.skipped += 1;
536 continue;
537 };
538 if self.verify_artifact(&candidate).is_ok() {
539 match self.sidecar_state(&candidate) {
540 SidecarState::None | SidecarState::Valid => report.healthy += 1,
541 SidecarState::Missing => {
542 report.missing_sidecars += 1;
543 if mode == ArtifactRepairMode::Apply {
544 if self.clear_missing_sidecar(&candidate).is_ok() {
545 report.repaired += 1;
546 } else {
547 report.skipped += 1;
548 }
549 }
550 }
551 SidecarState::Invalid => report.skipped += 1,
552 }
553 } else if self.owned_path_state(&candidate.artifact) == OwnedPathState::Missing {
554 report.orphaned_records += 1;
555 match self.sidecar_state(&candidate) {
556 SidecarState::None | SidecarState::Missing | SidecarState::Valid => {
557 if mode == ArtifactRepairMode::Apply {
558 if self.remove_orphaned_record(&candidate).is_ok() {
559 report.repaired += 1;
560 } else {
561 report.skipped += 1;
562 }
563 }
564 }
565 SidecarState::Invalid => report.skipped += 1,
566 }
567 } else {
568 report.skipped += 1;
569 }
570 }
571 Ok(report)
572 }
573
574 fn publish_ownership(&self, record: &OwnershipRecord) -> Result<(), BridgeError> {
575 self.verify_ownership_root()?;
576 let encoded = serde_json::to_vec(record)
577 .map_err(|_| artifact_error("could not encode artifact ownership record"))?;
578 if u64::try_from(encoded.len()).unwrap_or(u64::MAX) > MAX_MARKER_BYTES {
579 return Err(artifact_error("artifact ownership record is too large"));
580 }
581 let destination = self.ownership_root.join(format!("{}.json", record.id));
582 let mut temporary = NamedTempFile::new_in(&self.ownership_root).map_err(|error| {
583 artifact_error(format!("could not create ownership record: {error}"))
584 })?;
585 temporary
586 .write_all(&encoded)
587 .and_then(|()| temporary.as_file().sync_all())
588 .map_err(|error| {
589 artifact_error(format!("could not write ownership record: {error}"))
590 })?;
591 temporary.persist_noclobber(destination).map_err(|error| {
592 artifact_error(format!("could not publish ownership record: {error}"))
593 })?;
594 sync_directory(&self.ownership_root)
595 }
596
597 fn replace_ownership(&self, record: &OwnershipRecord) -> Result<(), BridgeError> {
598 self.verify_ownership_root()?;
599 let encoded = serde_json::to_vec(record)
600 .map_err(|_| artifact_error("could not encode artifact ownership record"))?;
601 if u64::try_from(encoded.len()).unwrap_or(u64::MAX) > MAX_MARKER_BYTES {
602 return Err(artifact_error("artifact ownership record is too large"));
603 }
604 let destination = self.ownership_root.join(format!("{}.json", record.id));
605 let mut temporary = NamedTempFile::new_in(&self.ownership_root)
606 .map_err(|_| artifact_error("could not create ownership record"))?;
607 temporary
608 .write_all(&encoded)
609 .and_then(|()| temporary.as_file().sync_all())
610 .map_err(|_| artifact_error("could not write ownership record"))?;
611 temporary
612 .persist(&destination)
613 .map_err(|_| artifact_error("could not replace ownership record"))?;
614 sync_directory(&self.ownership_root)
615 }
616
617 fn read_candidate(&self, marker: &Path) -> Result<OwnedCandidate, ()> {
618 if marker.parent() != Some(self.ownership_root.as_path())
619 || self.verify_ownership_root().is_err()
620 {
621 return Err(());
622 }
623 let marker_metadata = fs::symlink_metadata(marker).map_err(|_| ())?;
624 if !marker_metadata.file_type().is_file() || marker_metadata.len() > MAX_MARKER_BYTES {
625 return Err(());
626 }
627 let marker_name = marker
628 .file_name()
629 .and_then(|value| value.to_str())
630 .ok_or(())?;
631 let encoded = fs::read(marker).map_err(|_| ())?;
632 let record: OwnershipRecord = serde_json::from_slice(&encoded).map_err(|_| ())?;
633 let sidecar_valid = match (&record.sidecar_name, &record.sidecar_sha256) {
634 (None, None) => true,
635 (Some(name), Some(sha256)) => {
636 safe_relative(name)
637 && sha256.len() == 64
638 && sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
639 }
640 _ => false,
641 };
642 if record.version != 1
643 || marker_name != format!("{}.json", record.id)
644 || Uuid::parse_str(&record.id).is_err()
645 || !safe_relative(&record.name)
646 || record.sha256.len() != 64
647 || !record.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
648 || !sidecar_valid
649 {
650 return Err(());
651 }
652 Ok(OwnedCandidate {
653 marker: marker.to_owned(),
654 artifact: self.root.join(&record.name),
655 sidecar: record
656 .sidecar_name
657 .as_deref()
658 .map(|name| self.root.join(name)),
659 record,
660 })
661 }
662
663 fn remove_verified(&self, candidate: &OwnedCandidate) -> Result<(), ()> {
664 self.verify_candidate(candidate)?;
665 if let Some(sidecar) = &candidate.sidecar {
666 fs::remove_file(sidecar).map_err(|_| ())?;
667 }
668 fs::remove_file(&candidate.artifact).map_err(|_| ())?;
669 fs::remove_file(&candidate.marker).map_err(|_| ())?;
670 sync_directory(&self.root).map_err(|_| ())?;
671 sync_directory(&self.ownership_root).map_err(|_| ())
672 }
673
674 fn clear_missing_sidecar(&self, expected: &OwnedCandidate) -> Result<(), ()> {
675 let mut candidate = self.read_candidate(&expected.marker)?;
676 if candidate.record != expected.record
677 || self.verify_artifact(&candidate).is_err()
678 || self.sidecar_state(&candidate) != SidecarState::Missing
679 {
680 return Err(());
681 }
682 candidate.record.sidecar_name = None;
683 candidate.record.sidecar_sha256 = None;
684 self.replace_ownership(&candidate.record).map_err(|_| ())
685 }
686
687 fn remove_orphaned_record(&self, expected: &OwnedCandidate) -> Result<(), ()> {
688 let candidate = self.read_candidate(&expected.marker)?;
689 if candidate.record != expected.record
690 || self.owned_path_state(&candidate.artifact) != OwnedPathState::Missing
691 {
692 return Err(());
693 }
694 match self.sidecar_state(&candidate) {
695 SidecarState::Valid => {
696 let sidecar = candidate.sidecar.as_ref().ok_or(())?;
697 fs::remove_file(sidecar).map_err(|_| ())?;
698 sync_directory(sidecar.parent().ok_or(())?).map_err(|_| ())?;
699 }
700 SidecarState::None | SidecarState::Missing => {}
701 SidecarState::Invalid => return Err(()),
702 }
703 fs::remove_file(&candidate.marker).map_err(|_| ())?;
704 sync_directory(&self.ownership_root).map_err(|_| ())
705 }
706
707 fn verify_candidate(&self, candidate: &OwnedCandidate) -> Result<(), ()> {
708 self.verify_artifact(candidate)?;
709 if !matches!(
710 self.sidecar_state(candidate),
711 SidecarState::None | SidecarState::Valid
712 ) {
713 return Err(());
714 }
715 Ok(())
716 }
717
718 fn verify_artifact(&self, candidate: &OwnedCandidate) -> Result<(), ()> {
719 let OwnedPathState::Regular(length) = self.owned_path_state(&candidate.artifact) else {
720 return Err(());
721 };
722 if length > self.limits.max_encoded_bytes {
723 return Err(());
724 }
725 let bytes = fs::read(&candidate.artifact).map_err(|_| ())?;
726 let digest = base16ct::lower::encode_string(&Sha256::digest(&bytes));
727 if digest != candidate.record.sha256 || inspect_image(&bytes, self.limits).is_err() {
728 return Err(());
729 }
730 Ok(())
731 }
732
733 fn sidecar_state(&self, candidate: &OwnedCandidate) -> SidecarState {
734 let (Some(sidecar), Some(expected)) =
735 (&candidate.sidecar, &candidate.record.sidecar_sha256)
736 else {
737 return SidecarState::None;
738 };
739 match self.owned_path_state(sidecar) {
740 OwnedPathState::Missing => SidecarState::Missing,
741 OwnedPathState::Invalid => SidecarState::Invalid,
742 OwnedPathState::Regular(length) => {
743 if length > MAX_SIDECAR_BYTES {
744 return SidecarState::Invalid;
745 }
746 let Ok(encoded) = fs::read(sidecar) else {
747 return SidecarState::Invalid;
748 };
749 if base16ct::lower::encode_string(&Sha256::digest(&encoded)) != *expected
750 || !matches!(
751 serde_json::from_slice::<serde_json::Value>(&encoded),
752 Ok(serde_json::Value::Object(_))
753 )
754 {
755 SidecarState::Invalid
756 } else {
757 SidecarState::Valid
758 }
759 }
760 }
761 }
762
763 fn owned_path_state(&self, path: &Path) -> OwnedPathState {
764 owned_path_state_beneath(&self.root, path)
765 }
766
767 fn verify_ownership_root(&self) -> Result<(), BridgeError> {
768 let metadata = fs::symlink_metadata(&self.ownership_root)
769 .map_err(|_| artifact_error("artifact ownership root is unavailable"))?;
770 if !metadata.file_type().is_dir()
771 || self.ownership_root.parent() != Some(self.root.as_path())
772 {
773 return Err(artifact_error("artifact ownership root is invalid"));
774 }
775 Ok(())
776 }
777}
778
779fn owned_path_state_beneath(root: &Path, path: &Path) -> OwnedPathState {
780 let Ok(relative) = path.strip_prefix(root) else {
781 return OwnedPathState::Invalid;
782 };
783 let mut components = relative.components().peekable();
784 if components.peek().is_none() {
785 return OwnedPathState::Invalid;
786 }
787 let mut current = root.to_owned();
788 while let Some(component) = components.next() {
789 let std::path::Component::Normal(component) = component else {
790 return OwnedPathState::Invalid;
791 };
792 current.push(component);
793 let metadata = match fs::symlink_metadata(¤t) {
794 Ok(metadata) => metadata,
795 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
796 return OwnedPathState::Missing;
797 }
798 Err(_) => return OwnedPathState::Invalid,
799 };
800 if metadata.file_type().is_symlink() {
801 return OwnedPathState::Invalid;
802 }
803 if components.peek().is_some() {
804 if !metadata.file_type().is_dir() {
805 return OwnedPathState::Invalid;
806 }
807 } else if metadata.file_type().is_file() {
808 return OwnedPathState::Regular(metadata.len());
809 } else {
810 return OwnedPathState::Invalid;
811 }
812 }
813 OwnedPathState::Invalid
814}
815
816fn safe_relative(value: &str) -> bool {
817 !value.is_empty()
818 && value.len() <= 512
819 && value.split('/').all(|component| {
820 !component.is_empty()
821 && component != "."
822 && component != ".."
823 && component.len() <= 160
824 && !component.starts_with('.')
825 && component
826 .bytes()
827 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
828 })
829}
830
831fn safe_filename(value: &str, format: OutputFormat) -> bool {
832 if value.is_empty()
833 || value.len() > 160
834 || value.starts_with('.')
835 || value.contains(['/', '\\'])
836 || !value
837 .bytes()
838 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
839 {
840 return false;
841 }
842 let Some((_, extension)) = value.rsplit_once('.') else {
843 return true;
844 };
845 match format {
846 OutputFormat::Png => extension.eq_ignore_ascii_case("png"),
847 OutputFormat::Jpeg => {
848 extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg")
849 }
850 OutputFormat::Webp => extension.eq_ignore_ascii_case("webp"),
851 }
852}
853
854fn filename_with_extension(filename: &str, format: OutputFormat) -> String {
855 if Path::new(filename).extension().is_some() {
856 filename.to_owned()
857 } else {
858 format!("{filename}.{}", extension(format))
859 }
860}
861
862fn publish_noclobber(
863 directory: &Path,
864 requested_name: &str,
865 bytes: &[u8],
866 collision: ArtifactCollisionPolicy,
867 explicit_filename: bool,
868) -> Result<(PathBuf, String), BridgeError> {
869 let attempts = if explicit_filename && collision == ArtifactCollisionPolicy::Suffix {
870 10_000
871 } else {
872 1
873 };
874 for attempt in 0..attempts {
875 let name = if attempt == 0 {
876 requested_name.to_owned()
877 } else {
878 suffixed_filename(requested_name, attempt + 1)
879 };
880 let destination = directory.join(&name);
881 let mut temporary = NamedTempFile::new_in(directory)
882 .map_err(|_| artifact_error("could not create temporary artifact"))?;
883 temporary
884 .write_all(bytes)
885 .and_then(|()| temporary.as_file().sync_all())
886 .map_err(|_| artifact_error("could not write artifact"))?;
887 match temporary.persist_noclobber(&destination) {
888 Ok(_) => return Ok((destination, name)),
889 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
890 Err(_) => {
891 return Err(artifact_error(
892 "could not publish artifact without overwrite",
893 ));
894 }
895 }
896 }
897 Err(artifact_error(
898 "artifact collision suffix limit was reached",
899 ))
900}
901
902fn suffixed_filename(filename: &str, suffix: usize) -> String {
903 let path = Path::new(filename);
904 let stem = path
905 .file_stem()
906 .and_then(|value| value.to_str())
907 .unwrap_or("image");
908 path.extension()
909 .and_then(|value| value.to_str())
910 .map_or_else(
911 || format!("{stem}-{suffix}"),
912 |extension| format!("{stem}-{suffix}.{extension}"),
913 )
914}
915
916fn unix_timestamp(time: SystemTime) -> Result<u64, BridgeError> {
917 time.duration_since(UNIX_EPOCH)
918 .map(|duration| duration.as_secs())
919 .map_err(|_| artifact_error("system time is before the Unix epoch"))
920}
921
922fn sanitize_prefix(value: &str) -> String {
923 let sanitized: String = value
924 .chars()
925 .filter_map(|character| {
926 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
927 Some(character.to_ascii_lowercase())
928 } else if character.is_whitespace() {
929 Some('-')
930 } else {
931 None
932 }
933 })
934 .take(64)
935 .collect();
936 let sanitized = sanitized.trim_matches('-');
937 if sanitized.is_empty() {
938 "image".to_owned()
939 } else {
940 sanitized.to_owned()
941 }
942}
943
944const fn extension(format: OutputFormat) -> &'static str {
945 match format {
946 OutputFormat::Png => "png",
947 OutputFormat::Jpeg => "jpg",
948 OutputFormat::Webp => "webp",
949 }
950}
951
952#[cfg(unix)]
953fn sync_directory(path: &Path) -> Result<(), BridgeError> {
954 fs::File::open(path)
955 .and_then(|directory| directory.sync_all())
956 .map_err(|error| artifact_error(format!("could not sync artifact directory: {error}")))
957}
958
959#[cfg(not(unix))]
960fn sync_directory(_path: &Path) -> Result<(), BridgeError> {
961 Ok(())
962}
963
964fn artifact_error(message: impl Into<String>) -> BridgeError {
965 BridgeError::new(ErrorCode::Artifact, message)
966}
967
968#[cfg(test)]
969mod tests {
970 #![allow(clippy::unwrap_used)]
971
972 use std::fs;
973
974 use super::*;
975 use crate::inspect::test_png;
976
977 #[test]
978 fn publishes_verified_artifacts_without_name_reuse() {
979 let root = tempfile::tempdir().unwrap();
980 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
981 let bytes = test_png(2, 2);
982 let first = store
983 .publish(&bytes, Some("My portrait"), Some(OutputFormat::Png))
984 .unwrap();
985 let second = store
986 .publish(&bytes, Some("My portrait"), Some(OutputFormat::Png))
987 .unwrap();
988 assert_ne!(first.name, second.name);
989 assert!(first.name.starts_with("my-portrait-"));
990 assert_eq!(fs::read(first.path).unwrap(), bytes);
991 }
992
993 #[test]
994 fn rejects_mismatched_effective_format_before_write() {
995 let root = tempfile::tempdir().unwrap();
996 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
997 let error = store
998 .publish(&test_png(1, 1), None, Some(OutputFormat::Jpeg))
999 .unwrap_err();
1000 assert_eq!(error.code, ErrorCode::Artifact);
1001 assert_eq!(
1002 fs::read_dir(root.path())
1003 .unwrap()
1004 .filter_map(Result::ok)
1005 .filter(|entry| entry.file_name() != OWNERSHIP_DIRECTORY)
1006 .count(),
1007 0
1008 );
1009 }
1010
1011 #[test]
1012 fn cleanup_deletes_only_verified_bridge_owned_artifacts() {
1013 let root = tempfile::tempdir().unwrap();
1014 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1015 let owned = store
1016 .publish(&test_png(2, 2), Some("owned"), Some(OutputFormat::Png))
1017 .unwrap();
1018 let unowned = root.path().join("unowned.png");
1019 fs::write(&unowned, test_png(2, 2)).unwrap();
1020 let report = store
1021 .cleanup(
1022 RetentionPolicy {
1023 max_age: Duration::ZERO,
1024 ..RetentionPolicy::default()
1025 },
1026 SystemTime::now() + Duration::from_secs(1),
1027 )
1028 .unwrap();
1029 assert_eq!(report.deleted, 1);
1030 assert!(!owned.path.exists());
1031 assert!(unowned.exists());
1032 }
1033
1034 #[test]
1035 fn reads_only_matching_owned_artifacts() {
1036 let root = tempfile::tempdir().unwrap();
1037 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1038 let published = store
1039 .publish(&test_png(2, 3), Some("owned"), Some(OutputFormat::Png))
1040 .unwrap();
1041 let content = store.read(&published.id).unwrap();
1042 assert_eq!(content.id, published.id);
1043 assert_eq!(content.name, published.name);
1044 assert_eq!((content.metadata.width, content.metadata.height), (2, 3));
1045 assert!(store.read("019f0000-0000-7000-8000-000000000000").is_err());
1046 }
1047
1048 #[test]
1049 fn cleanup_does_not_delete_an_owned_path_after_content_replacement() {
1050 let root = tempfile::tempdir().unwrap();
1051 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1052 let owned = store
1053 .publish(&test_png(2, 2), None, Some(OutputFormat::Png))
1054 .unwrap();
1055 fs::write(&owned.path, test_png(3, 3)).unwrap();
1056 let report = store
1057 .cleanup(
1058 RetentionPolicy {
1059 max_age: Duration::ZERO,
1060 ..RetentionPolicy::default()
1061 },
1062 SystemTime::now() + Duration::from_secs(1),
1063 )
1064 .unwrap();
1065 assert_eq!(report.deleted, 0);
1066 assert_eq!(report.skipped, 1);
1067 assert!(owned.path.exists());
1068 }
1069
1070 #[test]
1071 fn cleanup_scan_is_bounded() {
1072 let root = tempfile::tempdir().unwrap();
1073 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1074 for index in 0..3 {
1075 fs::write(
1076 store.ownership_root.join(format!("invalid-{index}.json")),
1077 b"{}",
1078 )
1079 .unwrap();
1080 }
1081 let report = store
1082 .cleanup(
1083 RetentionPolicy {
1084 max_scan_entries: 2,
1085 ..RetentionPolicy::default()
1086 },
1087 SystemTime::now(),
1088 )
1089 .unwrap();
1090 assert_eq!(report.scanned, 2);
1091 assert_eq!(report.skipped, 2);
1092 assert!(report.scan_limit_reached);
1093 }
1094
1095 #[test]
1096 fn orphan_repair_is_auditable_and_removes_only_verified_owned_state() {
1097 let root = tempfile::tempdir().unwrap();
1098 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1099 let owned = store
1100 .publish(&test_png(2, 2), Some("orphan"), Some(OutputFormat::Png))
1101 .unwrap();
1102 let sidecar = store
1103 .attach_metadata(&owned.id, &owned.name, br#"{"prompt":"test"}"#)
1104 .unwrap();
1105 let marker = store.ownership_root.join(format!("{}.json", owned.id));
1106 let sidecar = root.path().join(sidecar.name);
1107 let unowned = root.path().join("unowned.png");
1108 fs::write(&unowned, test_png(1, 1)).unwrap();
1109 fs::remove_file(&owned.path).unwrap();
1110
1111 let audit = store.repair_orphans(10, ArtifactRepairMode::Audit).unwrap();
1112 assert_eq!(audit.scanned, 1);
1113 assert_eq!(audit.orphaned_records, 1);
1114 assert_eq!(audit.repaired, 0);
1115 assert!(marker.exists());
1116 assert!(sidecar.exists());
1117
1118 let repaired = store.repair_orphans(10, ArtifactRepairMode::Apply).unwrap();
1119 assert_eq!(repaired.orphaned_records, 1);
1120 assert_eq!(repaired.repaired, 1);
1121 assert!(!marker.exists());
1122 assert!(!sidecar.exists());
1123 assert!(unowned.exists());
1124 }
1125
1126 #[test]
1127 fn orphan_repair_clears_only_an_absent_sidecar_reference() {
1128 let root = tempfile::tempdir().unwrap();
1129 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1130 let owned = store
1131 .publish(&test_png(2, 2), Some("sidecar"), Some(OutputFormat::Png))
1132 .unwrap();
1133 let sidecar = store
1134 .attach_metadata(&owned.id, &owned.name, br#"{"prompt":"test"}"#)
1135 .unwrap();
1136 fs::remove_file(root.path().join(sidecar.name)).unwrap();
1137 assert!(store.read(&owned.id).is_err());
1138
1139 let audit = store.repair_orphans(10, ArtifactRepairMode::Audit).unwrap();
1140 assert_eq!(audit.missing_sidecars, 1);
1141 assert_eq!(audit.repaired, 0);
1142 let repaired = store.repair_orphans(10, ArtifactRepairMode::Apply).unwrap();
1143 assert_eq!(repaired.missing_sidecars, 1);
1144 assert_eq!(repaired.repaired, 1);
1145 assert_eq!(store.read(&owned.id).unwrap().name, owned.name);
1146 assert_eq!(
1147 store
1148 .repair_orphans(10, ArtifactRepairMode::Audit)
1149 .unwrap()
1150 .healthy,
1151 1
1152 );
1153 }
1154
1155 #[test]
1156 fn orphan_repair_never_modifies_changed_or_invalid_content() {
1157 let root = tempfile::tempdir().unwrap();
1158 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1159 let owned = store
1160 .publish(&test_png(2, 2), Some("changed"), Some(OutputFormat::Png))
1161 .unwrap();
1162 let sidecar = store
1163 .attach_metadata(&owned.id, &owned.name, br#"{"prompt":"test"}"#)
1164 .unwrap();
1165 let sidecar = root.path().join(sidecar.name);
1166 fs::write(&sidecar, br#"{"prompt":"changed"}"#).unwrap();
1167 fs::remove_file(&owned.path).unwrap();
1168
1169 let report = store.repair_orphans(10, ArtifactRepairMode::Apply).unwrap();
1170 assert_eq!(report.repaired, 0);
1171 assert_eq!(report.orphaned_records, 1);
1172 assert_eq!(report.skipped, 1);
1173 assert!(sidecar.exists());
1174 assert!(
1175 store
1176 .ownership_root
1177 .join(format!("{}.json", owned.id))
1178 .exists()
1179 );
1180 assert!(store.repair_orphans(0, ArtifactRepairMode::Audit).is_err());
1181 }
1182
1183 #[cfg(unix)]
1184 #[test]
1185 fn reads_cleanup_and_repair_reject_replaced_parent_symlinks() {
1186 use std::os::unix::fs::symlink;
1187
1188 let root = tempfile::tempdir().unwrap();
1189 let outside = tempfile::tempdir().unwrap();
1190 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1191 let bytes = test_png(2, 2);
1192 let owned = store
1193 .publish_with_options(
1194 &bytes,
1195 None,
1196 Some(OutputFormat::Png),
1197 ArtifactPublication {
1198 directory: Some("gallery"),
1199 ..ArtifactPublication::default()
1200 },
1201 )
1202 .unwrap();
1203 fs::remove_file(&owned.path).unwrap();
1204 fs::remove_dir(root.path().join("gallery")).unwrap();
1205 fs::write(outside.path().join(owned.path.file_name().unwrap()), &bytes).unwrap();
1206 symlink(outside.path(), root.path().join("gallery")).unwrap();
1207
1208 assert!(store.read(&owned.id).is_err());
1209 let cleanup = store
1210 .cleanup(
1211 RetentionPolicy {
1212 max_age: Duration::ZERO,
1213 ..RetentionPolicy::default()
1214 },
1215 SystemTime::now() + Duration::from_secs(1),
1216 )
1217 .unwrap();
1218 assert_eq!(cleanup.deleted, 0);
1219 assert_eq!(cleanup.skipped, 1);
1220 let repair = store.repair_orphans(10, ArtifactRepairMode::Apply).unwrap();
1221 assert_eq!(repair.repaired, 0);
1222 assert_eq!(repair.skipped, 1);
1223 assert!(
1224 outside
1225 .path()
1226 .join(owned.path.file_name().unwrap())
1227 .exists()
1228 );
1229 }
1230
1231 #[cfg(unix)]
1232 #[test]
1233 fn ownership_operations_reject_a_replaced_ownership_root() {
1234 use std::os::unix::fs::symlink;
1235
1236 let root = tempfile::tempdir().unwrap();
1237 let outside = tempfile::tempdir().unwrap();
1238 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1239 let owned = store
1240 .publish(&test_png(2, 2), Some("owned"), Some(OutputFormat::Png))
1241 .unwrap();
1242 let marker_name = format!("{}.json", owned.id);
1243 fs::copy(
1244 store.ownership_root.join(&marker_name),
1245 outside.path().join(&marker_name),
1246 )
1247 .unwrap();
1248 fs::rename(
1249 &store.ownership_root,
1250 root.path().join("original-ownership"),
1251 )
1252 .unwrap();
1253 symlink(outside.path(), &store.ownership_root).unwrap();
1254
1255 assert!(store.read(&owned.id).is_err());
1256 assert!(
1257 store
1258 .cleanup(RetentionPolicy::default(), SystemTime::now())
1259 .is_err()
1260 );
1261 assert!(store.repair_orphans(10, ArtifactRepairMode::Apply).is_err());
1262 assert!(outside.path().join(marker_name).is_file());
1263 assert!(owned.path.is_file());
1264 }
1265
1266 #[test]
1267 fn publishes_nested_exact_names_and_cleans_them_up() {
1268 let root = tempfile::tempdir().unwrap();
1269 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1270 let bytes = test_png(2, 2);
1271 let owned = store
1272 .publish_with_options(
1273 &bytes,
1274 None,
1275 Some(OutputFormat::Png),
1276 ArtifactPublication {
1277 directory: Some("portraits/people"),
1278 filename: Some("alice"),
1279 collision: ArtifactCollisionPolicy::Error,
1280 },
1281 )
1282 .unwrap();
1283 assert_eq!(owned.name, "portraits/people/alice.png");
1284 assert_eq!(fs::read(&owned.path).unwrap(), bytes);
1285 let sidecar = store
1286 .attach_metadata(&owned.id, &owned.name, br#"{"prompt":"test"}"#)
1287 .unwrap();
1288 assert!(root.path().join(&sidecar.name).is_file());
1289
1290 let report = store
1291 .cleanup(
1292 RetentionPolicy {
1293 max_age: Duration::ZERO,
1294 ..RetentionPolicy::default()
1295 },
1296 SystemTime::now() + Duration::from_secs(1),
1297 )
1298 .unwrap();
1299 assert_eq!(report.deleted, 1);
1300 assert!(!owned.path.exists());
1301 assert!(!root.path().join(sidecar.name).exists());
1302 }
1303
1304 #[test]
1305 fn explicit_collision_can_error_or_select_a_suffix() {
1306 let root = tempfile::tempdir().unwrap();
1307 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1308 let bytes = test_png(2, 2);
1309 let exact = ArtifactPublication {
1310 filename: Some("portrait.png"),
1311 ..ArtifactPublication::default()
1312 };
1313 store
1314 .publish_with_options(&bytes, None, Some(OutputFormat::Png), exact)
1315 .unwrap();
1316 assert!(
1317 store
1318 .publish_with_options(&bytes, None, Some(OutputFormat::Png), exact)
1319 .is_err()
1320 );
1321 let suffixed = store
1322 .publish_with_options(
1323 &bytes,
1324 None,
1325 Some(OutputFormat::Png),
1326 ArtifactPublication {
1327 collision: ArtifactCollisionPolicy::Suffix,
1328 ..exact
1329 },
1330 )
1331 .unwrap();
1332 assert_eq!(suffixed.name, "portrait-2.png");
1333 }
1334
1335 #[test]
1336 fn direct_store_calls_reject_filename_traversal_and_symlink_directories() {
1337 let root = tempfile::tempdir().unwrap();
1338 let outside = tempfile::tempdir().unwrap();
1339 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1340 let bytes = test_png(2, 2);
1341 assert!(
1342 store
1343 .publish_with_options(
1344 &bytes,
1345 None,
1346 Some(OutputFormat::Png),
1347 ArtifactPublication {
1348 filename: Some("../escape.png"),
1349 ..ArtifactPublication::default()
1350 },
1351 )
1352 .is_err()
1353 );
1354 #[cfg(unix)]
1355 {
1356 std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap();
1357 assert!(
1358 store
1359 .publish_with_options(
1360 &bytes,
1361 None,
1362 Some(OutputFormat::Png),
1363 ArtifactPublication {
1364 directory: Some("linked"),
1365 ..ArtifactPublication::default()
1366 },
1367 )
1368 .is_err()
1369 );
1370 }
1371 }
1372
1373 #[test]
1374 fn metadata_attachment_rejects_non_json_and_duplicate_sidecars() {
1375 let root = tempfile::tempdir().unwrap();
1376 let store = ArtifactStore::new(root.path(), ImageLimits::default()).unwrap();
1377 let owned = store
1378 .publish(&test_png(2, 2), None, Some(OutputFormat::Png))
1379 .unwrap();
1380 assert!(
1381 store
1382 .attach_metadata(&owned.id, &owned.name, b"not-json")
1383 .is_err()
1384 );
1385 store
1386 .attach_metadata(&owned.id, &owned.name, br#"{"version":1}"#)
1387 .unwrap();
1388 assert!(
1389 store
1390 .attach_metadata(&owned.id, &owned.name, br#"{"version":2}"#)
1391 .is_err()
1392 );
1393 }
1394}