projectatlas/
derived_snapshot_archive.rs

1//! Bounded archive adapter for portable derived graph snapshots.
2
3use super::CliError;
4use projectatlas_db::{
5    AtlasStore, DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedSnapshotContent,
6    MAX_DERIVED_SNAPSHOT_JSON_BYTES,
7};
8use serde::{Deserialize, Serialize};
9use std::fs::{self, File};
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use tar::{Archive, Builder, EntryType, Header};
13
14#[cfg(feature = "derived-snapshot-signatures")]
15use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
16
17/// Stable archive container format version.
18const ARCHIVE_FORMAT_VERSION: u32 = 1;
19/// Required top-level archive directory.
20const ARCHIVE_ROOT: &str = "projectatlas-derived-snapshot";
21/// Required manifest entry path.
22const MANIFEST_PATH: &str = "projectatlas-derived-snapshot/manifest.json";
23/// Required portable graph entry path.
24const PAYLOAD_PATH: &str = "projectatlas-derived-snapshot/graph.json";
25/// Optional signature entry path.
26const SIGNATURE_PATH: &str = "projectatlas-derived-snapshot/signature.json";
27/// Maximum accepted compressed archive size.
28const MAX_COMPRESSED_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
29/// Maximum expanded manifest size.
30const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
31/// Maximum expanded signature size.
32const MAX_SIGNATURE_BYTES: u64 = 16 * 1024;
33/// Maximum number of allowed archive entries.
34const MAX_ARCHIVE_ENTRIES: usize = 3;
35/// Maximum accepted Zstandard decoder window log.
36const MAX_ZSTD_WINDOW_LOG: u32 = 27;
37#[cfg(feature = "derived-snapshot-signatures")]
38/// Domain separator for archive signatures.
39const SIGNATURE_DOMAIN: &[u8] = b"projectatlas.derived-snapshot.archive-signature.v1";
40
41/// Successful portable archive export.
42#[derive(Debug, Serialize)]
43pub(super) struct SnapshotExportReport {
44    /// Written archive path.
45    pub(super) archive: String,
46    /// Portable snapshot digest.
47    pub(super) snapshot_digest: String,
48    /// Uncompressed payload bytes.
49    pub(super) payload_bytes: u64,
50    /// Final compressed archive bytes.
51    pub(super) compressed_bytes: u64,
52    /// Signature handling result.
53    pub(super) signature: SnapshotSignatureState,
54    /// Exported content inventory.
55    pub(super) content: Vec<DerivedSnapshotContent>,
56}
57
58/// Successful portable archive import.
59#[derive(Debug, Serialize)]
60pub(super) struct SnapshotImportReport {
61    /// Read archive path.
62    pub(super) archive: String,
63    /// Signature handling result.
64    pub(super) signature: SnapshotSignatureState,
65    #[serde(flatten)]
66    /// Atomic publication result.
67    pub(super) publication: DerivedGraphSnapshotImport,
68}
69
70/// Honest signature handling state for the selected build and request.
71#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub(super) enum SnapshotSignatureState {
74    /// No signature was requested or present for local use.
75    UnsignedLocal,
76    #[cfg(not(feature = "derived-snapshot-signatures"))]
77    /// A signature exists but this build cannot verify it.
78    PresentUnverified,
79    #[cfg(feature = "derived-snapshot-signatures")]
80    /// The embedded public key verifies the signature.
81    VerifiedEmbedded,
82    #[cfg(feature = "derived-snapshot-signatures")]
83    /// The signature verifies against an explicitly trusted public key.
84    VerifiedTrusted,
85}
86
87/// Export one deterministic tar.zst archive without overwriting an existing path.
88pub(super) fn export_snapshot_archive(
89    store: &AtlasStore,
90    output: &Path,
91    #[cfg(feature = "derived-snapshot-signatures")] signing_key: Option<&Path>,
92) -> Result<SnapshotExportReport, CliError> {
93    if output.exists() {
94        return Err(CliError::InvalidInput(format!(
95            "snapshot output '{}' already exists",
96            output.display()
97        )));
98    }
99    let parent = output
100        .parent()
101        .filter(|parent| !parent.as_os_str().is_empty())
102        .unwrap_or_else(|| Path::new("."));
103    if !parent.is_dir() {
104        return Err(CliError::InvalidInput(format!(
105            "snapshot output directory '{}' does not exist",
106            parent.display()
107        )));
108    }
109
110    let snapshot = store.export_derived_graph_snapshot()?;
111    let payload = snapshot.to_json()?;
112    let manifest = SnapshotArchiveManifest {
113        format_version: ARCHIVE_FORMAT_VERSION,
114        root: ARCHIVE_ROOT.to_string(),
115        payload: PAYLOAD_PATH.to_string(),
116        payload_bytes: usize_to_u64(payload.len())?,
117        payload_blake3: blake3::hash(&payload).to_hex().to_string(),
118        snapshot_digest: snapshot.digest().to_string(),
119    };
120    let manifest_bytes = serde_json::to_vec(&manifest)?;
121    let signature = {
122        #[cfg(feature = "derived-snapshot-signatures")]
123        {
124            signing_key
125                .map(|path| sign_archive(path, &manifest_bytes, &payload))
126                .transpose()?
127        }
128        #[cfg(not(feature = "derived-snapshot-signatures"))]
129        {
130            None::<Vec<u8>>
131        }
132    };
133    let signature_state = if signature.is_some() {
134        #[cfg(feature = "derived-snapshot-signatures")]
135        {
136            SnapshotSignatureState::VerifiedEmbedded
137        }
138        #[cfg(not(feature = "derived-snapshot-signatures"))]
139        {
140            SnapshotSignatureState::PresentUnverified
141        }
142    } else {
143        SnapshotSignatureState::UnsignedLocal
144    };
145
146    let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|source| CliError::Io {
147        path: parent.to_path_buf(),
148        source,
149    })?;
150    {
151        let encoder =
152            zstd::stream::write::Encoder::new(temporary.as_file_mut(), 9).map_err(|source| {
153                CliError::Io {
154                    path: output.to_path_buf(),
155                    source,
156                }
157            })?;
158        let mut archive = Builder::new(encoder);
159        append_entry(&mut archive, MANIFEST_PATH, &manifest_bytes)?;
160        append_entry(&mut archive, PAYLOAD_PATH, &payload)?;
161        if let Some(signature) = signature.as_deref() {
162            append_entry(&mut archive, SIGNATURE_PATH, signature)?;
163        }
164        let encoder = archive.into_inner().map_err(|source| CliError::Io {
165            path: output.to_path_buf(),
166            source,
167        })?;
168        encoder.finish().map_err(|source| CliError::Io {
169            path: output.to_path_buf(),
170            source,
171        })?;
172    }
173    temporary
174        .as_file()
175        .sync_all()
176        .map_err(|source| CliError::Io {
177            path: output.to_path_buf(),
178            source,
179        })?;
180    let compressed_bytes = temporary
181        .as_file()
182        .metadata()
183        .map_err(|source| CliError::Io {
184            path: output.to_path_buf(),
185            source,
186        })?
187        .len();
188    if compressed_bytes > MAX_COMPRESSED_ARCHIVE_BYTES {
189        return Err(CliError::InvalidInput(format!(
190            "compressed snapshot is {compressed_bytes} bytes; maximum is {MAX_COMPRESSED_ARCHIVE_BYTES}"
191        )));
192    }
193    temporary
194        .persist_noclobber(output)
195        .map_err(|error| CliError::Io {
196            path: output.to_path_buf(),
197            source: error.error,
198        })?;
199
200    Ok(SnapshotExportReport {
201        archive: output.display().to_string(),
202        snapshot_digest: snapshot.digest().to_string(),
203        payload_bytes: usize_to_u64(payload.len())?,
204        compressed_bytes,
205        signature: signature_state,
206        content: snapshot.content().to_vec(),
207    })
208}
209
210/// Validate one archive completely, then publish through the database boundary.
211pub(super) fn import_snapshot_archive(
212    store: &mut AtlasStore,
213    archive_path: &Path,
214    required_digest: Option<&str>,
215    #[cfg(feature = "derived-snapshot-signatures")] trusted_public_key: Option<&Path>,
216) -> Result<SnapshotImportReport, CliError> {
217    let archive = read_archive(archive_path)?;
218    let manifest = serde_json::from_slice::<SnapshotArchiveManifest>(&archive.manifest)?;
219    manifest.validate()?;
220    if manifest.payload_bytes != usize_to_u64(archive.payload.len())?
221        || manifest.payload_blake3 != blake3::hash(&archive.payload).to_hex().to_string()
222    {
223        return invalid("snapshot archive payload digest or byte count does not match");
224    }
225    let snapshot = DerivedGraphSnapshot::from_json(&archive.payload)?;
226    if snapshot.digest() != manifest.snapshot_digest {
227        return invalid("snapshot archive manifest names a different snapshot digest");
228    }
229    if let Some(required) = required_digest {
230        require_digest(required)?;
231        if required != snapshot.digest() {
232            return invalid("snapshot digest does not match the required trust pin");
233        }
234    }
235    let signature = verify_archive_signature(
236        archive.signature.as_deref(),
237        &archive.manifest,
238        &archive.payload,
239        #[cfg(feature = "derived-snapshot-signatures")]
240        trusted_public_key,
241    )?;
242    let publication = store.import_derived_graph_snapshot(&snapshot)?;
243    Ok(SnapshotImportReport {
244        archive: archive_path.display().to_string(),
245        signature,
246        publication,
247    })
248}
249
250/// Strict archive manifest bound to one payload.
251#[derive(Debug, Deserialize, Serialize)]
252struct SnapshotArchiveManifest {
253    /// Stable archive format version.
254    format_version: u32,
255    /// Required archive root.
256    root: String,
257    /// Required portable graph entry path.
258    payload: String,
259    /// Exact uncompressed payload bytes.
260    payload_bytes: u64,
261    /// BLAKE3 digest of the encoded payload.
262    payload_blake3: String,
263    /// Integrity digest declared by the portable snapshot.
264    snapshot_digest: String,
265}
266
267impl SnapshotArchiveManifest {
268    /// Validate the closed archive contract and bounded payload metadata.
269    fn validate(&self) -> Result<(), CliError> {
270        if self.format_version != ARCHIVE_FORMAT_VERSION {
271            return invalid("unsupported snapshot archive version");
272        }
273        if self.root != ARCHIVE_ROOT || self.payload != PAYLOAD_PATH {
274            return invalid("snapshot archive root or payload path is invalid");
275        }
276        if self.payload_bytes > MAX_DERIVED_SNAPSHOT_JSON_BYTES {
277            return invalid("snapshot archive payload exceeds the expanded limit");
278        }
279        require_digest(&self.payload_blake3)?;
280        require_digest(&self.snapshot_digest)
281    }
282}
283
284/// Bounded entries read from one validated archive shape.
285struct SnapshotArchiveParts {
286    /// Raw manifest bytes.
287    manifest: Vec<u8>,
288    /// Raw portable graph bytes.
289    payload: Vec<u8>,
290    /// Optional raw signature bytes.
291    signature: Option<Vec<u8>>,
292}
293
294/// Read an archive without extracting entries to disk.
295fn read_archive(path: &Path) -> Result<SnapshotArchiveParts, CliError> {
296    let compressed_bytes = fs::metadata(path)
297        .map_err(|source| CliError::Io {
298            path: path.to_path_buf(),
299            source,
300        })?
301        .len();
302    if compressed_bytes > MAX_COMPRESSED_ARCHIVE_BYTES {
303        return invalid("compressed snapshot archive exceeds the input limit");
304    }
305    let file = File::open(path).map_err(|source| CliError::Io {
306        path: path.to_path_buf(),
307        source,
308    })?;
309    let mut decoder = zstd::stream::read::Decoder::new(file).map_err(|source| CliError::Io {
310        path: path.to_path_buf(),
311        source,
312    })?;
313    decoder
314        .window_log_max(MAX_ZSTD_WINDOW_LOG)
315        .map_err(|source| CliError::Io {
316            path: path.to_path_buf(),
317            source,
318        })?;
319    let mut archive = Archive::new(decoder);
320    let mut manifest = None;
321    let mut payload = None;
322    let mut signature = None;
323    let mut entries = 0_usize;
324    for entry in archive.entries().map_err(|source| CliError::Io {
325        path: path.to_path_buf(),
326        source,
327    })? {
328        entries = entries.saturating_add(1);
329        if entries > MAX_ARCHIVE_ENTRIES {
330            return invalid("snapshot archive contains too many entries");
331        }
332        let entry = entry.map_err(|source| CliError::Io {
333            path: path.to_path_buf(),
334            source,
335        })?;
336        if entry.header().entry_type() != EntryType::Regular {
337            return invalid("snapshot archive contains a non-regular entry");
338        }
339        let entry_path_bytes = entry.path_bytes();
340        let entry_path = std::str::from_utf8(entry_path_bytes.as_ref()).map_err(|_source| {
341            CliError::InvalidInput("snapshot archive path is not UTF-8".into())
342        })?;
343        let (slot, maximum) = match entry_path {
344            MANIFEST_PATH => (&mut manifest, MAX_MANIFEST_BYTES),
345            PAYLOAD_PATH => (&mut payload, MAX_DERIVED_SNAPSHOT_JSON_BYTES),
346            SIGNATURE_PATH => (&mut signature, MAX_SIGNATURE_BYTES),
347            _ => return invalid("snapshot archive contains an unexpected path"),
348        };
349        if slot.is_some() {
350            return invalid("snapshot archive contains a duplicate path");
351        }
352        let entry_size = entry.size();
353        if entry_size > maximum {
354            return invalid("snapshot archive entry exceeds its expanded limit");
355        }
356        let mut bytes = Vec::with_capacity(usize::try_from(entry_size).map_err(|_source| {
357            CliError::InvalidInput("snapshot archive entry size cannot be represented".into())
358        })?);
359        entry
360            .take(maximum.saturating_add(1))
361            .read_to_end(&mut bytes)
362            .map_err(|source| CliError::Io {
363                path: path.to_path_buf(),
364                source,
365            })?;
366        if usize_to_u64(bytes.len())? != entry_size {
367            return invalid("snapshot archive entry is truncated");
368        }
369        *slot = Some(bytes);
370    }
371    Ok(SnapshotArchiveParts {
372        manifest: manifest
373            .ok_or_else(|| CliError::InvalidInput("snapshot archive manifest is missing".into()))?,
374        payload: payload
375            .ok_or_else(|| CliError::InvalidInput("snapshot archive payload is missing".into()))?,
376        signature,
377    })
378}
379
380/// Append one deterministic regular-file archive entry.
381fn append_entry<W: Write>(
382    archive: &mut Builder<W>,
383    path: &str,
384    bytes: &[u8],
385) -> Result<(), CliError> {
386    let mut header = Header::new_gnu();
387    header.set_entry_type(EntryType::Regular);
388    header.set_mode(0o644);
389    header.set_uid(0);
390    header.set_gid(0);
391    header.set_mtime(0);
392    header.set_size(usize_to_u64(bytes.len())?);
393    header.set_cksum();
394    archive
395        .append_data(&mut header, path, bytes)
396        .map_err(|source| CliError::Io {
397            path: PathBuf::from(path),
398            source,
399        })
400}
401
402/// Validate a lowercase BLAKE3 hexadecimal digest.
403fn require_digest(value: &str) -> Result<(), CliError> {
404    if value.len() == 64
405        && value
406            .bytes()
407            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
408    {
409        Ok(())
410    } else {
411        invalid("snapshot digest is not 64 lowercase hexadecimal characters")
412    }
413}
414
415/// Convert an in-memory length to the archive count type.
416fn usize_to_u64(value: usize) -> Result<u64, CliError> {
417    u64::try_from(value)
418        .map_err(|_source| CliError::InvalidInput("snapshot size cannot be represented".into()))
419}
420
421/// Return a typed invalid-input error.
422fn invalid<T>(message: &str) -> Result<T, CliError> {
423    Err(CliError::InvalidInput(message.to_string()))
424}
425
426#[cfg(feature = "derived-snapshot-signatures")]
427/// Self-contained Ed25519 signature record.
428#[derive(Deserialize, Serialize)]
429struct SnapshotArchiveSignature {
430    /// Signature algorithm and framing contract.
431    algorithm: String,
432    /// Embedded public key as lowercase hexadecimal.
433    public_key: String,
434    /// BLAKE3 identity of the public key.
435    key_id: String,
436    /// Ed25519 signature as lowercase hexadecimal.
437    signature: String,
438}
439
440#[cfg(feature = "derived-snapshot-signatures")]
441/// Sign the framed manifest and payload digest.
442fn sign_archive(key_path: &Path, manifest: &[u8], payload: &[u8]) -> Result<Vec<u8>, CliError> {
443    require_private_signing_key_permissions(key_path)?;
444    let mut secret = read_hex_file::<32>(key_path, "Ed25519 signing key")?;
445    let signing_key = SigningKey::from_bytes(&secret);
446    secret.fill(0);
447    let public_key = signing_key.verifying_key().to_bytes();
448    let signature = signing_key.sign(&signature_digest(manifest, payload)?);
449    Ok(serde_json::to_vec(&SnapshotArchiveSignature {
450        algorithm: "ed25519-blake3-v1".to_string(),
451        public_key: encode_hex(&public_key),
452        key_id: blake3::hash(&public_key).to_hex().to_string(),
453        signature: encode_hex(&signature.to_bytes()),
454    })?)
455}
456
457#[cfg(feature = "derived-snapshot-signatures")]
458/// Verify an optional signature and explicit trust policy.
459fn verify_archive_signature(
460    signature: Option<&[u8]>,
461    manifest: &[u8],
462    payload: &[u8],
463    trusted_public_key: Option<&Path>,
464) -> Result<SnapshotSignatureState, CliError> {
465    let Some(signature) = signature else {
466        if trusted_public_key.is_some() {
467            return invalid("trusted signature policy requires a signed snapshot archive");
468        }
469        return Ok(SnapshotSignatureState::UnsignedLocal);
470    };
471    let signature = serde_json::from_slice::<SnapshotArchiveSignature>(signature)?;
472    if signature.algorithm != "ed25519-blake3-v1" {
473        return invalid("snapshot archive signature algorithm is unsupported");
474    }
475    let public_bytes = decode_hex_array::<32>(&signature.public_key, "snapshot public key")?;
476    if signature.key_id != blake3::hash(&public_bytes).to_hex().to_string() {
477        return invalid("snapshot signature key identity does not match its public key");
478    }
479    let verifying_key = VerifyingKey::from_bytes(&public_bytes)
480        .map_err(|_source| CliError::InvalidInput("snapshot public key is invalid".into()))?;
481    let signature_bytes = decode_hex_array::<64>(&signature.signature, "snapshot signature")?;
482    let signature = Signature::try_from(signature_bytes.as_slice())
483        .map_err(|_source| CliError::InvalidInput("snapshot signature is invalid".into()))?;
484    verifying_key
485        .verify(&signature_digest(manifest, payload)?, &signature)
486        .map_err(|_source| {
487            CliError::InvalidInput("snapshot archive signature verification failed".into())
488        })?;
489    if let Some(trusted_path) = trusted_public_key {
490        let trusted = read_hex_file::<32>(trusted_path, "trusted Ed25519 public key")?;
491        if trusted != public_bytes {
492            return invalid("snapshot signer is not the explicitly trusted public key");
493        }
494        Ok(SnapshotSignatureState::VerifiedTrusted)
495    } else {
496        Ok(SnapshotSignatureState::VerifiedEmbedded)
497    }
498}
499
500#[cfg(not(feature = "derived-snapshot-signatures"))]
501/// Report signature presence honestly when verification support is absent.
502#[allow(clippy::unnecessary_wraps)]
503fn verify_archive_signature(
504    signature: Option<&[u8]>,
505    _manifest: &[u8],
506    _payload: &[u8],
507) -> Result<SnapshotSignatureState, CliError> {
508    Ok(if signature.is_some() {
509        SnapshotSignatureState::PresentUnverified
510    } else {
511        SnapshotSignatureState::UnsignedLocal
512    })
513}
514
515#[cfg(feature = "derived-snapshot-signatures")]
516/// Compute the domain-separated digest signed by Ed25519.
517fn signature_digest(manifest: &[u8], payload: &[u8]) -> Result<[u8; 32], CliError> {
518    let mut digest = blake3::Hasher::new();
519    digest.update(SIGNATURE_DOMAIN);
520    digest.update(&usize_to_u64(manifest.len())?.to_le_bytes());
521    digest.update(manifest);
522    digest.update(&usize_to_u64(payload.len())?.to_le_bytes());
523    digest.update(payload);
524    Ok(*digest.finalize().as_bytes())
525}
526
527#[cfg(feature = "derived-snapshot-signatures")]
528/// Read one bounded fixed-size hexadecimal key file.
529fn read_hex_file<const N: usize>(path: &Path, label: &str) -> Result<[u8; N], CliError> {
530    let metadata = fs::metadata(path).map_err(|source| CliError::Io {
531        path: path.to_path_buf(),
532        source,
533    })?;
534    if !metadata.is_file() || metadata.len() > 4_096 {
535        return Err(CliError::InvalidInput(format!(
536            "{label} file must be a regular file no larger than 4096 bytes"
537        )));
538    }
539    let value = fs::read_to_string(path).map_err(|source| CliError::Io {
540        path: path.to_path_buf(),
541        source,
542    })?;
543    decode_hex_array::<N>(value.trim(), label)
544}
545
546#[cfg(feature = "derived-snapshot-signatures")]
547/// Decode one fixed-size hexadecimal key or signature.
548fn decode_hex_array<const N: usize>(value: &str, label: &str) -> Result<[u8; N], CliError> {
549    if value.len() != N * 2 {
550        return Err(CliError::InvalidInput(format!(
551            "{label} must contain exactly {} hexadecimal characters",
552            N * 2
553        )));
554    }
555    let mut decoded = [0_u8; N];
556    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
557        decoded[index] = decode_nibble(pair[0])
558            .and_then(|high| decode_nibble(pair[1]).map(|low| (high << 4) | low))
559            .ok_or_else(|| CliError::InvalidInput(format!("{label} is not hexadecimal")))?;
560    }
561    Ok(decoded)
562}
563
564#[cfg(feature = "derived-snapshot-signatures")]
565/// Decode one hexadecimal nibble.
566const fn decode_nibble(value: u8) -> Option<u8> {
567    match value {
568        b'0'..=b'9' => Some(value - b'0'),
569        b'a'..=b'f' => Some(value - b'a' + 10),
570        b'A'..=b'F' => Some(value - b'A' + 10),
571        _ => None,
572    }
573}
574
575#[cfg(feature = "derived-snapshot-signatures")]
576/// Encode bytes as lowercase hexadecimal.
577fn encode_hex(bytes: &[u8]) -> String {
578    const HEX: &[u8; 16] = b"0123456789abcdef";
579    let mut encoded = String::with_capacity(bytes.len() * 2);
580    for byte in bytes {
581        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
582        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
583    }
584    encoded
585}
586
587#[cfg(all(feature = "derived-snapshot-signatures", unix))]
588/// Require a signing key inaccessible to group and other Unix users.
589fn require_private_signing_key_permissions(path: &Path) -> Result<(), CliError> {
590    use std::os::unix::fs::PermissionsExt;
591    let mode = fs::metadata(path)
592        .map_err(|source| CliError::Io {
593            path: path.to_path_buf(),
594            source,
595        })?
596        .permissions()
597        .mode();
598    if mode & 0o077 != 0 {
599        return invalid("Ed25519 signing key must not be accessible by group or other users");
600    }
601    Ok(())
602}
603
604#[cfg(all(feature = "derived-snapshot-signatures", not(unix)))]
605/// Require a regular signing key file on platforms without Unix mode bits.
606fn require_private_signing_key_permissions(path: &Path) -> Result<(), CliError> {
607    if path.is_file() {
608        Ok(())
609    } else {
610        invalid("Ed25519 signing key path is not a regular file")
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::{MANIFEST_PATH, PAYLOAD_PATH, append_entry, read_archive, require_digest};
617
618    #[test]
619    fn archive_paths_and_digest_shape_are_closed() {
620        assert!(MANIFEST_PATH.starts_with("projectatlas-derived-snapshot/"));
621        assert!(PAYLOAD_PATH.starts_with("projectatlas-derived-snapshot/"));
622        assert!(require_digest(&"a".repeat(64)).is_ok());
623        assert!(require_digest(&"A".repeat(64)).is_err());
624    }
625
626    #[test]
627    #[allow(clippy::panic_in_result_fn)]
628    fn archive_reader_rejects_duplicate_and_unexpected_paths()
629    -> Result<(), Box<dyn std::error::Error>> {
630        fn archive_with(
631            root: &std::path::Path,
632            name: &str,
633            paths: &[&str],
634        ) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
635            let path = root.join(name);
636            let file = std::fs::File::create(&path)?;
637            let encoder = zstd::stream::write::Encoder::new(file, 1)?;
638            let mut archive = tar::Builder::new(encoder);
639            for entry_path in paths {
640                append_entry(&mut archive, entry_path, b"{}")?;
641            }
642            archive.into_inner()?.finish()?;
643            Ok(path)
644        }
645
646        let temp = tempfile::tempdir()?;
647        let duplicate = archive_with(
648            temp.path(),
649            "duplicate.tar.zst",
650            &[MANIFEST_PATH, MANIFEST_PATH, PAYLOAD_PATH],
651        )?;
652        assert!(read_archive(&duplicate).is_err());
653        let unexpected = archive_with(
654            temp.path(),
655            "unexpected.tar.zst",
656            &[MANIFEST_PATH, "other-root/graph.json"],
657        )?;
658        assert!(read_archive(&unexpected).is_err());
659        Ok(())
660    }
661
662    #[cfg(feature = "derived-snapshot-signatures")]
663    #[test]
664    #[allow(clippy::panic_in_result_fn)]
665    fn explicit_ed25519_policy_rejects_tampering_and_untrusted_keys()
666    -> Result<(), Box<dyn std::error::Error>> {
667        use super::{SnapshotSignatureState, encode_hex, sign_archive, verify_archive_signature};
668        use ed25519_dalek::SigningKey;
669        use std::fs;
670        #[cfg(unix)]
671        use std::os::unix::fs::PermissionsExt;
672
673        let temp = tempfile::tempdir()?;
674        let secret_path = temp.path().join("signing-key.hex");
675        let trusted_path = temp.path().join("trusted-key.hex");
676        let secret = [7_u8; 32];
677        let signing_key = SigningKey::from_bytes(&secret);
678        fs::write(&secret_path, encode_hex(&secret))?;
679        fs::write(
680            &trusted_path,
681            encode_hex(&signing_key.verifying_key().to_bytes()),
682        )?;
683        #[cfg(unix)]
684        fs::set_permissions(&secret_path, fs::Permissions::from_mode(0o600))?;
685        let manifest = br#"{"format_version":1}"#;
686        let payload = br#"{"graph":[]}"#;
687        let signature = sign_archive(&secret_path, manifest, payload)?;
688        assert_eq!(
689            verify_archive_signature(Some(&signature), manifest, payload, Some(&trusted_path))?,
690            SnapshotSignatureState::VerifiedTrusted
691        );
692        assert!(verify_archive_signature(None, manifest, payload, Some(&trusted_path)).is_err());
693        assert!(
694            verify_archive_signature(
695                Some(&signature),
696                manifest,
697                br#"{"graph":["tampered"]}"#,
698                Some(&trusted_path)
699            )
700            .is_err()
701        );
702        fs::write(&trusted_path, encode_hex(&[9_u8; 32]))?;
703        assert!(
704            verify_archive_signature(Some(&signature), manifest, payload, Some(&trusted_path))
705                .is_err()
706        );
707        Ok(())
708    }
709}