1use crate::{
4 error::{Error, Result, io},
5 graph::Digest,
6 hash::sha256_file,
7 oci::ResolvedImageConfig,
8 plan::{BundlePlan, InclusionReason, PlannedFileKind},
9};
10use serde::{Deserialize, Serialize};
11use std::{
12 io::Write,
13 path::{Path, PathBuf},
14};
15
16pub const MANIFEST_VERSION: u32 = 4;
17const MANIFEST_SHA256_VERSION: u32 = 2;
18pub const MANIFEST_NAME_DEFAULT: &str = "elfpak-manifest.json";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Manifest {
23 pub manifest_version: u32,
24 pub elfpak_version: String,
25 pub binary: String,
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub binaries: Vec<String>,
30 pub architecture: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub interpreter: Option<String>,
33 pub source_root: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub rootfs: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub tar: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub oci_layout: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub oci_archive: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub image: Option<ManifestImage>,
49 #[serde(default)]
52 pub policy: ManifestPolicy,
53 pub files: Vec<ManifestFile>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub warnings: Vec<String>,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ManifestPolicy {
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub preset: Option<String>,
62 pub ca_certificates: bool,
63 pub tmp: bool,
64 pub passwd_group: bool,
65 pub nsswitch: bool,
66 pub tzdata: bool,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub ld_so_cache: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub user: Option<String>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub includes: Vec<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub allow_libraries: Option<Vec<String>>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ManifestFile {
82 pub path: String,
83 pub kind: String,
84 pub reason: Reason,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub sha256: Option<String>,
87 pub size: u64,
88 pub mode: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub target: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ManifestImage {
96 pub tag: String,
97 pub os: String,
98 pub architecture: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub user: Option<String>,
101 pub entrypoint: Vec<String>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub cmd: Vec<String>,
104 pub working_dir: String,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub env: Vec<String>,
107 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
108 pub labels: std::collections::BTreeMap<String, String>,
109 pub manifest_digest: String,
110}
111
112impl ManifestImage {
113 pub fn from_oci(image: &ResolvedImageConfig, manifest_digest: &Digest) -> ManifestImage {
114 ManifestImage {
115 tag: image.tag().to_string(),
116 os: image.os().to_string(),
117 architecture: image.architecture().to_string(),
118 user: image.user().map(str::to_string),
119 entrypoint: image.entrypoint().to_vec(),
120 cmd: image.cmd().to_vec(),
121 working_dir: image.working_dir().to_string(),
122 env: image.env().to_vec(),
123 labels: image.labels().clone(),
124 manifest_digest: format!("sha256:{manifest_digest}"),
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, Default)]
130pub struct ManifestOutputs<'a> {
131 pub rootfs: Option<&'a Path>,
132 pub tar: Option<&'a Path>,
133 pub oci_layout: Option<&'a Path>,
134 pub oci_archive: Option<&'a Path>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum Reason {
140 Simple(String),
141 NeededBy { needed_by: String, soname: String },
142 RuntimePolicy { runtime_policy: String },
143}
144
145impl From<&InclusionReason> for Reason {
146 fn from(reason: &InclusionReason) -> Reason {
147 match reason {
148 InclusionReason::Application => Reason::Simple("application".to_string()),
149 InclusionReason::Interpreter => Reason::Simple("interpreter".to_string()),
150 InclusionReason::ExplicitInclude => Reason::Simple("include".to_string()),
151 InclusionReason::NeededBy { binary, soname } => Reason::NeededBy {
152 needed_by: binary.display().to_string(),
153 soname: soname.clone(),
154 },
155 InclusionReason::RuntimePolicy { feature } => Reason::RuntimePolicy {
156 runtime_policy: feature.as_str().to_string(),
157 },
158 }
159 }
160}
161
162impl Manifest {
163 pub fn from_plan(plan: &BundlePlan, source_root: &Path, rootfs: Option<&Path>) -> Manifest {
165 Manifest::from_plan_with_artifacts(
166 plan,
167 source_root,
168 ManifestOutputs {
169 rootfs,
170 ..ManifestOutputs::default()
171 },
172 None,
173 )
174 }
175
176 pub fn from_plan_with_outputs(
177 plan: &BundlePlan,
178 source_root: &Path,
179 rootfs: Option<&Path>,
180 tar: Option<&Path>,
181 ) -> Manifest {
182 Manifest::from_plan_with_artifacts(
183 plan,
184 source_root,
185 ManifestOutputs {
186 rootfs,
187 tar,
188 ..ManifestOutputs::default()
189 },
190 None,
191 )
192 }
193
194 pub fn from_plan_with_artifacts(
195 plan: &BundlePlan,
196 source_root: &Path,
197 outputs: ManifestOutputs<'_>,
198 image: Option<ManifestImage>,
199 ) -> Manifest {
200 let files: Vec<ManifestFile> = plan
201 .files
202 .iter()
203 .map(|file| ManifestFile {
204 path: file.destination.display().to_string(),
205 kind: file.kind.as_str().to_string(),
206 reason: Reason::from(&file.reason),
207 sha256: file.sha256.as_ref().map(|d| d.0.clone()),
208 size: file.size,
209 mode: format!("{:04o}", file.mode),
210 target: file.link_target.as_ref().map(|t| t.display().to_string()),
211 })
212 .collect();
213
214 Manifest {
215 manifest_version: MANIFEST_VERSION,
216 elfpak_version: env!("CARGO_PKG_VERSION").to_string(),
217 binary: plan.executable().destination.display().to_string(),
218 binaries: plan
219 .executables()
220 .map(|file| file.destination.display().to_string())
221 .collect(),
222 architecture: plan.architecture.machine.to_string(),
223 interpreter: plan.interpreter().map(|p| p.display().to_string()),
224 source_root: source_root.display().to_string(),
225 rootfs: outputs.rootfs.map(|p| p.display().to_string()),
226 tar: outputs.tar.map(|p| p.display().to_string()),
227 oci_layout: outputs.oci_layout.map(|p| p.display().to_string()),
228 oci_archive: outputs.oci_archive.map(|p| p.display().to_string()),
229 image,
230 policy: ManifestPolicy {
231 preset: plan.preset.map(|p| p.to_string()),
232 ca_certificates: plan.runtime_policy.ca_certificates,
233 tmp: plan.runtime_policy.tmp,
234 passwd_group: plan.runtime_policy.passwd_group,
235 nsswitch: plan.runtime_policy.nsswitch,
236 tzdata: plan.runtime_policy.tzdata,
237 ld_so_cache: Some(plan.runtime_policy.ld_so_cache.to_string()),
238 user: plan.runtime_policy.user.as_ref().map(|u| u.to_string()),
239 includes: plan
240 .runtime_policy
241 .includes
242 .iter()
243 .map(|p| p.display().to_string())
244 .collect(),
245 allow_libraries: plan.dependency_policy.allow.clone(),
246 },
247 files,
248 warnings: plan
249 .warnings
250 .iter()
251 .map(|w| format!("{}: {}", w.code, w.message))
252 .collect(),
253 }
254 }
255
256 pub fn to_json(&self) -> String {
257 serde_json::to_string_pretty(self).expect("a manifest is plain data")
258 }
259
260 pub fn write(&self, path: &Path) -> Result<()> {
261 let parent = path
262 .parent()
263 .filter(|parent| !parent.as_os_str().is_empty())
264 .unwrap_or_else(|| Path::new("."));
265 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
266 let mut json = self.to_json();
267 json.push('\n');
268 let mut stage = tempfile::Builder::new()
269 .prefix(".elfpak-manifest-")
270 .tempfile_in(parent)
271 .map_err(|e| io(parent, e))?;
272 set_output_permissions(stage.path(), path)?;
273 stage.write_all(json.as_bytes()).map_err(|e| io(path, e))?;
274 stage.as_file().sync_all().map_err(|e| io(path, e))?;
275 stage.persist(path).map_err(|e| io(path, e.error))?;
276 Ok(())
277 }
278
279 pub fn load(path: &Path) -> Result<Manifest> {
280 let bytes = std::fs::read(path).map_err(|e| io(path, e))?;
281 let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|e| Error::Manifest {
282 path: path.to_path_buf(),
283 message: e.to_string(),
284 })?;
285 manifest.validate(path)?;
286 Ok(manifest)
287 }
288
289 fn validate(&self, manifest_path: &Path) -> Result<()> {
293 if self.manifest_version == 0 || self.manifest_version > MANIFEST_VERSION {
294 return Err(invalid_manifest(
295 manifest_path,
296 format!("unsupported manifest version {}", self.manifest_version),
297 ));
298 }
299 let binaries = self.validate_binaries(manifest_path)?;
300 let mut paths = std::collections::HashSet::new();
301 for file in &self.files {
302 let path = Path::new(&file.path);
303 if !path.is_absolute()
304 || path != crate::paths::normalize_absolute(path)
305 || !paths.insert(path.to_path_buf())
306 {
307 return Err(invalid_manifest(
308 manifest_path,
309 format!("invalid or duplicate path `{}`", file.path),
310 ));
311 }
312 let mode = u32::from_str_radix(&file.mode, 8).ok();
313 if mode.is_none_or(|mode| mode > 0o7777) {
314 return Err(invalid_manifest(
315 manifest_path,
316 format!("invalid mode `{}` for `{}`", file.mode, file.path),
317 ));
318 }
319 match file.kind.as_str() {
320 "directory" if file.size == 0 && file.sha256.is_none() && file.target.is_none() => {
321 }
322 "symlink" if file.size == 0 && file.sha256.is_none() && file.target.is_some() => {}
323 "executable" | "interpreter" | "shared-object" | "certificate-bundle"
324 | "runtime-config" | "application-data"
325 if file.target.is_none()
326 && file.sha256.as_ref().is_some_and(|digest| {
327 self.manifest_version < MANIFEST_SHA256_VERSION || is_sha256(digest)
328 }) => {}
329 _ => {
330 return Err(invalid_manifest(
331 manifest_path,
332 format!("inconsistent entry `{}`", file.path),
333 ));
334 }
335 }
336 }
337 if self.manifest_version >= 3 {
338 let executables: std::collections::HashSet<PathBuf> = self
339 .files
340 .iter()
341 .filter(|file| file.kind == "executable")
342 .map(|file| crate::paths::normalize_absolute(Path::new(&file.path)))
343 .collect();
344 if binaries != executables {
345 return Err(invalid_manifest(
346 manifest_path,
347 "binaries must list every executable manifest entry exactly once".to_string(),
348 ));
349 }
350 }
351 self.validate_image(manifest_path)?;
352 Ok(())
353 }
354
355 fn validate_image(&self, manifest_path: &Path) -> Result<()> {
356 let has_oci_output = self.oci_layout.is_some() || self.oci_archive.is_some();
357 if self.manifest_version < 4 && (has_oci_output || self.image.is_some()) {
358 return Err(invalid_manifest(
359 manifest_path,
360 "OCI fields require manifest version 4".to_string(),
361 ));
362 }
363 if has_oci_output != self.image.is_some() {
364 return Err(invalid_manifest(
365 manifest_path,
366 "OCI destinations and image metadata must be recorded together".to_string(),
367 ));
368 }
369 if let Some(image) = &self.image {
370 let digest = image
371 .manifest_digest
372 .strip_prefix("sha256:")
373 .filter(|digest| is_sha256(digest));
374 if digest.is_none() {
375 return Err(invalid_manifest(
376 manifest_path,
377 "image manifest_digest must be sha256:<64 lowercase hex>".to_string(),
378 ));
379 }
380 }
381 Ok(())
382 }
383
384 fn validate_binaries(
385 &self,
386 manifest_path: &Path,
387 ) -> Result<std::collections::HashSet<PathBuf>> {
388 if self.manifest_version >= 3 && self.binaries.is_empty() {
389 return Err(invalid_manifest(
390 manifest_path,
391 "manifest version 3 or newer requires a non-empty binaries list".to_string(),
392 ));
393 }
394 let binaries: Vec<&str> = if self.binaries.is_empty() {
395 vec![self.binary.as_str()]
396 } else {
397 if self.binaries.first().map(String::as_str) != Some(self.binary.as_str()) {
398 return Err(invalid_manifest(
399 manifest_path,
400 "binary must be the first entry in binaries".to_string(),
401 ));
402 }
403 self.binaries.iter().map(String::as_str).collect()
404 };
405 let mut unique = std::collections::HashSet::new();
406 for binary in binaries {
407 let path = Path::new(binary);
408 if !path.is_absolute()
409 || path != crate::paths::normalize_absolute(path)
410 || !unique.insert(path.to_path_buf())
411 {
412 return Err(invalid_manifest(
413 manifest_path,
414 format!("invalid or duplicate binary path `{binary}`"),
415 ));
416 }
417 }
418 Ok(unique)
419 }
420
421 pub fn verify(&self, rootfs: &Path, options: &VerifyOptions) -> VerifyReport {
425 let mut report = VerifyReport::default();
426 match std::fs::symlink_metadata(rootfs) {
427 Ok(metadata) if metadata.is_symlink() => {
428 report.problems.push(Problem {
429 path: "/".to_string(),
430 detail: "verification root must not be a symlink".to_string(),
431 });
432 return report;
433 }
434 Ok(metadata) if !metadata.is_dir() => {
435 report.problems.push(Problem {
436 path: "/".to_string(),
437 detail: "verification root is not a directory".to_string(),
438 });
439 return report;
440 }
441 Ok(_) | Err(_) => {}
442 }
443 for file in &self.files {
444 report.checked += 1;
445 let target = crate::paths::join_under(rootfs, Path::new(&file.path));
446 assert!(target.starts_with(rootfs));
447
448 if has_symlinked_ancestor(rootfs, &target) {
449 report.problems.push(Problem {
450 path: file.path.clone(),
451 detail: "path traverses a symlinked directory inside the rootfs".to_string(),
452 });
453 continue;
454 }
455
456 let Ok(metadata) = std::fs::symlink_metadata(&target) else {
457 report.problems.push(Problem {
458 path: file.path.clone(),
459 detail: "missing".to_string(),
460 });
461 continue;
462 };
463
464 if let Some(problem) = verify_entry(file, &target, &metadata) {
465 report.problems.push(problem);
466 continue;
467 }
468
469 if options.strict
472 && file.kind != "symlink"
473 && let Some(problem) = mode_problem(file, &metadata)
474 {
475 report.problems.push(problem);
476 }
477 }
478
479 if options.strict {
480 self.report_unexpected(rootfs, &mut report);
481 }
482 report
483 }
484
485 fn report_unexpected(&self, rootfs: &Path, report: &mut VerifyReport) {
488 let expected: std::collections::HashSet<PathBuf> = self
489 .files
490 .iter()
491 .map(|f| crate::paths::normalize_absolute(Path::new(&f.path)))
492 .collect();
493
494 let mut stack = vec![rootfs.to_path_buf()];
495 while let Some(current) = stack.pop() {
496 assert!(current.starts_with(rootfs), "the walk stays in the rootfs");
497
498 let Ok(entries) = std::fs::read_dir(¤t) else {
499 continue;
500 };
501 let mut found: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
504 found.sort();
505
506 for path in found {
507 let Ok(relative) = path.strip_prefix(rootfs) else {
508 continue;
509 };
510 let logical = crate::paths::normalize_absolute(&Path::new("/").join(relative));
511 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
512 continue;
513 };
514 if metadata.is_dir() && !metadata.is_symlink() {
517 stack.push(path.clone());
518 }
519 if !expected.contains(&logical) {
520 report.unexpected += 1;
521 report.problems.push(Problem {
522 path: logical.display().to_string(),
523 detail: "present in the rootfs but not listed in the manifest".to_string(),
524 });
525 }
526 }
527 }
528 }
529
530 pub fn file_count(&self) -> usize {
532 self.files
533 .iter()
534 .filter(|f| f.kind != PlannedFileKind::Directory.as_str())
535 .count()
536 }
537}
538
539fn invalid_manifest(path: &Path, message: String) -> Error {
540 Error::Manifest {
541 path: path.to_path_buf(),
542 message,
543 }
544}
545
546fn is_sha256(digest: &str) -> bool {
547 digest.len() == 64
548 && digest
549 .bytes()
550 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
551}
552
553fn has_symlinked_ancestor(rootfs: &Path, target: &Path) -> bool {
556 let mut current = target.parent();
557 while let Some(path) = current {
558 if path == rootfs {
559 return false;
560 }
561 match std::fs::symlink_metadata(path) {
562 Ok(metadata) if metadata.is_symlink() => return true,
563 Ok(_) | Err(_) => {}
564 }
565 current = path.parent();
566 }
567 true
568}
569
570fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
571 use std::os::unix::fs::PermissionsExt;
572
573 let permissions = std::fs::metadata(destination)
574 .map(|metadata| metadata.permissions())
575 .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
576 std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
577}
578
579fn verify_entry(
582 file: &ManifestFile,
583 target: &Path,
584 metadata: &std::fs::Metadata,
585) -> Option<Problem> {
586 match file.kind.as_str() {
587 "directory" => (!metadata.is_dir()).then(|| Problem {
588 path: file.path.clone(),
589 detail: "expected a directory".to_string(),
590 }),
591 "symlink" => verify_symlink(file, target, metadata),
592 "executable" | "interpreter" | "shared-object" | "certificate-bundle"
593 | "runtime-config" | "application-data" => verify_regular(file, target, metadata),
594 _ => Some(Problem {
595 path: file.path.clone(),
596 detail: format!("unknown manifest entry kind `{}`", file.kind),
597 }),
598 }
599}
600
601fn verify_symlink(
604 file: &ManifestFile,
605 target: &Path,
606 metadata: &std::fs::Metadata,
607) -> Option<Problem> {
608 if !metadata.is_symlink() {
609 return Some(Problem {
610 path: file.path.clone(),
611 detail: "expected a symlink".to_string(),
612 });
613 }
614 let actual = std::fs::read_link(target).unwrap_or_default();
615 let expected = file.target.clone().unwrap_or_default();
616 if actual.as_os_str() == expected.as_str() {
617 return None;
618 }
619 Some(Problem {
620 path: file.path.clone(),
621 detail: format!(
622 "link target is `{}`, expected `{}`",
623 actual.display(),
624 expected
625 ),
626 })
627}
628
629fn verify_regular(
631 file: &ManifestFile,
632 target: &Path,
633 metadata: &std::fs::Metadata,
634) -> Option<Problem> {
635 if !metadata.is_file() {
636 return Some(Problem {
637 path: file.path.clone(),
638 detail: "expected a regular file".to_string(),
639 });
640 }
641 let Some(expected) = file.sha256.as_ref() else {
642 return Some(Problem {
643 path: file.path.clone(),
644 detail: "regular file has no sha256 digest".to_string(),
645 });
646 };
647 match sha256_file(target) {
648 Ok((actual, size)) if &actual.0 == expected && size == file.size => None,
649 Ok((_actual, size)) if size != file.size => Some(Problem {
650 path: file.path.clone(),
651 detail: format!("size is {size} bytes, expected {}", file.size),
652 }),
653 Ok((actual, _)) => Some(Problem {
654 path: file.path.clone(),
655 detail: format!("sha256 mismatch (found {}, expected {expected})", actual.0),
656 }),
657 Err(e) => Some(Problem {
658 path: file.path.clone(),
659 detail: format!("unreadable: {e}"),
660 }),
661 }
662}
663
664fn mode_problem(file: &ManifestFile, metadata: &std::fs::Metadata) -> Option<Problem> {
666 use std::os::unix::fs::PermissionsExt;
667 let expected = u32::from_str_radix(&file.mode, 8).ok()?;
668 let actual = metadata.permissions().mode() & 0o7777;
669 (actual != expected).then(|| Problem {
670 path: file.path.clone(),
671 detail: format!("mode is {actual:04o}, expected {expected:04o}"),
672 })
673}
674
675#[derive(Debug, Default, Clone, Copy)]
677pub struct VerifyOptions {
678 pub strict: bool,
681}
682
683#[derive(Debug, Default)]
684pub struct VerifyReport {
685 pub checked: u32,
686 pub unexpected: u32,
688 pub problems: Vec<Problem>,
689}
690
691#[derive(Debug)]
692pub struct Problem {
693 pub path: String,
694 pub detail: String,
695}
696
697impl VerifyReport {
698 pub fn is_ok(&self) -> bool {
699 self.problems.is_empty()
700 }
701
702 pub fn failure_count(&self) -> u32 {
704 u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
705 }
706}