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