Skip to main content

a3s_box_runtime/snapshot/
rootfs_bundle.rs

1//! Versioned rootfs payloads for filesystem snapshots.
2//!
3//! Directory snapshots retain the historical shared-lower layout. On macOS,
4//! a clean guest-native ext4 generation is captured and restored as an
5//! immutable raw artifact clone, so neither operation attaches a host volume.
6
7use 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/// Storage representation of a published filesystem snapshot.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SnapshotRootfsFormat {
28    /// A host directory shared as an immutable lower by restored boxes.
29    Directory,
30    /// A clean raw ext4 artifact cloned privately into every restored box.
31    GuestNativeExt4,
32}
33
34/// Metadata and rootfs representation materialized for one restored box.
35#[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    /// Save a clean guest-native ext4 generation without exposing it as a host
140    /// directory. The snapshot owns an immutable clone; later restores own
141    /// separate writable clones and therefore do not retain a store reference.
142    #[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    /// Inspect and validate the rootfs representation of a published snapshot.
179    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    /// Materialize a published snapshot into a newly-created box directory.
187    ///
188    /// Directory payloads publish the historical `.snapshot-lower` reference
189    /// while the store lock is held. Raw payloads clone and validate a private
190    /// writable artifact, so deleting the snapshot later cannot affect the box.
191    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        // Legacy snapshots predate the versioned rootfs manifest. Preserve
322        // compatibility only for the exact historical directory payload.
323        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        // Mutating the source clone after capture must not alter the immutable
788        // snapshot generation even though APFS initially shares its blocks.
789        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}