Skip to main content

nucleus/image/
mod.rs

1use crate::container::{ContainerState, RootfsMode};
2use crate::error::{NucleusError, Result};
3use crate::filesystem::{
4    is_immediate_nix_store_object_path, read_rootfs_attestation, DirectoryManifest,
5    ROOTFS_ATTESTATION_FILE, ROOTFS_STORE_PATHS_FILE,
6};
7use crate::resources::Cgroup;
8use nix::sys::stat::{makedev, mknod, Mode, SFlag};
9use nix::unistd::Uid;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::BTreeMap;
13use std::ffi::CString;
14use std::fs;
15use std::fs::OpenOptions;
16use std::io::{Read, Write};
17use std::os::unix::ffi::OsStrExt;
18use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt};
19use std::path::{Component, Path, PathBuf};
20use std::time::SystemTime;
21
22pub const IMAGE_SCHEMA_VERSION: u32 = 2;
23pub const IMAGE_MANIFEST_FILE: &str = "manifest.json";
24pub const IMAGE_SIGNATURE_FILE: &str = "image.sig";
25pub const IMAGE_ROOTFS_ATTESTATION_FILE: &str = "rootfs.sha256";
26pub const IMAGE_STORE_PATHS_FILE: &str = "store-paths";
27pub const IMAGE_DIFF_DIR: &str = "diff";
28const IMAGE_HMAC_KEY_SIZE: usize = 32;
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct NucleusImageManifest {
32    pub schema_version: u32,
33    pub image_id: String,
34    pub created_at: u64,
35    pub nucleus_version: String,
36    pub base: ImageBase,
37    pub diff: Option<ImageDiff>,
38    pub config: ImageConfig,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct ImageBase {
43    pub rootfs_path: String,
44    pub store_paths: Vec<String>,
45    pub attestation: DirectoryManifest,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ImageDiff {
50    pub path: String,
51    pub manifest: BTreeMap<String, String>,
52    pub deleted_paths: Vec<String>,
53    pub digest: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct ImageConfig {
58    pub command: Vec<String>,
59    pub env: BTreeMap<String, String>,
60    pub workdir: String,
61    pub uid: u32,
62    pub gid: u32,
63    pub additional_gids: Vec<u32>,
64}
65
66#[derive(Debug, Clone, Default)]
67pub struct ImageCommitOptions {
68    pub freeze: bool,
69}
70
71impl NucleusImageManifest {
72    pub fn new(base: ImageBase, diff: Option<ImageDiff>, config: ImageConfig) -> Result<Self> {
73        let created_at = SystemTime::now()
74            .duration_since(SystemTime::UNIX_EPOCH)
75            .unwrap_or_default()
76            .as_secs();
77        let mut manifest = Self {
78            schema_version: IMAGE_SCHEMA_VERSION,
79            image_id: String::new(),
80            created_at,
81            nucleus_version: env!("CARGO_PKG_VERSION").to_string(),
82            base,
83            diff,
84            config,
85        };
86        manifest.image_id = manifest.compute_image_id()?;
87        Ok(manifest)
88    }
89
90    pub fn compute_image_id(&self) -> Result<String> {
91        let mut unsigned = self.clone();
92        unsigned.image_id.clear();
93        let canonical = serde_json::to_vec(&unsigned)?;
94        Ok(hex::encode(Sha256::digest(canonical)))
95    }
96
97    pub fn validate_identity(&self) -> Result<()> {
98        if self.schema_version != IMAGE_SCHEMA_VERSION {
99            return Err(image_error(format!(
100                "Unsupported image schema version {}",
101                self.schema_version
102            )));
103        }
104        let expected = self.compute_image_id()?;
105        if self.image_id != expected {
106            return Err(image_error(format!(
107                "Image manifest digest mismatch: expected {}, got {}",
108                expected, self.image_id
109            )));
110        }
111        Ok(())
112    }
113
114    pub fn save(&self, image_dir: &Path) -> Result<()> {
115        atomic_write_json(image_dir, IMAGE_MANIFEST_FILE, self)
116    }
117
118    pub fn load(image_dir: &Path) -> Result<Self> {
119        let path = image_dir.join(IMAGE_MANIFEST_FILE);
120        let json = read_file_nofollow_bytes(&path)
121            .map_err(|e| image_error(format!("Failed to read image manifest {:?}: {}", path, e)))?;
122        let manifest: Self = serde_json::from_slice(&json)?;
123        manifest.validate_identity()?;
124        Ok(manifest)
125    }
126}
127
128impl ImageBase {
129    pub fn from_rootfs(rootfs_path: &Path) -> Result<Self> {
130        let rootfs_path = fs::canonicalize(rootfs_path).map_err(|e| {
131            image_error(format!(
132                "Failed to canonicalize rootfs path {:?}: {}",
133                rootfs_path, e
134            ))
135        })?;
136        let store_paths = read_store_paths(&rootfs_path)?;
137        let attestation = read_rootfs_attestation(&rootfs_path)?;
138        Ok(Self {
139            rootfs_path: rootfs_path.display().to_string(),
140            store_paths,
141            attestation,
142        })
143    }
144}
145
146impl ImageConfig {
147    pub fn from_state(state: &ContainerState) -> Self {
148        Self {
149            command: state.command.clone(),
150            env: state.environment.clone(),
151            workdir: state.workdir.clone(),
152            uid: state.process_uid,
153            gid: state.process_gid,
154            additional_gids: state.additional_gids.clone(),
155        }
156    }
157}
158
159pub fn commit_container_image(
160    state: &ContainerState,
161    output_dir: &Path,
162    options: &ImageCommitOptions,
163) -> Result<NucleusImageManifest> {
164    if state.rootfs_mode != RootfsMode::Overlay {
165        return Err(image_error(format!(
166            "Container {} was launched with rootfs_mode={:?}; image commit requires overlay",
167            state.id, state.rootfs_mode
168        )));
169    }
170
171    let rootfs_path = state.rootfs_path.as_deref().ok_or_else(|| {
172        image_error(format!(
173            "Container {} has no recorded rootfs path; cannot commit image",
174            state.id
175        ))
176    })?;
177    let upperdir = state.rootfs_upperdir.as_deref().ok_or_else(|| {
178        image_error(format!(
179            "Container {} has no recorded overlay upperdir; cannot commit image",
180            state.id
181        ))
182    })?;
183    let upperdir = PathBuf::from(upperdir);
184    ensure_real_directory(&upperdir, "overlay upperdir")?;
185
186    let _freeze_guard = if options.freeze {
187        let cgroup_path = state.cgroup_path.as_deref().ok_or_else(|| {
188            image_error(format!(
189                "Container {} has no recorded cgroup path; cannot freeze for image commit",
190                state.id
191            ))
192        })?;
193        Some(Cgroup::freeze_existing(Path::new(cgroup_path))?)
194    } else {
195        None
196    };
197
198    prepare_image_dir(output_dir)?;
199    let diff_dir = output_dir.join(IMAGE_DIFF_DIR);
200    prepare_empty_dir(&diff_dir, "image diff directory")?;
201
202    let base = ImageBase::from_rootfs(Path::new(rootfs_path))?;
203    copy_base_sidecars(Path::new(rootfs_path), output_dir)?;
204    let diff = export_diff(&upperdir, &diff_dir)?;
205    let config = ImageConfig::from_state(state);
206    let manifest = NucleusImageManifest::new(base, Some(diff), config)?;
207    manifest.save(output_dir)?;
208    write_image_hmac(output_dir)?;
209    Ok(manifest)
210}
211
212pub fn load_image(image_dir: &Path) -> Result<NucleusImageManifest> {
213    let sig_path = image_dir.join(IMAGE_SIGNATURE_FILE);
214    if sig_path.exists() {
215        verify_image_hmac(image_dir)?;
216    } else if !is_immediate_nix_store_object_path(image_dir) {
217        return Err(image_error(format!(
218            "Image signature {:?} is missing outside the Nix store",
219            sig_path
220        )));
221    }
222    NucleusImageManifest::load(image_dir)
223}
224
225pub fn copy_image_diff_to_upper(image_dir: &Path, upperdir: &Path) -> Result<()> {
226    let manifest = load_image(image_dir)?;
227    let Some(diff) = manifest.diff else {
228        return Ok(());
229    };
230    let diff_dir = image_dir.join(diff.path);
231    ensure_real_directory(&diff_dir, "image diff directory")?;
232    ensure_real_directory(upperdir, "target overlay upperdir")?;
233    copy_tree(
234        &diff_dir,
235        &diff_dir,
236        upperdir,
237        &mut BTreeMap::new(),
238        &mut Vec::new(),
239    )?;
240    replay_deleted_paths(upperdir, &diff.deleted_paths)?;
241    Ok(())
242}
243
244fn replay_deleted_paths(upperdir: &Path, deleted_paths: &[String]) -> Result<()> {
245    for deleted in deleted_paths {
246        let dest = upperdir.join(validate_manifest_relative_path(deleted)?);
247        if let Some(parent) = dest.parent() {
248            fs::create_dir_all(parent).map_err(|e| {
249                image_error(format!(
250                    "Failed to create whiteout parent {:?}: {}",
251                    parent, e
252                ))
253            })?;
254        }
255        if dest.exists() {
256            return Err(image_error(format!(
257                "Cannot replay deletion whiteout over existing path {:?}",
258                dest
259            )));
260        }
261        mknod(
262            &dest,
263            SFlag::S_IFCHR,
264            Mode::from_bits_truncate(0),
265            makedev(0, 0),
266        )
267        .map_err(|e| {
268            image_error(format!(
269                "Failed to replay deletion whiteout {:?}: {}",
270                dest, e
271            ))
272        })?;
273    }
274    Ok(())
275}
276
277fn export_diff(upperdir: &Path, diff_dir: &Path) -> Result<ImageDiff> {
278    let mut manifest = BTreeMap::new();
279    let mut deleted_paths = Vec::new();
280    copy_tree(
281        upperdir,
282        upperdir,
283        diff_dir,
284        &mut manifest,
285        &mut deleted_paths,
286    )?;
287    let digest = digest_diff_manifest(&manifest, &deleted_paths)?;
288    Ok(ImageDiff {
289        path: IMAGE_DIFF_DIR.to_string(),
290        manifest,
291        deleted_paths,
292        digest,
293    })
294}
295
296fn copy_tree(
297    root: &Path,
298    current: &Path,
299    dest_root: &Path,
300    manifest: &mut BTreeMap<String, String>,
301    deleted_paths: &mut Vec<String>,
302) -> Result<()> {
303    let mut entries = fs::read_dir(current)
304        .map_err(|e| {
305            image_error(format!(
306                "Failed to read diff directory {:?}: {}",
307                current, e
308            ))
309        })?
310        .collect::<std::result::Result<Vec<_>, _>>()
311        .map_err(|e| image_error(format!("Failed to enumerate diff directory: {}", e)))?;
312    entries.sort_by_key(|entry| entry.file_name());
313
314    for entry in entries {
315        let path = entry.path();
316        let rel = relative_path(root, &path)?;
317        if should_skip_runtime_diff_path(&rel) {
318            continue;
319        }
320
321        let metadata = fs::symlink_metadata(&path)
322            .map_err(|e| image_error(format!("Failed to stat diff path {:?}: {}", path, e)))?;
323        let dest = dest_root.join(&rel);
324        if metadata.file_type().is_symlink() {
325            let target = fs::read_link(&path)
326                .map_err(|e| image_error(format!("Failed to read symlink {:?}: {}", path, e)))?;
327            if let Some(parent) = dest.parent() {
328                fs::create_dir_all(parent).map_err(|e| {
329                    image_error(format!("Failed to create diff parent {:?}: {}", parent, e))
330                })?;
331            }
332            std::os::unix::fs::symlink(&target, &dest).map_err(|e| {
333                image_error(format!(
334                    "Failed to copy symlink {:?} -> {:?}: {}",
335                    path, dest, e
336                ))
337            })?;
338            preserve_path_metadata(&path, &dest, &metadata, false)?;
339            manifest.insert(rel, format!("symlink:{}", target.display()));
340        } else if metadata.is_dir() {
341            fs::create_dir_all(&dest).map_err(|e| {
342                image_error(format!("Failed to create diff directory {:?}: {}", dest, e))
343            })?;
344            copy_tree(root, &path, dest_root, manifest, deleted_paths)?;
345            preserve_path_metadata(&path, &dest, &metadata, true)?;
346        } else if metadata.is_file() {
347            if let Some(parent) = dest.parent() {
348                fs::create_dir_all(parent).map_err(|e| {
349                    image_error(format!("Failed to create diff parent {:?}: {}", parent, e))
350                })?;
351            }
352            fs::copy(&path, &dest).map_err(|e| {
353                image_error(format!(
354                    "Failed to copy diff file {:?} -> {:?}: {}",
355                    path, dest, e
356                ))
357            })?;
358            preserve_path_metadata(&path, &dest, &metadata, true)?;
359            manifest.insert(rel, hash_file(&path)?);
360        } else if metadata.file_type().is_char_device() && metadata.rdev() == 0 {
361            deleted_paths.push(rel);
362        } else {
363            return Err(image_error(format!(
364                "Image diff contains unsupported special file {:?}",
365                path
366            )));
367        }
368    }
369
370    Ok(())
371}
372
373fn preserve_path_metadata(
374    source: &Path,
375    dest: &Path,
376    metadata: &fs::Metadata,
377    follow: bool,
378) -> Result<()> {
379    set_owner(dest, metadata.uid(), metadata.gid(), follow)?;
380    if follow {
381        fs::set_permissions(
382            dest,
383            fs::Permissions::from_mode(metadata.permissions().mode()),
384        )
385        .map_err(|e| image_error(format!("Failed to set metadata mode for {:?}: {}", dest, e)))?;
386    }
387    copy_xattrs(source, dest, follow)?;
388    set_timestamps(dest, metadata, follow)
389}
390
391fn set_owner(path: &Path, uid: u32, gid: u32, follow: bool) -> Result<()> {
392    let path_c = path_cstring(path)?;
393    let flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW };
394    let rc = unsafe {
395        libc::fchownat(
396            libc::AT_FDCWD,
397            path_c.as_ptr(),
398            uid as libc::uid_t,
399            gid as libc::gid_t,
400            flags,
401        )
402    };
403    if rc != 0 {
404        return Err(image_error(format!(
405            "Failed to preserve owner {}:{} for {:?}: {}",
406            uid,
407            gid,
408            path,
409            std::io::Error::last_os_error()
410        )));
411    }
412    Ok(())
413}
414
415fn set_timestamps(path: &Path, metadata: &fs::Metadata, follow: bool) -> Result<()> {
416    let path_c = path_cstring(path)?;
417    let times = [
418        libc::timespec {
419            tv_sec: metadata.atime() as libc::time_t,
420            tv_nsec: metadata.atime_nsec() as libc::c_long,
421        },
422        libc::timespec {
423            tv_sec: metadata.mtime() as libc::time_t,
424            tv_nsec: metadata.mtime_nsec() as libc::c_long,
425        },
426    ];
427    let flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW };
428    let rc = unsafe { libc::utimensat(libc::AT_FDCWD, path_c.as_ptr(), times.as_ptr(), flags) };
429    if rc != 0 {
430        return Err(image_error(format!(
431            "Failed to preserve timestamps for {:?}: {}",
432            path,
433            std::io::Error::last_os_error()
434        )));
435    }
436    Ok(())
437}
438
439fn copy_xattrs(source: &Path, dest: &Path, follow: bool) -> Result<()> {
440    for name in list_xattrs(source, follow)? {
441        if let Some(value) = get_xattr(source, &name, follow)? {
442            set_xattr(dest, &name, &value, follow)?;
443        }
444    }
445    Ok(())
446}
447
448fn list_xattrs(path: &Path, follow: bool) -> Result<Vec<Vec<u8>>> {
449    let path_c = path_cstring(path)?;
450    let size = unsafe {
451        if follow {
452            libc::listxattr(path_c.as_ptr(), std::ptr::null_mut(), 0)
453        } else {
454            libc::llistxattr(path_c.as_ptr(), std::ptr::null_mut(), 0)
455        }
456    };
457    if size < 0 {
458        let err = std::io::Error::last_os_error();
459        if is_xattr_unsupported(&err) {
460            return Ok(Vec::new());
461        }
462        return Err(image_error(format!(
463            "Failed to list xattrs for {:?}: {}",
464            path, err
465        )));
466    }
467    if size == 0 {
468        return Ok(Vec::new());
469    }
470
471    let mut buf = vec![0u8; size as usize];
472    let read = unsafe {
473        if follow {
474            libc::listxattr(
475                path_c.as_ptr(),
476                buf.as_mut_ptr() as *mut libc::c_char,
477                buf.len(),
478            )
479        } else {
480            libc::llistxattr(
481                path_c.as_ptr(),
482                buf.as_mut_ptr() as *mut libc::c_char,
483                buf.len(),
484            )
485        }
486    };
487    if read < 0 {
488        return Err(image_error(format!(
489            "Failed to read xattr list for {:?}: {}",
490            path,
491            std::io::Error::last_os_error()
492        )));
493    }
494    buf.truncate(read as usize);
495    Ok(buf
496        .split(|byte| *byte == 0)
497        .filter(|name| !name.is_empty())
498        .map(|name| name.to_vec())
499        .collect())
500}
501
502fn get_xattr(path: &Path, name: &[u8], follow: bool) -> Result<Option<Vec<u8>>> {
503    let path_c = path_cstring(path)?;
504    let name_c = bytes_cstring(name, "xattr name")?;
505    let size = unsafe {
506        if follow {
507            libc::getxattr(path_c.as_ptr(), name_c.as_ptr(), std::ptr::null_mut(), 0)
508        } else {
509            libc::lgetxattr(path_c.as_ptr(), name_c.as_ptr(), std::ptr::null_mut(), 0)
510        }
511    };
512    if size < 0 {
513        let err = std::io::Error::last_os_error();
514        if err.raw_os_error() == Some(libc::ENODATA) || is_xattr_unsupported(&err) {
515            return Ok(None);
516        }
517        return Err(image_error(format!(
518            "Failed to get xattr {:?} for {:?}: {}",
519            String::from_utf8_lossy(name),
520            path,
521            err
522        )));
523    }
524    let mut value = vec![0u8; size as usize];
525    let read = unsafe {
526        if follow {
527            libc::getxattr(
528                path_c.as_ptr(),
529                name_c.as_ptr(),
530                value.as_mut_ptr() as *mut libc::c_void,
531                value.len(),
532            )
533        } else {
534            libc::lgetxattr(
535                path_c.as_ptr(),
536                name_c.as_ptr(),
537                value.as_mut_ptr() as *mut libc::c_void,
538                value.len(),
539            )
540        }
541    };
542    if read < 0 {
543        return Err(image_error(format!(
544            "Failed to read xattr {:?} for {:?}: {}",
545            String::from_utf8_lossy(name),
546            path,
547            std::io::Error::last_os_error()
548        )));
549    }
550    value.truncate(read as usize);
551    Ok(Some(value))
552}
553
554fn set_xattr(path: &Path, name: &[u8], value: &[u8], follow: bool) -> Result<()> {
555    let path_c = path_cstring(path)?;
556    let name_c = bytes_cstring(name, "xattr name")?;
557    let rc = unsafe {
558        if follow {
559            libc::setxattr(
560                path_c.as_ptr(),
561                name_c.as_ptr(),
562                value.as_ptr() as *const libc::c_void,
563                value.len(),
564                0,
565            )
566        } else {
567            libc::lsetxattr(
568                path_c.as_ptr(),
569                name_c.as_ptr(),
570                value.as_ptr() as *const libc::c_void,
571                value.len(),
572                0,
573            )
574        }
575    };
576    if rc != 0 {
577        return Err(image_error(format!(
578            "Failed to preserve xattr {:?} for {:?}: {}",
579            String::from_utf8_lossy(name),
580            path,
581            std::io::Error::last_os_error()
582        )));
583    }
584    Ok(())
585}
586
587fn is_xattr_unsupported(err: &std::io::Error) -> bool {
588    let raw = err.raw_os_error();
589    raw == Some(libc::ENOTSUP) || raw == Some(libc::EOPNOTSUPP)
590}
591
592fn copy_base_sidecars(rootfs_path: &Path, output_dir: &Path) -> Result<()> {
593    copy_sidecar(
594        &rootfs_path.join(ROOTFS_ATTESTATION_FILE),
595        &output_dir.join(IMAGE_ROOTFS_ATTESTATION_FILE),
596    )?;
597    copy_sidecar(
598        &rootfs_path.join(ROOTFS_STORE_PATHS_FILE),
599        &output_dir.join(IMAGE_STORE_PATHS_FILE),
600    )
601}
602
603fn copy_sidecar(source: &Path, dest: &Path) -> Result<()> {
604    let content = read_file_nofollow_bytes(source)
605        .map_err(|e| image_error(format!("Failed to read sidecar {:?}: {}", source, e)))?;
606    atomic_write_bytes(dest, &content, 0o600)
607}
608
609fn read_store_paths(rootfs_path: &Path) -> Result<Vec<String>> {
610    let path = rootfs_path.join(ROOTFS_STORE_PATHS_FILE);
611    let content = read_file_nofollow_bytes(&path)
612        .map_err(|e| image_error(format!("Failed to read store paths {:?}: {}", path, e)))?;
613    let content = String::from_utf8(content)
614        .map_err(|e| image_error(format!("Store paths {:?} are not UTF-8: {}", path, e)))?;
615    let mut paths = Vec::new();
616    for (line_no, line) in content.lines().enumerate() {
617        let trimmed = line.trim();
618        if trimmed.is_empty() {
619            continue;
620        }
621        if !is_immediate_nix_store_object_path(Path::new(trimmed)) {
622            return Err(image_error(format!(
623                "Invalid store path on line {} in {:?}: {}",
624                line_no + 1,
625                path,
626                trimmed
627            )));
628        }
629        paths.push(trimmed.to_string());
630    }
631    paths.sort();
632    paths.dedup();
633    Ok(paths)
634}
635
636fn digest_diff_manifest(
637    manifest: &BTreeMap<String, String>,
638    deleted_paths: &[String],
639) -> Result<String> {
640    let canonical = serde_json::to_vec(&(manifest, deleted_paths))?;
641    Ok(hex::encode(Sha256::digest(canonical)))
642}
643
644fn should_skip_runtime_diff_path(rel: &str) -> bool {
645    first_component(rel)
646        .map(|component| matches!(component, "dev" | "proc" | "sys"))
647        .unwrap_or(false)
648        || rel == ".old_root"
649        || rel.starts_with(".old_root/")
650        || rel == "run/secrets"
651        || rel.starts_with("run/secrets/")
652}
653
654fn first_component(path: &str) -> Option<&str> {
655    path.split('/')
656        .next()
657        .filter(|component| !component.is_empty())
658}
659
660fn relative_path(root: &Path, path: &Path) -> Result<String> {
661    let rel = path
662        .strip_prefix(root)
663        .map_err(|e| image_error(format!("Failed to compute relative diff path: {}", e)))?;
664    path_to_string(rel)
665}
666
667fn path_to_string(path: &Path) -> Result<String> {
668    let mut parts = Vec::new();
669    for component in path.components() {
670        match component {
671            Component::Normal(part) => {
672                let part = part.to_str().ok_or_else(|| {
673                    image_error(format!("Image path component is not UTF-8: {:?}", part))
674                })?;
675                parts.push(part.to_string());
676            }
677            Component::CurDir => {}
678            Component::RootDir | Component::ParentDir | Component::Prefix(_) => {
679                return Err(image_error(format!(
680                    "Invalid relative image path component in {:?}",
681                    path
682                )));
683            }
684        }
685    }
686    Ok(parts.join("/"))
687}
688
689fn path_cstring(path: &Path) -> Result<CString> {
690    CString::new(path.as_os_str().as_bytes())
691        .map_err(|_| image_error(format!("Path {:?} contains an interior NUL", path)))
692}
693
694fn bytes_cstring(bytes: &[u8], label: &str) -> Result<CString> {
695    CString::new(bytes).map_err(|_| {
696        image_error(format!(
697            "{} {:?} contains an interior NUL",
698            label,
699            String::from_utf8_lossy(bytes)
700        ))
701    })
702}
703
704fn validate_manifest_relative_path(path: &str) -> Result<PathBuf> {
705    if path.is_empty() {
706        return Err(image_error(
707            "Image manifest path cannot be empty".to_string(),
708        ));
709    }
710    let path = Path::new(path);
711    if path.is_absolute() {
712        return Err(image_error(format!(
713            "Image manifest path must be relative: {:?}",
714            path
715        )));
716    }
717    let mut normalized = PathBuf::new();
718    for component in path.components() {
719        match component {
720            Component::Normal(part) => normalized.push(part),
721            Component::CurDir => {}
722            Component::RootDir | Component::ParentDir | Component::Prefix(_) => {
723                return Err(image_error(format!(
724                    "Invalid image manifest path component in {:?}",
725                    path
726                )));
727            }
728        }
729    }
730    if normalized.as_os_str().is_empty() {
731        return Err(image_error(
732            "Image manifest path cannot be empty".to_string(),
733        ));
734    }
735    Ok(normalized)
736}
737
738fn hash_file(path: &Path) -> Result<String> {
739    let mut file = OpenOptions::new()
740        .read(true)
741        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
742        .open(path)
743        .map_err(|e| image_error(format!("Failed to open file {:?}: {}", path, e)))?;
744    let mut hasher = Sha256::new();
745    let mut buf = [0u8; 8192];
746    loop {
747        let read = file
748            .read(&mut buf)
749            .map_err(|e| image_error(format!("Failed to read file {:?}: {}", path, e)))?;
750        if read == 0 {
751            break;
752        }
753        hasher.update(&buf[..read]);
754    }
755    Ok(hex::encode(hasher.finalize()))
756}
757
758fn prepare_image_dir(path: &Path) -> Result<()> {
759    reject_symlink_path(path, "image directory")?;
760    if path.exists() {
761        if !path.is_dir() {
762            return Err(image_error(format!(
763                "Image output {:?} is not a directory",
764                path
765            )));
766        }
767    } else {
768        fs::create_dir_all(path)
769            .map_err(|e| image_error(format!("Failed to create image dir {:?}: {}", path, e)))?;
770    }
771    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
772        .map_err(|e| image_error(format!("Failed to secure image dir {:?}: {}", path, e)))
773}
774
775fn prepare_empty_dir(path: &Path, label: &str) -> Result<()> {
776    reject_symlink_path(path, label)?;
777    if path.exists() {
778        if !path.is_dir() {
779            return Err(image_error(format!(
780                "{} {:?} is not a directory",
781                label, path
782            )));
783        }
784        for entry in fs::read_dir(path)
785            .map_err(|e| image_error(format!("Failed to read {} {:?}: {}", label, path, e)))?
786        {
787            let entry = entry.map_err(|e| {
788                image_error(format!("Failed to enumerate {} {:?}: {}", label, path, e))
789            })?;
790            let entry_path = entry.path();
791            let metadata = fs::symlink_metadata(&entry_path).map_err(|e| {
792                image_error(format!(
793                    "Failed to stat {} entry {:?}: {}",
794                    label, entry_path, e
795                ))
796            })?;
797            if metadata.is_dir() && !metadata.file_type().is_symlink() {
798                fs::remove_dir_all(&entry_path).map_err(|e| {
799                    image_error(format!(
800                        "Failed to remove stale dir {:?}: {}",
801                        entry_path, e
802                    ))
803                })?;
804            } else {
805                fs::remove_file(&entry_path).map_err(|e| {
806                    image_error(format!(
807                        "Failed to remove stale file {:?}: {}",
808                        entry_path, e
809                    ))
810                })?;
811            }
812        }
813    } else {
814        fs::create_dir(path)
815            .map_err(|e| image_error(format!("Failed to create {} {:?}: {}", label, path, e)))?;
816    }
817    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
818        .map_err(|e| image_error(format!("Failed to secure {} {:?}: {}", label, path, e)))
819}
820
821fn ensure_real_directory(path: &Path, label: &str) -> Result<()> {
822    reject_symlink_path(path, label)?;
823    let metadata = fs::metadata(path)
824        .map_err(|e| image_error(format!("Failed to stat {} {:?}: {}", label, path, e)))?;
825    if !metadata.is_dir() {
826        return Err(image_error(format!(
827            "{} {:?} is not a directory",
828            label, path
829        )));
830    }
831    Ok(())
832}
833
834fn atomic_write_json<T: Serialize>(dir: &Path, name: &str, value: &T) -> Result<()> {
835    let json = serde_json::to_vec_pretty(value)?;
836    atomic_write_bytes(&dir.join(name), &json, 0o600)
837}
838
839fn atomic_write_bytes(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
840    let parent = path
841        .parent()
842        .ok_or_else(|| image_error(format!("Path {:?} has no parent", path)))?;
843    let file_name = path
844        .file_name()
845        .ok_or_else(|| image_error(format!("Path {:?} has no file name", path)))?;
846    let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy()));
847    match fs::symlink_metadata(&tmp_path) {
848        Ok(meta) if meta.file_type().is_symlink() => {
849            return Err(image_error(format!(
850                "Refusing symlink temp image file {:?}",
851                tmp_path
852            )));
853        }
854        Ok(_) => fs::remove_file(&tmp_path).map_err(|e| {
855            image_error(format!("Failed to remove temp file {:?}: {}", tmp_path, e))
856        })?,
857        Err(_) => {}
858    }
859
860    let mut file = OpenOptions::new()
861        .create_new(true)
862        .write(true)
863        .mode(mode)
864        .custom_flags(libc::O_NOFOLLOW)
865        .open(&tmp_path)
866        .map_err(|e| image_error(format!("Failed to open temp file {:?}: {}", tmp_path, e)))?;
867    file.write_all(bytes)
868        .map_err(|e| image_error(format!("Failed to write temp file {:?}: {}", tmp_path, e)))?;
869    file.sync_all()
870        .map_err(|e| image_error(format!("Failed to sync temp file {:?}: {}", tmp_path, e)))?;
871    fs::rename(&tmp_path, path).map_err(|e| {
872        image_error(format!(
873            "Failed to atomically replace image file {:?}: {}",
874            path, e
875        ))
876    })
877}
878
879fn write_image_hmac(dir: &Path) -> Result<()> {
880    let key = load_or_create_image_hmac_key()?;
881    let digest = compute_image_hmac(dir, &key)?;
882    atomic_write_bytes(&dir.join(IMAGE_SIGNATURE_FILE), digest.as_bytes(), 0o600)
883}
884
885fn verify_image_hmac(dir: &Path) -> Result<()> {
886    let sig_path = dir.join(IMAGE_SIGNATURE_FILE);
887    let expected = read_file_nofollow_bytes(&sig_path).map_err(|e| {
888        image_error(format!(
889            "Failed to read image signature {:?}: {}",
890            sig_path, e
891        ))
892    })?;
893    let expected = std::str::from_utf8(&expected)
894        .map_err(|e| image_error(format!("Image signature is not UTF-8: {}", e)))?
895        .trim()
896        .to_string();
897    if expected.is_empty() {
898        return Err(image_error("Image signature is empty".to_string()));
899    }
900    let key = load_or_create_image_hmac_key()?;
901    let actual = compute_image_hmac(dir, &key)?;
902    if expected != actual {
903        return Err(image_error(format!(
904            "Image integrity verification failed: HMAC mismatch (expected {}, got {})",
905            expected, actual
906        )));
907    }
908    Ok(())
909}
910
911fn compute_image_hmac(dir: &Path, key: &[u8]) -> Result<String> {
912    let mut key_block = [0u8; 64];
913    if key.len() > key_block.len() {
914        let digest = Sha256::digest(key);
915        key_block[..digest.len()].copy_from_slice(&digest);
916    } else {
917        key_block[..key.len()].copy_from_slice(key);
918    }
919
920    let mut ipad = [0x36u8; 64];
921    let mut opad = [0x5cu8; 64];
922    for (dst, src) in ipad.iter_mut().zip(key_block.iter()) {
923        *dst ^= *src;
924    }
925    for (dst, src) in opad.iter_mut().zip(key_block.iter()) {
926        *dst ^= *src;
927    }
928
929    let mut inner = Sha256::new();
930    inner.update(ipad);
931    update_image_hmac_inner(&mut inner, dir, dir)?;
932    let inner_hash = inner.finalize();
933
934    let mut outer = Sha256::new();
935    outer.update(opad);
936    outer.update(inner_hash);
937    Ok(hex::encode(outer.finalize()))
938}
939
940fn update_image_hmac_inner(hasher: &mut Sha256, root: &Path, dir: &Path) -> Result<()> {
941    let mut entries = fs::read_dir(dir)
942        .map_err(|e| image_error(format!("Failed to read image directory {:?}: {}", dir, e)))?
943        .collect::<std::result::Result<Vec<_>, _>>()
944        .map_err(|e| image_error(format!("Failed to enumerate image directory: {}", e)))?;
945    entries.sort_by_key(|entry| entry.path());
946
947    for entry in entries {
948        let path = entry.path();
949        let relative = path
950            .strip_prefix(root)
951            .map_err(|e| image_error(format!("Failed to compute image relative path: {}", e)))?;
952        if relative == Path::new(IMAGE_SIGNATURE_FILE) {
953            continue;
954        }
955        let relative = path_to_string(relative)?;
956        let metadata = fs::symlink_metadata(&path)
957            .map_err(|e| image_error(format!("Failed to stat image path {:?}: {}", path, e)))?;
958        if metadata.file_type().is_symlink() {
959            let target = fs::read_link(&path)
960                .map_err(|e| image_error(format!("Failed to read symlink {:?}: {}", path, e)))?;
961            hasher.update(b"L\0");
962            hasher.update(relative.as_bytes());
963            hasher.update(b"\0");
964            update_metadata_hmac(hasher, &path, &metadata, false)?;
965            hasher.update(target.as_os_str().as_encoded_bytes());
966            hasher.update(b"\0");
967        } else if metadata.is_dir() {
968            hasher.update(b"D\0");
969            hasher.update(relative.as_bytes());
970            hasher.update(b"\0");
971            update_metadata_hmac(hasher, &path, &metadata, true)?;
972            update_image_hmac_inner(hasher, root, &path)?;
973        } else if metadata.is_file() {
974            hasher.update(b"F\0");
975            hasher.update(relative.as_bytes());
976            hasher.update(b"\0");
977            update_metadata_hmac(hasher, &path, &metadata, true)?;
978            hasher.update(metadata.len().to_le_bytes());
979            let mut file = OpenOptions::new()
980                .read(true)
981                .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
982                .open(&path)
983                .map_err(|e| image_error(format!("Failed to open image file {:?}: {}", path, e)))?;
984            let mut buf = [0u8; 8192];
985            loop {
986                let read = file.read(&mut buf).map_err(|e| {
987                    image_error(format!("Failed to read image file {:?}: {}", path, e))
988                })?;
989                if read == 0 {
990                    break;
991                }
992                hasher.update(&buf[..read]);
993            }
994        } else {
995            return Err(image_error(format!(
996                "Image integrity scan rejects special file {:?}",
997                path
998            )));
999        }
1000    }
1001    Ok(())
1002}
1003
1004fn update_metadata_hmac(
1005    hasher: &mut Sha256,
1006    path: &Path,
1007    metadata: &fs::Metadata,
1008    follow: bool,
1009) -> Result<()> {
1010    hasher.update(metadata.uid().to_le_bytes());
1011    hasher.update(metadata.gid().to_le_bytes());
1012    hasher.update(metadata.permissions().mode().to_le_bytes());
1013    hasher.update(metadata.mtime().to_le_bytes());
1014    hasher.update(metadata.mtime_nsec().to_le_bytes());
1015
1016    let mut xattrs = Vec::new();
1017    for name in list_xattrs(path, follow)? {
1018        if let Some(value) = get_xattr(path, &name, follow)? {
1019            xattrs.push((name, value));
1020        }
1021    }
1022    xattrs.sort_by(|(a, _), (b, _)| a.cmp(b));
1023    for (name, value) in xattrs {
1024        hasher.update(b"X\0");
1025        hasher.update((name.len() as u64).to_le_bytes());
1026        hasher.update(&name);
1027        hasher.update((value.len() as u64).to_le_bytes());
1028        hasher.update(&value);
1029    }
1030
1031    Ok(())
1032}
1033
1034fn image_hmac_key_path() -> PathBuf {
1035    if let Some(path) = std::env::var_os("NUCLEUS_IMAGE_HMAC_KEY_FILE").filter(|p| !p.is_empty()) {
1036        return PathBuf::from(path);
1037    }
1038    if Uid::effective().is_root() {
1039        PathBuf::from("/var/lib/nucleus/image-hmac.key")
1040    } else {
1041        dirs::data_local_dir()
1042            .map(|dir| dir.join("nucleus/image-hmac.key"))
1043            .or_else(|| dirs::home_dir().map(|dir| dir.join(".nucleus/image-hmac.key")))
1044            .unwrap_or_else(|| PathBuf::from("/tmp/nucleus-image-hmac.key"))
1045    }
1046}
1047
1048fn load_or_create_image_hmac_key() -> Result<Vec<u8>> {
1049    let key_path = image_hmac_key_path();
1050    let parent = key_path
1051        .parent()
1052        .ok_or_else(|| image_error(format!("Image HMAC key path {:?} has no parent", key_path)))?;
1053    ensure_secure_key_parent_dir(parent)?;
1054    reject_symlink_path(&key_path, "image HMAC key file")?;
1055
1056    if key_path.exists() {
1057        let metadata = fs::metadata(&key_path)
1058            .map_err(|e| image_error(format!("Failed to stat image HMAC key: {}", e)))?;
1059        let mode = metadata.permissions().mode() & 0o777;
1060        let owner = metadata.uid();
1061        let euid = Uid::effective().as_raw();
1062        if owner != euid {
1063            return Err(image_error(format!(
1064                "Image HMAC key {:?} is owned by uid {} (expected {})",
1065                key_path, owner, euid
1066            )));
1067        }
1068        if mode & 0o077 != 0 {
1069            return Err(image_error(format!(
1070                "Image HMAC key {:?} has insecure mode {:o}; expected owner-only access",
1071                key_path, mode
1072            )));
1073        }
1074        let key = read_file_nofollow_bytes(&key_path)
1075            .map_err(|e| image_error(format!("Failed to read image HMAC key: {}", e)))?;
1076        if key.len() < IMAGE_HMAC_KEY_SIZE {
1077            return Err(image_error(format!(
1078                "Image HMAC key {:?} is too short ({} bytes)",
1079                key_path,
1080                key.len()
1081            )));
1082        }
1083        return Ok(key);
1084    }
1085
1086    let mut key = vec![0u8; IMAGE_HMAC_KEY_SIZE];
1087    fill_secure_random(&mut key)?;
1088    let mut file = OpenOptions::new()
1089        .create_new(true)
1090        .write(true)
1091        .mode(0o600)
1092        .custom_flags(libc::O_NOFOLLOW)
1093        .open(&key_path)
1094        .map_err(|e| {
1095            image_error(format!(
1096                "Failed to create image HMAC key {:?}: {}",
1097                key_path, e
1098            ))
1099        })?;
1100    file.write_all(&key).map_err(|e| {
1101        image_error(format!(
1102            "Failed to write image HMAC key {:?}: {}",
1103            key_path, e
1104        ))
1105    })?;
1106    file.sync_all().map_err(|e| {
1107        image_error(format!(
1108            "Failed to sync image HMAC key {:?}: {}",
1109            key_path, e
1110        ))
1111    })?;
1112    Ok(key)
1113}
1114
1115fn ensure_secure_key_parent_dir(path: &Path) -> Result<()> {
1116    reject_symlink_path(path, "image HMAC key directory")?;
1117    if path.exists() {
1118        let metadata = fs::metadata(path)
1119            .map_err(|e| image_error(format!("Failed to stat image HMAC key dir: {}", e)))?;
1120        if !metadata.is_dir() {
1121            return Err(image_error(format!(
1122                "Image HMAC key directory {:?} is not a directory",
1123                path
1124            )));
1125        }
1126        let mode = metadata.permissions().mode() & 0o777;
1127        let owner = metadata.uid();
1128        let euid = Uid::effective().as_raw();
1129        if owner != euid {
1130            return Err(image_error(format!(
1131                "Image HMAC key directory {:?} is owned by uid {} (expected {})",
1132                path, owner, euid
1133            )));
1134        }
1135        if mode & 0o077 != 0 {
1136            return Err(image_error(format!(
1137                "Image HMAC key directory {:?} has insecure mode {:o}; expected owner-only access",
1138                path, mode
1139            )));
1140        }
1141        return Ok(());
1142    }
1143    fs::create_dir_all(path)
1144        .map_err(|e| image_error(format!("Failed to create image HMAC key dir: {}", e)))?;
1145    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1146        .map_err(|e| image_error(format!("Failed to secure image HMAC key dir: {}", e)))
1147}
1148
1149fn fill_secure_random(buf: &mut [u8]) -> Result<()> {
1150    let mut file = OpenOptions::new()
1151        .read(true)
1152        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
1153        .open("/dev/urandom")
1154        .map_err(|e| image_error(format!("Failed to open /dev/urandom: {}", e)))?;
1155    let metadata = file
1156        .metadata()
1157        .map_err(|e| image_error(format!("Failed to stat /dev/urandom: {}", e)))?;
1158    if !metadata.file_type().is_char_device() {
1159        return Err(image_error(
1160            "/dev/urandom is not a character device".to_string(),
1161        ));
1162    }
1163    file.read_exact(buf)
1164        .map_err(|e| image_error(format!("Failed to read /dev/urandom: {}", e)))
1165}
1166
1167fn read_file_nofollow_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
1168    let mut file = OpenOptions::new()
1169        .read(true)
1170        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
1171        .open(path)?;
1172    let mut content = Vec::new();
1173    file.read_to_end(&mut content)?;
1174    Ok(content)
1175}
1176
1177fn reject_symlink_path(path: &Path, label: &str) -> Result<()> {
1178    match fs::symlink_metadata(path) {
1179        Ok(metadata) if metadata.file_type().is_symlink() => Err(image_error(format!(
1180            "Refusing symlink {} {:?}",
1181            label, path
1182        ))),
1183        Ok(_) | Err(_) => Ok(()),
1184    }
1185}
1186
1187fn image_error(message: String) -> NucleusError {
1188    NucleusError::ConfigError(format!("Image error: {}", message))
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use crate::container::{ContainerState, ContainerStateParams};
1195    use std::ffi::OsString;
1196    use std::os::unix::fs::symlink;
1197    use std::sync::{Mutex, OnceLock};
1198    use tempfile::TempDir;
1199
1200    fn image_key_env_lock() -> &'static Mutex<()> {
1201        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1202        LOCK.get_or_init(|| Mutex::new(()))
1203    }
1204
1205    struct ImageKeyEnvGuard {
1206        previous: Option<OsString>,
1207    }
1208
1209    impl ImageKeyEnvGuard {
1210        fn set(path: &Path) -> Self {
1211            let previous = std::env::var_os("NUCLEUS_IMAGE_HMAC_KEY_FILE");
1212            std::env::set_var("NUCLEUS_IMAGE_HMAC_KEY_FILE", path);
1213            Self { previous }
1214        }
1215    }
1216
1217    impl Drop for ImageKeyEnvGuard {
1218        fn drop(&mut self) {
1219            match &self.previous {
1220                Some(value) => std::env::set_var("NUCLEUS_IMAGE_HMAC_KEY_FILE", value),
1221                None => std::env::remove_var("NUCLEUS_IMAGE_HMAC_KEY_FILE"),
1222            }
1223        }
1224    }
1225
1226    fn sample_rootfs(dir: &Path) -> PathBuf {
1227        let rootfs = dir.join("rootfs");
1228        fs::create_dir(&rootfs).unwrap();
1229        fs::create_dir(rootfs.join("bin")).unwrap();
1230        fs::write(rootfs.join("bin/app"), "app").unwrap();
1231        fs::write(
1232            rootfs.join(ROOTFS_ATTESTATION_FILE),
1233            format!("{}\tbin/app\n", hash_file(&rootfs.join("bin/app")).unwrap()),
1234        )
1235        .unwrap();
1236        fs::write(
1237            rootfs.join(ROOTFS_STORE_PATHS_FILE),
1238            "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-coreutils\n",
1239        )
1240        .unwrap();
1241        rootfs
1242    }
1243
1244    fn sample_state(rootfs: &Path, upper: &Path) -> ContainerState {
1245        let mut state = ContainerState::new(ContainerStateParams {
1246            id: "abc123def456".to_string(),
1247            name: "worker".to_string(),
1248            pid: 123,
1249            command: vec!["/bin/app".to_string()],
1250            memory_limit: None,
1251            cpu_limit: None,
1252            using_gvisor: false,
1253            rootless: false,
1254            cgroup_path: None,
1255            process_uid: 1000,
1256            process_gid: 1000,
1257            additional_gids: vec![27],
1258        });
1259        state
1260            .environment
1261            .insert("APP_ENV".to_string(), "snapshot".to_string());
1262        state.workdir = "/srv/app".to_string();
1263        state.rootfs_path = Some(rootfs.display().to_string());
1264        state.rootfs_mode = RootfsMode::Overlay;
1265        state.rootfs_upperdir = Some(upper.display().to_string());
1266        state.rootfs_workdir = Some(upper.parent().unwrap().join("work").display().to_string());
1267        state
1268    }
1269
1270    #[test]
1271    fn test_manifest_image_id_roundtrips() {
1272        let base = ImageBase {
1273            rootfs_path: "/nix/store/rootfs".to_string(),
1274            store_paths: Vec::new(),
1275            attestation: DirectoryManifest::new(),
1276        };
1277        let config = ImageConfig {
1278            command: vec!["/bin/true".to_string()],
1279            env: BTreeMap::new(),
1280            workdir: "/workspace".to_string(),
1281            uid: 0,
1282            gid: 0,
1283            additional_gids: Vec::new(),
1284        };
1285        let manifest = NucleusImageManifest::new(base, None, config).unwrap();
1286        assert_eq!(manifest.compute_image_id().unwrap(), manifest.image_id);
1287        manifest.validate_identity().unwrap();
1288    }
1289
1290    #[test]
1291    fn test_manifest_detects_tampered_image_id() {
1292        let base = ImageBase {
1293            rootfs_path: "/nix/store/rootfs".to_string(),
1294            store_paths: Vec::new(),
1295            attestation: DirectoryManifest::new(),
1296        };
1297        let config = ImageConfig {
1298            command: vec!["/bin/true".to_string()],
1299            env: BTreeMap::new(),
1300            workdir: "/workspace".to_string(),
1301            uid: 0,
1302            gid: 0,
1303            additional_gids: Vec::new(),
1304        };
1305        let mut manifest = NucleusImageManifest::new(base, None, config).unwrap();
1306        manifest.config.command = vec!["/bin/false".to_string()];
1307        assert!(manifest.validate_identity().is_err());
1308    }
1309
1310    #[test]
1311    fn test_commit_rejects_non_overlay_state() {
1312        let temp = TempDir::new().unwrap();
1313        let rootfs = sample_rootfs(temp.path());
1314        let upper = temp.path().join("upper");
1315        fs::create_dir(&upper).unwrap();
1316        let mut state = sample_state(&rootfs, &upper);
1317        state.rootfs_mode = RootfsMode::Bind;
1318
1319        let err = commit_container_image(
1320            &state,
1321            &temp.path().join("image"),
1322            &ImageCommitOptions::default(),
1323        )
1324        .unwrap_err();
1325        assert!(err.to_string().contains("requires overlay"));
1326    }
1327
1328    #[test]
1329    fn test_commit_writes_and_verifies_cold_thin_image() {
1330        let _lock = image_key_env_lock().lock().unwrap();
1331        let temp = TempDir::new().unwrap();
1332        let key_dir = temp.path().join("keys");
1333        fs::create_dir(&key_dir).unwrap();
1334        fs::set_permissions(&key_dir, fs::Permissions::from_mode(0o700)).unwrap();
1335        let _guard = ImageKeyEnvGuard::set(&key_dir.join("image.key"));
1336
1337        let rootfs = sample_rootfs(temp.path());
1338        let upper = temp.path().join("upper");
1339        fs::create_dir(&upper).unwrap();
1340        fs::create_dir(upper.join("etc")).unwrap();
1341        fs::write(upper.join("etc/config"), "changed").unwrap();
1342        fs::set_permissions(upper.join("etc/config"), fs::Permissions::from_mode(0o754)).unwrap();
1343        let xattr_supported = set_xattr(
1344            &upper.join("etc/config"),
1345            b"user.nucleus.test",
1346            b"preserved",
1347            true,
1348        )
1349        .is_ok();
1350        fs::create_dir(upper.join("dev")).unwrap();
1351        fs::write(upper.join("dev/runtime-node"), "skip").unwrap();
1352        symlink("config", upper.join("etc/config-link")).unwrap();
1353
1354        let image_dir = temp.path().join("image");
1355        let manifest = commit_container_image(
1356            &sample_state(&rootfs, &upper),
1357            &image_dir,
1358            &ImageCommitOptions::default(),
1359        )
1360        .unwrap();
1361
1362        assert_eq!(manifest.schema_version, IMAGE_SCHEMA_VERSION);
1363        assert_eq!(manifest.config.env["APP_ENV"], "snapshot");
1364        assert_eq!(manifest.config.workdir, "/srv/app");
1365        assert!(image_dir.join(IMAGE_MANIFEST_FILE).exists());
1366        assert!(image_dir.join(IMAGE_SIGNATURE_FILE).exists());
1367        assert!(image_dir.join(IMAGE_ROOTFS_ATTESTATION_FILE).exists());
1368        assert!(image_dir.join(IMAGE_STORE_PATHS_FILE).exists());
1369        assert!(image_dir.join("diff/etc/config").exists());
1370        assert!(!image_dir.join("diff/dev/runtime-node").exists());
1371        let source_meta = fs::symlink_metadata(upper.join("etc/config")).unwrap();
1372        let copied_meta = fs::symlink_metadata(image_dir.join("diff/etc/config")).unwrap();
1373        assert_eq!(
1374            copied_meta.permissions().mode() & 0o7777,
1375            source_meta.permissions().mode() & 0o7777
1376        );
1377        assert_eq!(copied_meta.uid(), source_meta.uid());
1378        assert_eq!(copied_meta.gid(), source_meta.gid());
1379        assert_eq!(copied_meta.mtime(), source_meta.mtime());
1380        assert_eq!(copied_meta.mtime_nsec(), source_meta.mtime_nsec());
1381        if xattr_supported {
1382            assert_eq!(
1383                get_xattr(
1384                    &image_dir.join("diff/etc/config"),
1385                    b"user.nucleus.test",
1386                    true
1387                )
1388                .unwrap()
1389                .as_deref(),
1390                Some(&b"preserved"[..])
1391            );
1392        }
1393
1394        let loaded = load_image(&image_dir).unwrap();
1395        assert_eq!(loaded.image_id, manifest.image_id);
1396        assert!(loaded
1397            .diff
1398            .unwrap()
1399            .manifest
1400            .contains_key("etc/config-link"));
1401    }
1402
1403    #[test]
1404    fn test_image_hmac_detects_tampering() {
1405        let _lock = image_key_env_lock().lock().unwrap();
1406        let temp = TempDir::new().unwrap();
1407        let key_dir = temp.path().join("keys");
1408        fs::create_dir(&key_dir).unwrap();
1409        fs::set_permissions(&key_dir, fs::Permissions::from_mode(0o700)).unwrap();
1410        let _guard = ImageKeyEnvGuard::set(&key_dir.join("image.key"));
1411
1412        let rootfs = sample_rootfs(temp.path());
1413        let upper = temp.path().join("upper");
1414        fs::create_dir(&upper).unwrap();
1415        fs::write(upper.join("file"), "one").unwrap();
1416        let image_dir = temp.path().join("image");
1417        commit_container_image(
1418            &sample_state(&rootfs, &upper),
1419            &image_dir,
1420            &ImageCommitOptions::default(),
1421        )
1422        .unwrap();
1423
1424        fs::write(image_dir.join("diff/file"), "two").unwrap();
1425        let err = load_image(&image_dir).unwrap_err();
1426        assert!(err.to_string().contains("HMAC mismatch"));
1427    }
1428
1429    #[test]
1430    fn test_image_hmac_detects_metadata_tampering() {
1431        let _lock = image_key_env_lock().lock().unwrap();
1432        let temp = TempDir::new().unwrap();
1433        let key_dir = temp.path().join("keys");
1434        fs::create_dir(&key_dir).unwrap();
1435        fs::set_permissions(&key_dir, fs::Permissions::from_mode(0o700)).unwrap();
1436        let _guard = ImageKeyEnvGuard::set(&key_dir.join("image.key"));
1437
1438        let rootfs = sample_rootfs(temp.path());
1439        let upper = temp.path().join("upper");
1440        fs::create_dir(&upper).unwrap();
1441        fs::write(upper.join("file"), "one").unwrap();
1442        let image_dir = temp.path().join("image");
1443        commit_container_image(
1444            &sample_state(&rootfs, &upper),
1445            &image_dir,
1446            &ImageCommitOptions::default(),
1447        )
1448        .unwrap();
1449
1450        fs::set_permissions(
1451            image_dir.join("diff/file"),
1452            fs::Permissions::from_mode(0o600),
1453        )
1454        .unwrap();
1455        let err = load_image(&image_dir).unwrap_err();
1456        assert!(err.to_string().contains("HMAC mismatch"));
1457    }
1458
1459    #[test]
1460    fn test_whiteout_replay_rejects_manifest_path_escape() {
1461        let temp = TempDir::new().unwrap();
1462        let upper = temp.path().join("upper");
1463        fs::create_dir(&upper).unwrap();
1464
1465        let err = replay_deleted_paths(&upper, &["../escape".to_string()]).unwrap_err();
1466        assert!(err.to_string().contains("Invalid image manifest path"));
1467    }
1468
1469    // Cross-cutting regression: a credential-brokered sandbox must not bake
1470    // its broker endpoint or per-container identity env into a committed image
1471    // manifest. Those values are host/container specific; baking them in would
1472    // make the image non-portable and would leak per-container tokens. The
1473    // contract is that `config.environment` is the capture surface for image
1474    // commit, while `config.derived_environment` carries launch-derived values
1475    // that reach the workload but stay out of committed artifacts.
1476    #[test]
1477    fn test_image_commit_excludes_credential_broker_derived_env() {
1478        use crate::container::ContainerConfig;
1479        use crate::network::{BridgeConfig, CredentialBrokerConfig, NatBackend, NetworkMode};
1480
1481        let broker = CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1482        let mut config = ContainerConfig::try_new_with_id(
1483            Some("0123456789abcdef0123456789abcdef".to_string()),
1484            None,
1485            vec!["/bin/sh".to_string()],
1486        )
1487        .unwrap()
1488        .with_network(NetworkMode::Bridge(
1489            BridgeConfig::default().with_nat_backend(NatBackend::Kernel),
1490        ))
1491        // User env is the capture surface; it must round-trip into the image.
1492        .with_env("APP_PORT".to_string(), "8080".to_string())
1493        // `with_credential_broker` already routes identity env through
1494        // `derived_environment`. Mimic the CLI's broker-proxy-env step here
1495        // to assert the same derived path is used for proxy env.
1496        .with_credential_broker(broker.clone())
1497        .with_egress_policy(broker.egress_policy());
1498        for (key, value) in broker.proxy_environment() {
1499            config = config.with_derived_env(key, value);
1500        }
1501
1502        // Sanity: user env stays clean of every broker-derived var.
1503        let broker_keys = [
1504            "HTTP_PROXY",
1505            "HTTPS_PROXY",
1506            "http_proxy",
1507            "https_proxy",
1508            "NUCLEUS_CONTAINER_ID",
1509            "NUCLEUS_CREDENTIAL_BROKER_TOKEN",
1510        ];
1511        for key in broker_keys {
1512            assert!(
1513                !config.environment.iter().any(|(k, _)| k == key),
1514                "broker-derived env `{key}` leaked into user environment"
1515            );
1516        }
1517        // And the broker vars land in derived env where they belong.
1518        assert!(config
1519            .derived_environment
1520            .iter()
1521            .any(|(k, _)| k == "HTTPS_PROXY"));
1522        assert!(config
1523            .derived_environment
1524            .iter()
1525            .any(|(k, _)| k == "NUCLEUS_CONTAINER_ID"));
1526        assert!(config
1527            .derived_environment
1528            .iter()
1529            .any(|(k, _)| k == "NUCLEUS_CREDENTIAL_BROKER_TOKEN"));
1530
1531        // Mirror what the runtime captures into ContainerState. Only user env
1532        // is captured; derived env is intentionally dropped here.
1533        let mut state = ContainerState::new(ContainerStateParams {
1534            id: config.id.clone(),
1535            name: config.name.clone(),
1536            pid: 123,
1537            command: config.command.clone(),
1538            memory_limit: None,
1539            cpu_limit: None,
1540            using_gvisor: false,
1541            rootless: false,
1542            cgroup_path: None,
1543            process_uid: config.process_identity.uid,
1544            process_gid: config.process_identity.gid,
1545            additional_gids: config.process_identity.additional_gids.clone(),
1546        });
1547        state.environment = config.environment.iter().cloned().collect();
1548        state.workdir = config.workdir.display().to_string();
1549
1550        let temp = TempDir::new().unwrap();
1551        let rootfs = sample_rootfs(temp.path());
1552        let upper = temp.path().join("upper");
1553        fs::create_dir(&upper).unwrap();
1554        state.rootfs_path = Some(rootfs.display().to_string());
1555        state.rootfs_mode = RootfsMode::Overlay;
1556        state.rootfs_upperdir = Some(upper.display().to_string());
1557        state.rootfs_workdir = Some(upper.parent().unwrap().join("work").display().to_string());
1558
1559        let image_dir = temp.path().join("image");
1560        let manifest =
1561            commit_container_image(&state, &image_dir, &ImageCommitOptions::default()).unwrap();
1562
1563        // User env survives the round trip.
1564        assert_eq!(
1565            manifest.config.env.get("APP_PORT").map(String::as_str),
1566            Some("8080"),
1567            "user-supplied env must survive image commit"
1568        );
1569        // Broker-derived env must not.
1570        for key in broker_keys {
1571            assert!(
1572                !manifest.config.env.contains_key(key),
1573                "broker-derived env `{key}` was baked into the committed image"
1574            );
1575        }
1576    }
1577}