1use 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
17const ARCHIVE_FORMAT_VERSION: u32 = 1;
19const ARCHIVE_ROOT: &str = "projectatlas-derived-snapshot";
21const MANIFEST_PATH: &str = "projectatlas-derived-snapshot/manifest.json";
23const PAYLOAD_PATH: &str = "projectatlas-derived-snapshot/graph.json";
25const SIGNATURE_PATH: &str = "projectatlas-derived-snapshot/signature.json";
27const MAX_COMPRESSED_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
29const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
31const MAX_SIGNATURE_BYTES: u64 = 16 * 1024;
33const MAX_ARCHIVE_ENTRIES: usize = 3;
35const MAX_ZSTD_WINDOW_LOG: u32 = 27;
37#[cfg(feature = "derived-snapshot-signatures")]
38const SIGNATURE_DOMAIN: &[u8] = b"projectatlas.derived-snapshot.archive-signature.v1";
40
41#[derive(Debug, Serialize)]
43pub(super) struct SnapshotExportReport {
44 pub(super) archive: String,
46 pub(super) snapshot_digest: String,
48 pub(super) payload_bytes: u64,
50 pub(super) compressed_bytes: u64,
52 pub(super) signature: SnapshotSignatureState,
54 pub(super) content: Vec<DerivedSnapshotContent>,
56}
57
58#[derive(Debug, Serialize)]
60pub(super) struct SnapshotImportReport {
61 pub(super) archive: String,
63 pub(super) signature: SnapshotSignatureState,
65 #[serde(flatten)]
66 pub(super) publication: DerivedGraphSnapshotImport,
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub(super) enum SnapshotSignatureState {
74 UnsignedLocal,
76 #[cfg(not(feature = "derived-snapshot-signatures"))]
77 PresentUnverified,
79 #[cfg(feature = "derived-snapshot-signatures")]
80 VerifiedEmbedded,
82 #[cfg(feature = "derived-snapshot-signatures")]
83 VerifiedTrusted,
85}
86
87pub(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
210pub(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#[derive(Debug, Deserialize, Serialize)]
252struct SnapshotArchiveManifest {
253 format_version: u32,
255 root: String,
257 payload: String,
259 payload_bytes: u64,
261 payload_blake3: String,
263 snapshot_digest: String,
265}
266
267impl SnapshotArchiveManifest {
268 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
284struct SnapshotArchiveParts {
286 manifest: Vec<u8>,
288 payload: Vec<u8>,
290 signature: Option<Vec<u8>>,
292}
293
294fn 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
380fn 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
402fn 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
415fn 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
421fn invalid<T>(message: &str) -> Result<T, CliError> {
423 Err(CliError::InvalidInput(message.to_string()))
424}
425
426#[cfg(feature = "derived-snapshot-signatures")]
427#[derive(Deserialize, Serialize)]
429struct SnapshotArchiveSignature {
430 algorithm: String,
432 public_key: String,
434 key_id: String,
436 signature: String,
438}
439
440#[cfg(feature = "derived-snapshot-signatures")]
441fn 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")]
458fn 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#[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")]
516fn 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")]
528fn 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")]
547fn 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")]
565const 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")]
576fn 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))]
588fn 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)))]
605fn 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}