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 #[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 .with_env("APP_PORT".to_string(), "8080".to_string())
1493 .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 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 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 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 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 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}