Skip to main content

imagegen_bridge_artifacts/
store.rs

1//! Atomic, collision-safe artifact publication.
2
3use 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/// Bridge-owned artifact returned to the runtime.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct StoredArtifact {
25    /// Opaque public identifier.
26    pub id: String,
27    /// Safe single-component filename.
28    pub name: String,
29    /// Internal absolute path; never expose this directly to remote clients.
30    pub path: PathBuf,
31    /// Verified image metadata.
32    pub metadata: ImageMetadata,
33}
34
35/// Portable reference to an attached generation-metadata sidecar.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct StoredSidecar {
38    /// Safe relative name below the artifact root.
39    pub name: String,
40}
41
42/// Verified bridge-owned artifact bytes for trusted delivery code.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct StoredArtifactContent {
45    /// Opaque artifact identifier.
46    pub id: String,
47    /// Portable relative artifact name.
48    pub name: String,
49    /// Independently verified encoded image bytes.
50    pub bytes: Vec<u8>,
51    /// Verified image properties and checksum.
52    pub metadata: ImageMetadata,
53}
54
55/// Per-request artifact placement below the configured owned root.
56#[derive(Debug, Clone, Copy, Default)]
57pub struct ArtifactPublication<'a> {
58    /// Portable relative directory, or the root when absent.
59    pub directory: Option<&'a str>,
60    /// Exact single-image filename, or a generated UUID name when absent.
61    pub filename: Option<&'a str>,
62    /// Behavior when the explicit filename exists.
63    pub collision: ArtifactCollisionPolicy,
64}
65
66/// Publishes verified images beneath one owned output root.
67#[derive(Debug, Clone)]
68pub struct ArtifactStore {
69    root: PathBuf,
70    ownership_root: PathBuf,
71    limits: ImageLimits,
72}
73
74/// Bounded policy for deleting bridge-owned artifacts.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct RetentionPolicy {
77    /// Delete artifacts at least this old.
78    pub max_age: Duration,
79    /// Optional maximum retained artifact count, keeping newest valid records.
80    pub max_artifacts: Option<usize>,
81    /// Hard bound on ownership records inspected per cleanup pass.
82    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/// Safe aggregate result from one retention pass.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
97pub struct CleanupReport {
98    /// Ownership entries inspected.
99    pub scanned: usize,
100    /// Verified owned artifacts and markers removed.
101    pub deleted: usize,
102    /// Invalid, changed, missing, or otherwise non-deletable records.
103    pub skipped: usize,
104    /// Whether the scan stopped at its configured entry bound.
105    pub scan_limit_reached: bool,
106}
107
108/// Aggregate result from one bounded ownership repair pass.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
110pub struct ArtifactRepairReport {
111    /// Ownership entries inspected.
112    pub scanned: usize,
113    /// Fully valid artifact records requiring no repair.
114    pub healthy: usize,
115    /// Valid ownership records whose artifact file is absent.
116    pub orphaned_records: usize,
117    /// Valid artifacts whose recorded metadata sidecar is absent.
118    pub missing_sidecars: usize,
119    /// Orphan records removed or sidecar references cleared.
120    pub repaired: usize,
121    /// Invalid, changed, or otherwise unsafe records left untouched.
122    pub skipped: usize,
123    /// Whether the scan stopped at its configured entry bound.
124    pub scan_limit_reached: bool,
125}
126
127/// Selects whether an orphan-repair pass only reports or also applies repairs.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum ArtifactRepairMode {
130    /// Inspect storage without mutating it.
131    Audit,
132    /// Apply only repairs that can be proven safe from ownership records.
133    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    /// Creates or opens an artifact root.
174    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    /// Verifies and atomically publishes one image without overwriting a file.
221    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    /// Verifies and atomically publishes one image at a constrained relative location.
236    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    /// Atomically attaches bounded JSON metadata to an owned artifact.
310    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    /// Reads one ownership-verified artifact without exposing its filesystem path.
379    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    /// Returns the private artifact root for trusted runtime code.
440    #[must_use]
441    pub fn root(&self) -> &Path {
442        &self.root
443    }
444
445    /// Deletes only verified artifacts with bridge-created ownership records.
446    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    /// Audits or repairs only unambiguous ownership-record orphans.
504    ///
505    /// Audit mode is non-mutating. Apply mode
506    /// removes a valid marker only when its artifact is absent, optionally
507    /// removing an unchanged owned sidecar, or clears a missing-sidecar
508    /// reference from an otherwise valid artifact. Invalid or changed content
509    /// is always left untouched.
510    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(&current) {
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}