1use std::fs::{File, OpenOptions};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10
11use a3s_box_core::error::{BoxError, Result};
12use a3s_box_core::snapshot::SnapshotMetadata;
13use serde::{Deserialize, Serialize};
14
15use super::SnapshotStore;
16
17pub const SNAPSHOT_ROOTFS_SCHEMA: &str = "a3s.box.snapshot-rootfs.v1";
18const ROOTFS_MANIFEST_NAME: &str = "rootfs.json";
19const DIRECTORY_NAME: &str = "rootfs";
20const RAW_EXT4_DIRECTORY_NAME: &str = "rootfs-ext4-v1";
21const RAW_EXT4_ARTIFACT_SCHEMA: &str = "a3s.box.rootfs-ext4.v1";
22const MAX_ROOTFS_MANIFEST_BYTES: u64 = 64 * 1024;
23const MAX_SNAPSHOT_METADATA_BYTES: u64 = 1024 * 1024;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SnapshotRootfsFormat {
28 Directory,
30 GuestNativeExt4,
32}
33
34#[derive(Debug, Clone)]
36pub struct RestoredSnapshotRootfs {
37 pub metadata: SnapshotMetadata,
38 pub format: SnapshotRootfsFormat,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct SnapshotRootfsManifest {
44 schema: String,
45 snapshot_id: String,
46 source_box_id: String,
47 rootfs: SnapshotRootfsPayload,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "format", rename_all = "kebab-case", deny_unknown_fields)]
52enum SnapshotRootfsPayload {
53 Directory,
54 RawExt4 {
55 artifact: Ext4ArtifactIdentity,
56 sparse_sha256: String,
57 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62struct Ext4ArtifactIdentity {
63 schema: String,
64 builder: String,
65 format: String,
66 capacity_bytes: u64,
67 fs_uuid: String,
68}
69
70impl SnapshotRootfsManifest {
71 fn directory(metadata: &SnapshotMetadata) -> Self {
72 Self {
73 schema: SNAPSHOT_ROOTFS_SCHEMA.to_string(),
74 snapshot_id: metadata.id.clone(),
75 source_box_id: metadata.source_box_id.clone(),
76 rootfs: SnapshotRootfsPayload::Directory,
77 }
78 }
79
80 #[cfg(target_os = "macos")]
81 fn raw_ext4(
82 metadata: &SnapshotMetadata,
83 artifact: &crate::rootfs::Ext4Artifact,
84 sparse_sha256: String,
85 ) -> Self {
86 Self {
87 schema: SNAPSHOT_ROOTFS_SCHEMA.to_string(),
88 snapshot_id: metadata.id.clone(),
89 source_box_id: metadata.source_box_id.clone(),
90 rootfs: SnapshotRootfsPayload::RawExt4 {
91 artifact: Ext4ArtifactIdentity {
92 schema: artifact.manifest.schema.clone(),
93 builder: artifact.manifest.builder.clone(),
94 format: artifact.manifest.format.clone(),
95 capacity_bytes: artifact.manifest.capacity_bytes,
96 fs_uuid: artifact.manifest.fs_uuid.clone(),
97 },
98 sparse_sha256,
99 },
100 }
101 }
102
103 fn validate_for(&self, metadata: &SnapshotMetadata) -> Result<()> {
104 if self.schema != SNAPSHOT_ROOTFS_SCHEMA
105 || self.snapshot_id != metadata.id
106 || self.source_box_id != metadata.source_box_id
107 {
108 return Err(snapshot_error(format!(
109 "rootfs manifest identity does not match snapshot {}",
110 metadata.id
111 )));
112 }
113 if let SnapshotRootfsPayload::RawExt4 {
114 artifact,
115 sparse_sha256,
116 } = &self.rootfs
117 {
118 if artifact.schema != RAW_EXT4_ARTIFACT_SCHEMA
119 || artifact.format != "raw-ext4"
120 || artifact.capacity_bytes == 0
121 || artifact.fs_uuid.len() != 32
122 || !artifact
123 .fs_uuid
124 .bytes()
125 .all(|byte| byte.is_ascii_hexdigit())
126 || !is_lower_sha256(sparse_sha256)
127 {
128 return Err(snapshot_error(format!(
129 "raw-ext4 identity is invalid for snapshot {}",
130 metadata.id
131 )));
132 }
133 }
134 Ok(())
135 }
136}
137
138impl SnapshotStore {
139 #[cfg(target_os = "macos")]
143 pub fn save_guest_native_ext4(
144 &self,
145 mut metadata: SnapshotMetadata,
146 box_dir: &Path,
147 ) -> Result<SnapshotMetadata> {
148 validate_snapshot_id(&metadata.id)?;
149 let _lock = self.acquire_exclusive_lock()?;
150 let snapshot_dir = self.base_dir.join(&metadata.id);
151 require_absent(&snapshot_dir, "snapshot")?;
152
153 let staging_prefix = format!(".staging-{}-{}-", metadata.id, std::process::id());
154 let staging = tempfile::Builder::new()
155 .prefix(&staging_prefix)
156 .tempdir_in(&self.base_dir)
157 .map_err(|error| {
158 snapshot_error(format!(
159 "failed to create snapshot staging directory in {}: {error}",
160 self.base_dir.display()
161 ))
162 })?;
163
164 let source = box_dir.join(RAW_EXT4_DIRECTORY_NAME);
165 let destination = staging.path().join(RAW_EXT4_DIRECTORY_NAME);
166 let artifact =
167 crate::rootfs::clone_clean_guest_native_ext4_artifact(&source, &destination)?;
168 let digest = crate::rootfs::guest_native_ext4_sparse_digest(&artifact)?;
169 metadata.size_bytes = crate::rootfs::guest_native_ext4_allocated_bytes(&destination)?;
170 let manifest = SnapshotRootfsManifest::raw_ext4(&metadata, &artifact, digest);
171 write_manifest(staging.path(), &manifest)?;
172 write_metadata(staging.path(), &metadata)?;
173 sync_directory(staging.path())?;
174 publish_staging(staging, &snapshot_dir, &self.base_dir)?;
175 Ok(metadata)
176 }
177
178 pub fn rootfs_format(&self, id: &str) -> Result<SnapshotRootfsFormat> {
180 validate_snapshot_id(id)?;
181 let _lock = self.acquire_exclusive_lock()?;
182 let (snapshot_dir, metadata) = load_snapshot_bundle(&self.base_dir, id)?;
183 inspect_payload(&snapshot_dir, &metadata).map(|payload| payload.format())
184 }
185
186 pub fn restore_rootfs_to_box(
192 &self,
193 id: &str,
194 box_dir: &Path,
195 ) -> Result<RestoredSnapshotRootfs> {
196 validate_snapshot_id(id)?;
197 let _lock = self.acquire_exclusive_lock()?;
198 let (snapshot_dir, metadata) = load_snapshot_bundle(&self.base_dir, id)?;
199 let payload = inspect_payload(&snapshot_dir, &metadata)?;
200 validate_box_directory(box_dir)?;
201
202 let image_config = metadata.require_image_config()?.clone();
203
204 match payload {
205 ValidatedPayload::Directory(rootfs) => {
206 let marker = box_dir.join(".snapshot-lower");
207 require_absent(&marker, "snapshot lower marker")?;
208 let temporary = box_dir.join(format!(
209 ".snapshot-lower.{}.tmp",
210 uuid::Uuid::new_v4().simple()
211 ));
212 a3s_box_core::fs_atomic::write_durable(
213 &temporary,
214 &marker,
215 rootfs.to_string_lossy().as_bytes(),
216 )
217 .map_err(BoxError::IoError)?;
218 if let Err(error) =
219 crate::resolved_image::persist_snapshot_image_config(box_dir, &image_config)
220 {
221 let _ = std::fs::remove_file(&marker);
222 return Err(error);
223 }
224 Ok(RestoredSnapshotRootfs {
225 metadata,
226 format: SnapshotRootfsFormat::Directory,
227 })
228 }
229 ValidatedPayload::RawExt4 {
230 source,
231 #[cfg(target_os = "macos")]
232 identity,
233 } => {
234 #[cfg(target_os = "macos")]
235 {
236 let destination = box_dir.join(RAW_EXT4_DIRECTORY_NAME);
237 require_absent(&destination, "restored raw-ext4 artifact")?;
238 let artifact = crate::rootfs::clone_clean_guest_native_ext4_artifact(
239 &source,
240 &destination,
241 )?;
242 if let Err(error) = validate_ext4_identity(&artifact, &identity) {
243 let _ = std::fs::remove_dir_all(&destination);
244 return Err(error);
245 }
246 let digest = match crate::rootfs::guest_native_ext4_sparse_digest(&artifact) {
247 Ok(digest) => digest,
248 Err(error) => {
249 let _ = std::fs::remove_dir_all(&destination);
250 return Err(error);
251 }
252 };
253 if digest != identity.sparse_sha256 {
254 let _ = std::fs::remove_dir_all(&destination);
255 return Err(snapshot_error(format!(
256 "restored raw-ext4 integrity mismatch for snapshot {}",
257 metadata.id
258 )));
259 }
260 if let Err(error) =
261 crate::resolved_image::persist_snapshot_image_config(box_dir, &image_config)
262 {
263 let _ = std::fs::remove_dir_all(&destination);
264 return Err(error);
265 }
266 Ok(RestoredSnapshotRootfs {
267 metadata,
268 format: SnapshotRootfsFormat::GuestNativeExt4,
269 })
270 }
271 #[cfg(not(target_os = "macos"))]
272 {
273 let _ = source;
274 Err(snapshot_error(format!(
275 "snapshot {} contains a guest-native macOS rootfs that this host cannot restore",
276 metadata.id
277 )))
278 }
279 }
280 }
281 }
282}
283
284pub(super) fn write_directory_manifest(
285 snapshot_directory: &Path,
286 metadata: &SnapshotMetadata,
287) -> Result<()> {
288 write_manifest(
289 snapshot_directory,
290 &SnapshotRootfsManifest::directory(metadata),
291 )
292}
293
294enum ValidatedPayload {
295 Directory(PathBuf),
296 RawExt4 {
297 source: PathBuf,
298 #[cfg(target_os = "macos")]
299 identity: RawExt4Identity,
300 },
301}
302
303impl ValidatedPayload {
304 fn format(&self) -> SnapshotRootfsFormat {
305 match self {
306 Self::Directory(_) => SnapshotRootfsFormat::Directory,
307 Self::RawExt4 { .. } => SnapshotRootfsFormat::GuestNativeExt4,
308 }
309 }
310}
311
312#[cfg(target_os = "macos")]
313struct RawExt4Identity {
314 artifact: Ext4ArtifactIdentity,
315 sparse_sha256: String,
316}
317
318fn inspect_payload(snapshot_dir: &Path, metadata: &SnapshotMetadata) -> Result<ValidatedPayload> {
319 let manifest = read_manifest(snapshot_dir)?;
320 let Some(manifest) = manifest else {
321 reject_alternate_payload(snapshot_dir, RAW_EXT4_DIRECTORY_NAME, "legacy directory")?;
324 return validate_directory_payload(snapshot_dir).map(ValidatedPayload::Directory);
325 };
326 manifest.validate_for(metadata)?;
327 match manifest.rootfs {
328 SnapshotRootfsPayload::Directory => {
329 reject_alternate_payload(snapshot_dir, RAW_EXT4_DIRECTORY_NAME, "directory")?;
330 validate_directory_payload(snapshot_dir).map(ValidatedPayload::Directory)
331 }
332 SnapshotRootfsPayload::RawExt4 {
333 artifact,
334 sparse_sha256,
335 } => {
336 reject_alternate_payload(snapshot_dir, DIRECTORY_NAME, "raw-ext4")?;
337 let source = snapshot_dir.join(RAW_EXT4_DIRECTORY_NAME);
338 validate_raw_payload(&source, &artifact, &sparse_sha256)?;
339 Ok(ValidatedPayload::RawExt4 {
340 source,
341 #[cfg(target_os = "macos")]
342 identity: RawExt4Identity {
343 artifact,
344 sparse_sha256,
345 },
346 })
347 }
348 }
349}
350
351fn reject_alternate_payload(
352 snapshot_dir: &Path,
353 alternate_name: &str,
354 selected_format: &str,
355) -> Result<()> {
356 let alternate = snapshot_dir.join(alternate_name);
357 match std::fs::symlink_metadata(&alternate) {
358 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
359 Ok(_) => Err(snapshot_error(format!(
360 "snapshot bundle declares {selected_format} rootfs but also contains alternate payload {}",
361 alternate.display()
362 ))),
363 Err(error) => Err(BoxError::IoError(error)),
364 }
365}
366
367fn validate_directory_payload(snapshot_dir: &Path) -> Result<PathBuf> {
368 let rootfs = snapshot_dir.join(DIRECTORY_NAME);
369 let metadata = std::fs::symlink_metadata(&rootfs).map_err(|error| {
370 snapshot_error(format!(
371 "failed to inspect snapshot rootfs {}: {error}",
372 rootfs.display()
373 ))
374 })?;
375 if !metadata.is_dir() || metadata.file_type().is_symlink() {
376 return Err(snapshot_error(format!(
377 "snapshot rootfs is not a plain directory: {}",
378 rootfs.display()
379 )));
380 }
381 rootfs
382 .canonicalize()
383 .map_err(BoxError::IoError)
384 .and_then(|rootfs| {
385 if rootfs.parent() != Some(snapshot_dir) {
386 return Err(snapshot_error(format!(
387 "snapshot rootfs escapes its bundle: {}",
388 rootfs.display()
389 )));
390 }
391 Ok(rootfs)
392 })
393}
394
395fn validate_raw_payload(
396 source: &Path,
397 identity: &Ext4ArtifactIdentity,
398 sparse_sha256: &str,
399) -> Result<()> {
400 #[cfg(target_os = "macos")]
401 {
402 let artifact = crate::rootfs::open_clean_guest_native_ext4_artifact(source)?;
403 validate_ext4_identity(
404 &artifact,
405 &RawExt4Identity {
406 artifact: identity.clone(),
407 sparse_sha256: sparse_sha256.to_string(),
408 },
409 )?;
410 let actual = crate::rootfs::guest_native_ext4_sparse_digest(&artifact)?;
411 if actual != sparse_sha256 {
412 return Err(snapshot_error(format!(
413 "raw-ext4 snapshot integrity mismatch at {}",
414 artifact.disk.display()
415 )));
416 }
417 Ok(())
418 }
419 #[cfg(not(target_os = "macos"))]
420 {
421 let metadata = std::fs::symlink_metadata(source).map_err(BoxError::IoError)?;
422 if !metadata.is_dir() || metadata.file_type().is_symlink() {
423 return Err(snapshot_error(format!(
424 "raw-ext4 snapshot payload is not a plain directory: {}",
425 source.display()
426 )));
427 }
428 let _ = (identity, sparse_sha256);
429 Ok(())
430 }
431}
432
433#[cfg(target_os = "macos")]
434fn validate_ext4_identity(
435 artifact: &crate::rootfs::Ext4Artifact,
436 expected: &RawExt4Identity,
437) -> Result<()> {
438 let actual = Ext4ArtifactIdentity {
439 schema: artifact.manifest.schema.clone(),
440 builder: artifact.manifest.builder.clone(),
441 format: artifact.manifest.format.clone(),
442 capacity_bytes: artifact.manifest.capacity_bytes,
443 fs_uuid: artifact.manifest.fs_uuid.clone(),
444 };
445 if actual != expected.artifact {
446 return Err(snapshot_error(format!(
447 "raw-ext4 artifact identity mismatch at {}",
448 artifact.directory.display()
449 )));
450 }
451 Ok(())
452}
453
454fn load_snapshot_bundle(base_dir: &Path, id: &str) -> Result<(PathBuf, SnapshotMetadata)> {
455 load_snapshot_metadata(base_dir, id)?
456 .ok_or_else(|| snapshot_error(format!("snapshot '{id}' does not exist")))
457}
458
459pub(super) fn load_snapshot_metadata(
460 base_dir: &Path,
461 id: &str,
462) -> Result<Option<(PathBuf, SnapshotMetadata)>> {
463 validate_snapshot_id(id)?;
464 let base = base_dir.canonicalize().map_err(BoxError::IoError)?;
465 let snapshot = base_dir.join(id);
466 let file_type = match std::fs::symlink_metadata(&snapshot) {
467 Ok(metadata) => metadata.file_type(),
468 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
469 Err(error) => return Err(BoxError::IoError(error)),
470 };
471 if !file_type.is_dir() || file_type.is_symlink() {
472 return Err(snapshot_error(format!(
473 "snapshot bundle is not a plain directory: {}",
474 snapshot.display()
475 )));
476 }
477 let snapshot = snapshot.canonicalize().map_err(BoxError::IoError)?;
478 if snapshot.parent() != Some(base.as_path()) {
479 return Err(snapshot_error(format!(
480 "snapshot bundle escapes its store: {}",
481 snapshot.display()
482 )));
483 }
484 let metadata_path = snapshot.join("metadata.json");
485 let metadata_file = std::fs::symlink_metadata(&metadata_path).map_err(BoxError::IoError)?;
486 if !metadata_file.file_type().is_file()
487 || metadata_file.file_type().is_symlink()
488 || metadata_file.len() > MAX_SNAPSHOT_METADATA_BYTES
489 {
490 return Err(snapshot_error(format!(
491 "snapshot metadata is not a bounded plain file: {}",
492 metadata_path.display()
493 )));
494 }
495 let mut bytes = Vec::with_capacity(metadata_file.len() as usize);
496 File::open(&metadata_path)
497 .and_then(|file| {
498 file.take(MAX_SNAPSHOT_METADATA_BYTES + 1)
499 .read_to_end(&mut bytes)
500 })
501 .map_err(BoxError::IoError)?;
502 if bytes.len() as u64 > MAX_SNAPSHOT_METADATA_BYTES {
503 return Err(snapshot_error(format!(
504 "snapshot metadata grew beyond its limit: {}",
505 metadata_path.display()
506 )));
507 }
508 let metadata: SnapshotMetadata = serde_json::from_slice(&bytes).map_err(|error| {
509 snapshot_error(format!(
510 "invalid snapshot metadata {}: {error}",
511 metadata_path.display()
512 ))
513 })?;
514 if metadata.id != id {
515 return Err(snapshot_error(format!(
516 "snapshot metadata identity '{}' does not match requested id '{id}'",
517 metadata.id
518 )));
519 }
520 Ok(Some((snapshot, metadata)))
521}
522
523fn validate_box_directory(box_dir: &Path) -> Result<()> {
524 std::fs::create_dir_all(box_dir).map_err(BoxError::IoError)?;
525 let metadata = std::fs::symlink_metadata(box_dir).map_err(BoxError::IoError)?;
526 if !metadata.is_dir() || metadata.file_type().is_symlink() {
527 return Err(snapshot_error(format!(
528 "snapshot restore destination is not a plain directory: {}",
529 box_dir.display()
530 )));
531 }
532 Ok(())
533}
534
535fn read_manifest(snapshot_directory: &Path) -> Result<Option<SnapshotRootfsManifest>> {
536 let path = snapshot_directory.join(ROOTFS_MANIFEST_NAME);
537 let metadata = match std::fs::symlink_metadata(&path) {
538 Ok(metadata) => metadata,
539 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
540 Err(error) => return Err(BoxError::IoError(error)),
541 };
542 if !metadata.is_file()
543 || metadata.file_type().is_symlink()
544 || metadata.len() > MAX_ROOTFS_MANIFEST_BYTES
545 {
546 return Err(snapshot_error(format!(
547 "snapshot rootfs manifest is not a bounded plain file: {}",
548 path.display()
549 )));
550 }
551 let mut bytes = Vec::with_capacity(metadata.len() as usize);
552 File::open(&path)
553 .and_then(|file| {
554 file.take(MAX_ROOTFS_MANIFEST_BYTES + 1)
555 .read_to_end(&mut bytes)
556 })
557 .map_err(BoxError::IoError)?;
558 if bytes.len() as u64 > MAX_ROOTFS_MANIFEST_BYTES {
559 return Err(snapshot_error(format!(
560 "snapshot rootfs manifest grew beyond its limit: {}",
561 path.display()
562 )));
563 }
564 serde_json::from_slice(&bytes).map(Some).map_err(|error| {
565 snapshot_error(format!(
566 "invalid snapshot rootfs manifest {}: {error}",
567 path.display()
568 ))
569 })
570}
571
572fn write_manifest(directory: &Path, manifest: &SnapshotRootfsManifest) -> Result<()> {
573 let bytes = serde_json::to_vec_pretty(manifest).map_err(|error| {
574 BoxError::SerializationError(format!(
575 "Failed to encode snapshot rootfs manifest: {error}"
576 ))
577 })?;
578 write_new_synced(&directory.join(ROOTFS_MANIFEST_NAME), &bytes)
579}
580
581#[cfg(target_os = "macos")]
582fn write_metadata(directory: &Path, metadata: &SnapshotMetadata) -> Result<()> {
583 let bytes = serde_json::to_vec_pretty(metadata).map_err(|error| {
584 BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {error}"))
585 })?;
586 write_new_synced(&directory.join("metadata.json"), &bytes)
587}
588
589fn write_new_synced(path: &Path, bytes: &[u8]) -> Result<()> {
590 let mut file = OpenOptions::new()
591 .write(true)
592 .create_new(true)
593 .open(path)
594 .map_err(BoxError::IoError)?;
595 file.write_all(bytes).map_err(BoxError::IoError)?;
596 file.sync_all().map_err(BoxError::IoError)
597}
598
599#[cfg(target_os = "macos")]
600fn publish_staging(staging: tempfile::TempDir, destination: &Path, base_dir: &Path) -> Result<()> {
601 let staging_path = staging.keep();
602 if let Err(error) = std::fs::rename(&staging_path, destination) {
603 let _ = std::fs::remove_dir_all(&staging_path);
604 return Err(snapshot_error(format!(
605 "failed to publish snapshot {}: {error}",
606 destination.display()
607 )));
608 }
609 sync_directory(base_dir)
610}
611
612#[cfg(target_os = "macos")]
613fn sync_directory(path: &Path) -> Result<()> {
614 File::open(path)
615 .and_then(|directory| directory.sync_all())
616 .map_err(BoxError::IoError)
617}
618
619fn require_absent(path: &Path, description: &str) -> Result<()> {
620 match std::fs::symlink_metadata(path) {
621 Ok(_) => Err(snapshot_error(format!(
622 "{description} already exists: {}",
623 path.display()
624 ))),
625 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
626 Err(error) => Err(BoxError::IoError(error)),
627 }
628}
629
630pub(super) fn validate_snapshot_id(id: &str) -> Result<()> {
631 if id.is_empty()
632 || id.len() > 255
633 || id == "."
634 || id == ".."
635 || Path::new(id).components().count() != 1
636 || id.contains(std::path::MAIN_SEPARATOR)
637 || id.as_bytes().contains(&0)
638 {
639 return Err(snapshot_error(format!("invalid snapshot id '{id}'")));
640 }
641 Ok(())
642}
643
644fn is_lower_sha256(value: &str) -> bool {
645 value.len() == 64
646 && value
647 .bytes()
648 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
649}
650
651fn snapshot_error(message: impl Into<String>) -> BoxError {
652 BoxError::CacheError(message.into())
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 fn metadata(id: &str) -> SnapshotMetadata {
660 let mut metadata = SnapshotMetadata::new(
661 id.to_string(),
662 id.to_string(),
663 "source-box".to_string(),
664 "alpine:3.20".to_string(),
665 );
666 metadata.image_config = Some(a3s_box_core::SnapshotImageConfig::default());
667 metadata
668 }
669
670 #[test]
671 fn directory_snapshot_manifest_is_explicit_and_restore_is_durable() {
672 let temporary = tempfile::tempdir().unwrap();
673 let source = temporary.path().join("source");
674 std::fs::create_dir(&source).unwrap();
675 std::fs::write(source.join("state"), b"directory-generation").unwrap();
676 let store = SnapshotStore::new(&temporary.path().join("snapshots")).unwrap();
677
678 store.save(metadata("directory"), &source).unwrap();
679 assert_eq!(
680 store.rootfs_format("directory").unwrap(),
681 SnapshotRootfsFormat::Directory
682 );
683
684 let box_dir = temporary.path().join("boxes/restored");
685 let restored = store.restore_rootfs_to_box("directory", &box_dir).unwrap();
686 assert_eq!(restored.metadata.id, "directory");
687 assert_eq!(restored.format, SnapshotRootfsFormat::Directory);
688 assert_eq!(
689 PathBuf::from(std::fs::read_to_string(box_dir.join(".snapshot-lower")).unwrap()),
690 store.rootfs_path("directory").canonicalize().unwrap()
691 );
692 assert!(box_dir
693 .join(crate::resolved_image::RESOLVED_IMAGE_CONFIG_FILE)
694 .is_file());
695 }
696
697 #[test]
698 fn legacy_directory_snapshot_without_manifest_remains_supported() {
699 let temporary = tempfile::tempdir().unwrap();
700 let store = SnapshotStore::new(&temporary.path().join("snapshots")).unwrap();
701 let snapshot = temporary.path().join("snapshots/legacy");
702 std::fs::create_dir_all(snapshot.join("rootfs")).unwrap();
703 std::fs::write(snapshot.join("rootfs/state"), b"legacy").unwrap();
704 std::fs::write(
705 snapshot.join("metadata.json"),
706 serde_json::to_vec_pretty(&metadata("legacy")).unwrap(),
707 )
708 .unwrap();
709
710 assert_eq!(
711 store.rootfs_format("legacy").unwrap(),
712 SnapshotRootfsFormat::Directory
713 );
714 }
715
716 #[test]
717 fn rootfs_manifest_cannot_be_rebound_to_other_metadata() {
718 let temporary = tempfile::tempdir().unwrap();
719 let source = temporary.path().join("source");
720 std::fs::create_dir(&source).unwrap();
721 let store = SnapshotStore::new(&temporary.path().join("snapshots")).unwrap();
722 store.save(metadata("bound"), &source).unwrap();
723
724 let manifest = temporary.path().join("snapshots/bound/rootfs.json");
725 let mut value: serde_json::Value =
726 serde_json::from_slice(&std::fs::read(&manifest).unwrap()).unwrap();
727 value["source_box_id"] = serde_json::Value::String("other-box".to_string());
728 std::fs::write(&manifest, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
729
730 let error = store.rootfs_format("bound").unwrap_err().to_string();
731 assert!(error.contains("identity does not match"), "{error}");
732 }
733
734 #[test]
735 fn directory_snapshot_rejects_an_ambiguous_raw_payload() {
736 let temporary = tempfile::tempdir().unwrap();
737 let source = temporary.path().join("source");
738 std::fs::create_dir(&source).unwrap();
739 let store = SnapshotStore::new(&temporary.path().join("snapshots")).unwrap();
740 store.save(metadata("ambiguous"), &source).unwrap();
741 std::fs::create_dir(
742 temporary
743 .path()
744 .join("snapshots/ambiguous")
745 .join(RAW_EXT4_DIRECTORY_NAME),
746 )
747 .unwrap();
748
749 let error = store.rootfs_format("ambiguous").unwrap_err().to_string();
750
751 assert!(error.contains("alternate payload"), "{error}");
752 }
753
754 #[cfg(target_os = "macos")]
755 fn publish_raw_box(box_dir: &Path) -> crate::rootfs::Ext4Artifact {
756 let source = box_dir.join("logical-rootfs");
757 std::fs::create_dir_all(&source).unwrap();
758 std::fs::write(source.join("state"), b"raw-generation").unwrap();
759 crate::rootfs::publish_ext4_artifact(
760 &source,
761 &box_dir.join(RAW_EXT4_DIRECTORY_NAME),
762 crate::rootfs::Ext4ArtifactOptions::from_disk_mib(16, [7; 16]).unwrap(),
763 )
764 .unwrap()
765 }
766
767 #[cfg(target_os = "macos")]
768 #[test]
769 fn raw_ext4_snapshot_restores_an_independent_verified_clone() {
770 use std::io::{Seek, SeekFrom, Write};
771
772 let temporary = tempfile::tempdir().unwrap();
773 let source_box = temporary.path().join("boxes/source");
774 let source_artifact = publish_raw_box(&source_box);
775 let store = SnapshotStore::new(&temporary.path().join("snapshots")).unwrap();
776
777 let saved = store
778 .save_guest_native_ext4(metadata("raw"), &source_box)
779 .unwrap();
780 assert!(saved.size_bytes > 0);
781 assert_eq!(
782 store.rootfs_format("raw").unwrap(),
783 SnapshotRootfsFormat::GuestNativeExt4
784 );
785 assert!(!store.rootfs_path("raw").exists());
786
787 let mut source = OpenOptions::new()
790 .write(true)
791 .open(&source_artifact.disk)
792 .unwrap();
793 source.seek(SeekFrom::Start(0)).unwrap();
794 source.write_all(b"changed-source").unwrap();
795 source.sync_all().unwrap();
796
797 let restored_box = temporary.path().join("boxes/restored");
798 let restored = store.restore_rootfs_to_box("raw", &restored_box).unwrap();
799 assert_eq!(restored.format, SnapshotRootfsFormat::GuestNativeExt4);
800 assert!(!restored_box.join(".snapshot-lower").exists());
801 let restored_artifact = crate::rootfs::open_clean_guest_native_ext4_artifact(
802 &restored_box.join(RAW_EXT4_DIRECTORY_NAME),
803 )
804 .unwrap();
805 let filesystem =
806 mkext4::reader::Fs::open(File::open(&restored_artifact.disk).unwrap()).unwrap();
807 let state = filesystem.resolve("/state").unwrap();
808 assert_eq!(filesystem.read_file(state).unwrap(), b"raw-generation");
809
810 assert!(store.delete("raw").unwrap());
811 assert!(restored_artifact.disk.is_file());
812 assert_eq!(filesystem.read_file(state).unwrap(), b"raw-generation");
813 }
814
815 #[cfg(target_os = "macos")]
816 #[test]
817 fn raw_ext4_restore_rejects_payload_tampering_before_publication() {
818 use std::io::{Seek, SeekFrom, Write};
819
820 let temporary = tempfile::tempdir().unwrap();
821 let source_box = temporary.path().join("boxes/source");
822 publish_raw_box(&source_box);
823 let store = SnapshotStore::new(&temporary.path().join("snapshots")).unwrap();
824 store
825 .save_guest_native_ext4(metadata("tampered"), &source_box)
826 .unwrap();
827
828 let disk = temporary
829 .path()
830 .join("snapshots/tampered/rootfs-ext4-v1/rootfs.ext4");
831 let mut disk = OpenOptions::new().write(true).open(disk).unwrap();
832 disk.seek(SeekFrom::Start(0)).unwrap();
833 disk.write_all(b"tampered").unwrap();
834 disk.sync_all().unwrap();
835
836 let restored_box = temporary.path().join("boxes/restored");
837 let error = store
838 .restore_rootfs_to_box("tampered", &restored_box)
839 .unwrap_err()
840 .to_string();
841 assert!(error.contains("integrity mismatch"), "{error}");
842 assert!(!restored_box.join(RAW_EXT4_DIRECTORY_NAME).exists());
843 }
844}