1use crate::{
4 error::{Error, Result, io},
5 graph::Digest,
6 hash::sha256_file,
7 oci::ResolvedImageConfig,
8 plan::{BundlePlan, InclusionReason, PLAN_ENTRIES_MAX, PlannedFileKind},
9};
10use serde::{Deserialize, Serialize};
11use std::{
12 io::{Read, Write},
13 path::{Path, PathBuf},
14};
15
16pub const MANIFEST_VERSION: u32 = 4;
17
18const MANIFEST_BYTES_MAX: usize = 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 #[serde(deserialize_with = "deserialize_manifest_files")]
61 pub files: Vec<ManifestFile>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub warnings: Vec<String>,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct ManifestPolicy {
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub preset: Option<String>,
70 pub ca_certificates: bool,
71 pub tmp: bool,
72 pub passwd_group: bool,
73 pub nsswitch: bool,
74 pub tzdata: bool,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub ld_so_cache: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub user: Option<String>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub includes: Vec<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub allow_libraries: Option<Vec<String>>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ManifestFile {
90 pub path: String,
91 pub kind: String,
92 pub reason: Reason,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub sha256: Option<String>,
95 pub size: u64,
96 pub mode: String,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub target: Option<String>,
100}
101
102const MANIFEST_ENTRY_LIMIT_MESSAGE: &str = "manifest entries exceed the supported limit";
103
104fn deserialize_manifest_files<'de, D>(
105 deserializer: D,
106) -> std::result::Result<Vec<ManifestFile>, D::Error>
107where
108 D: serde::Deserializer<'de>,
109{
110 deserialize_bounded_manifest_files(deserializer, PLAN_ENTRIES_MAX)
111}
112
113fn deserialize_bounded_manifest_files<'de, D>(
119 deserializer: D,
120 limit: usize,
121) -> std::result::Result<Vec<ManifestFile>, D::Error>
122where
123 D: serde::Deserializer<'de>,
124{
125 struct BoundedManifestFilesVisitor {
126 limit: usize,
127 }
128
129 impl<'de> serde::de::Visitor<'de> for BoundedManifestFilesVisitor {
130 type Value = Vec<ManifestFile>;
131
132 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 formatter.write_str("an array of manifest entries")
134 }
135
136 fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error>
137 where
138 A: serde::de::SeqAccess<'de>,
139 {
140 let capacity = sequence.size_hint().unwrap_or(0).min(self.limit);
141 let mut files = Vec::with_capacity(capacity);
142 while let Some(file) = sequence.next_element()? {
143 if files.len() == self.limit {
144 return Err(serde::de::Error::custom(format_args!(
145 "{MANIFEST_ENTRY_LIMIT_MESSAGE} of {}",
146 self.limit
147 )));
148 }
149 files.push(file);
150 }
151 Ok(files)
152 }
153 }
154
155 deserializer.deserialize_seq(BoundedManifestFilesVisitor { limit })
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ManifestImage {
160 pub tag: String,
161 pub os: String,
162 pub architecture: String,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub user: Option<String>,
165 pub entrypoint: Vec<String>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub cmd: Vec<String>,
168 pub working_dir: String,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub env: Vec<String>,
171 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
172 pub labels: std::collections::BTreeMap<String, String>,
173 pub manifest_digest: String,
174}
175
176impl ManifestImage {
177 pub fn from_oci(image: &ResolvedImageConfig, manifest_digest: &Digest) -> ManifestImage {
178 ManifestImage {
179 tag: image.tag().to_string(),
180 os: image.os().to_string(),
181 architecture: image.architecture().to_string(),
182 user: image.user().map(str::to_string),
183 entrypoint: image.entrypoint().to_vec(),
184 cmd: image.cmd().to_vec(),
185 working_dir: image.working_dir().to_string(),
186 env: image.env().to_vec(),
187 labels: image.labels().clone(),
188 manifest_digest: format!("sha256:{manifest_digest}"),
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, Default)]
194pub struct ManifestOutputs<'a> {
195 pub rootfs: Option<&'a Path>,
196 pub tar: Option<&'a Path>,
197 pub oci_layout: Option<&'a Path>,
198 pub oci_archive: Option<&'a Path>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(untagged)]
203pub enum Reason {
204 Simple(String),
205 NeededBy { needed_by: String, soname: String },
206 RuntimePolicy { runtime_policy: String },
207}
208
209impl From<&InclusionReason> for Reason {
210 fn from(reason: &InclusionReason) -> Reason {
211 match reason {
212 InclusionReason::Application => Reason::Simple("application".to_string()),
213 InclusionReason::Interpreter => Reason::Simple("interpreter".to_string()),
214 InclusionReason::ExplicitInclude => Reason::Simple("include".to_string()),
215 InclusionReason::NeededBy { binary, soname } => Reason::NeededBy {
216 needed_by: binary.display().to_string(),
217 soname: soname.clone(),
218 },
219 InclusionReason::RuntimePolicy { feature } => Reason::RuntimePolicy {
220 runtime_policy: feature.as_str().to_string(),
221 },
222 }
223 }
224}
225
226impl Manifest {
227 pub fn from_plan(plan: &BundlePlan, source_root: &Path, rootfs: Option<&Path>) -> Manifest {
229 Manifest::from_plan_with_artifacts(
230 plan,
231 source_root,
232 ManifestOutputs {
233 rootfs,
234 ..ManifestOutputs::default()
235 },
236 None,
237 )
238 }
239
240 pub fn from_plan_with_outputs(
241 plan: &BundlePlan,
242 source_root: &Path,
243 rootfs: Option<&Path>,
244 tar: Option<&Path>,
245 ) -> Manifest {
246 Manifest::from_plan_with_artifacts(
247 plan,
248 source_root,
249 ManifestOutputs {
250 rootfs,
251 tar,
252 ..ManifestOutputs::default()
253 },
254 None,
255 )
256 }
257
258 pub fn from_plan_with_artifacts(
259 plan: &BundlePlan,
260 source_root: &Path,
261 outputs: ManifestOutputs<'_>,
262 image: Option<ManifestImage>,
263 ) -> Manifest {
264 let files: Vec<ManifestFile> = plan
265 .files
266 .iter()
267 .map(|file| ManifestFile {
268 path: file.destination.display().to_string(),
269 kind: file.kind.as_str().to_string(),
270 reason: Reason::from(&file.reason),
271 sha256: file.sha256.as_ref().map(|d| d.0.clone()),
272 size: file.size,
273 mode: format!("{:04o}", file.mode),
274 target: file.link_target.as_ref().map(|t| t.display().to_string()),
275 })
276 .collect();
277
278 Manifest {
279 manifest_version: MANIFEST_VERSION,
280 elfpak_version: env!("CARGO_PKG_VERSION").to_string(),
281 binary: plan.executable().destination.display().to_string(),
282 binaries: plan
283 .executables()
284 .map(|file| file.destination.display().to_string())
285 .collect(),
286 architecture: plan.architecture.machine.to_string(),
287 interpreter: plan.interpreter().map(|p| p.display().to_string()),
288 source_root: source_root.display().to_string(),
289 rootfs: outputs.rootfs.map(|p| p.display().to_string()),
290 tar: outputs.tar.map(|p| p.display().to_string()),
291 oci_layout: outputs.oci_layout.map(|p| p.display().to_string()),
292 oci_archive: outputs.oci_archive.map(|p| p.display().to_string()),
293 image,
294 policy: ManifestPolicy {
295 preset: plan.preset.map(|p| p.to_string()),
296 ca_certificates: plan.runtime_policy.ca_certificates,
297 tmp: plan.runtime_policy.tmp,
298 passwd_group: plan.runtime_policy.passwd_group,
299 nsswitch: plan.runtime_policy.nsswitch,
300 tzdata: plan.runtime_policy.tzdata,
301 ld_so_cache: Some(plan.runtime_policy.ld_so_cache.to_string()),
302 user: plan.runtime_policy.user.as_ref().map(|u| u.to_string()),
303 includes: plan
304 .runtime_policy
305 .includes
306 .iter()
307 .map(|p| p.display().to_string())
308 .collect(),
309 allow_libraries: plan.dependency_policy.allow.clone(),
310 },
311 files,
312 warnings: plan
313 .warnings
314 .iter()
315 .map(|w| format!("{}: {}", w.code, w.message))
316 .collect(),
317 }
318 }
319
320 pub fn to_json(&self) -> String {
321 serde_json::to_string_pretty(self).expect("a manifest is plain data")
322 }
323
324 pub fn write(&self, path: &Path) -> Result<()> {
325 let parent = path
326 .parent()
327 .filter(|parent| !parent.as_os_str().is_empty())
328 .unwrap_or_else(|| Path::new("."));
329 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
330 let mut json = self.to_json();
331 json.push('\n');
332 let mut stage = tempfile::Builder::new()
333 .prefix(".elfpak-manifest-")
334 .tempfile_in(parent)
335 .map_err(|e| io(parent, e))?;
336 crate::rootfs::set_output_permissions(stage.path(), path)?;
337 stage.write_all(json.as_bytes()).map_err(|e| io(path, e))?;
338 stage.as_file().sync_all().map_err(|e| io(path, e))?;
339 stage.persist(path).map_err(|e| io(path, e.error))?;
340 Ok(())
341 }
342
343 pub fn load(path: &Path) -> Result<Manifest> {
344 let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
348 if !metadata.is_file() {
349 return Err(invalid_manifest(path, "not a regular file".to_string()));
352 }
353 let bytes = read_manifest_with_limit(path, MANIFEST_BYTES_MAX)?;
354 let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|error| {
355 let message = error.to_string();
356 if message.contains(MANIFEST_ENTRY_LIMIT_MESSAGE) {
357 Error::LimitExceeded {
358 resource: "manifest entries",
359 limit: PLAN_ENTRIES_MAX,
360 }
361 } else {
362 Error::Manifest {
363 path: path.to_path_buf(),
364 message,
365 }
366 }
367 })?;
368 manifest.validate(path)?;
369 Ok(manifest)
370 }
371
372 fn validate(&self, manifest_path: &Path) -> Result<()> {
376 if self.manifest_version == 0 || self.manifest_version > MANIFEST_VERSION {
377 return Err(invalid_manifest(
378 manifest_path,
379 format!("unsupported manifest version {}", self.manifest_version),
380 ));
381 }
382 validate_manifest_entry_count(self.files.len())?;
383 let binaries = self.validate_binaries(manifest_path)?;
384 let mut paths = std::collections::HashSet::new();
385 for file in &self.files {
386 let path = Path::new(&file.path);
387 if !path.is_absolute()
388 || path != crate::paths::normalize_absolute(path)
389 || !paths.insert(path.to_path_buf())
390 {
391 return Err(invalid_manifest(
392 manifest_path,
393 format!("invalid or duplicate path `{}`", file.path),
394 ));
395 }
396 let mode = u32::from_str_radix(&file.mode, 8).ok();
397 if mode.is_none_or(|mode| mode > 0o7777) {
398 return Err(invalid_manifest(
399 manifest_path,
400 format!("invalid mode `{}` for `{}`", file.mode, file.path),
401 ));
402 }
403 match file.kind.as_str() {
404 "directory" if file.size == 0 && file.sha256.is_none() && file.target.is_none() => {
405 }
406 "symlink" if file.size == 0 && file.sha256.is_none() && file.target.is_some() => {}
407 "executable" | "interpreter" | "shared-object" | "certificate-bundle"
408 | "runtime-config" | "application-data"
409 if file.target.is_none()
410 && file.sha256.as_ref().is_some_and(|digest| {
411 self.manifest_version < MANIFEST_SHA256_VERSION || is_sha256(digest)
412 }) => {}
413 _ => {
414 return Err(invalid_manifest(
415 manifest_path,
416 format!("inconsistent entry `{}`", file.path),
417 ));
418 }
419 }
420 }
421 if self.manifest_version >= 3 {
422 let executables: std::collections::HashSet<PathBuf> = self
423 .files
424 .iter()
425 .filter(|file| file.kind == "executable")
426 .map(|file| crate::paths::normalize_absolute(Path::new(&file.path)))
427 .collect();
428 if binaries != executables {
429 return Err(invalid_manifest(
430 manifest_path,
431 "binaries must list every executable manifest entry exactly once".to_string(),
432 ));
433 }
434 }
435 self.validate_image(manifest_path)?;
436 Ok(())
437 }
438
439 fn validate_image(&self, manifest_path: &Path) -> Result<()> {
440 let has_oci_output = self.oci_layout.is_some() || self.oci_archive.is_some();
441 if self.manifest_version < 4 && (has_oci_output || self.image.is_some()) {
442 return Err(invalid_manifest(
443 manifest_path,
444 "OCI fields require manifest version 4".to_string(),
445 ));
446 }
447 if has_oci_output != self.image.is_some() {
448 return Err(invalid_manifest(
449 manifest_path,
450 "OCI destinations and image metadata must be recorded together".to_string(),
451 ));
452 }
453 if let Some(image) = &self.image {
454 let digest = image
455 .manifest_digest
456 .strip_prefix("sha256:")
457 .filter(|digest| is_sha256(digest));
458 if digest.is_none() {
459 return Err(invalid_manifest(
460 manifest_path,
461 "image manifest_digest must be sha256:<64 lowercase hex>".to_string(),
462 ));
463 }
464 }
465 Ok(())
466 }
467
468 fn validate_binaries(
469 &self,
470 manifest_path: &Path,
471 ) -> Result<std::collections::HashSet<PathBuf>> {
472 if self.manifest_version >= 3 && self.binaries.is_empty() {
473 return Err(invalid_manifest(
474 manifest_path,
475 "manifest version 3 or newer requires a non-empty binaries list".to_string(),
476 ));
477 }
478 let binaries: Vec<&str> = if self.binaries.is_empty() {
479 vec![self.binary.as_str()]
480 } else {
481 if self.binaries.first().map(String::as_str) != Some(self.binary.as_str()) {
482 return Err(invalid_manifest(
483 manifest_path,
484 "binary must be the first entry in binaries".to_string(),
485 ));
486 }
487 self.binaries.iter().map(String::as_str).collect()
488 };
489 let mut unique = std::collections::HashSet::new();
490 for binary in binaries {
491 let path = Path::new(binary);
492 if !path.is_absolute()
493 || path != crate::paths::normalize_absolute(path)
494 || !unique.insert(path.to_path_buf())
495 {
496 return Err(invalid_manifest(
497 manifest_path,
498 format!("invalid or duplicate binary path `{binary}`"),
499 ));
500 }
501 }
502 Ok(unique)
503 }
504
505 pub fn verify(&self, rootfs: &Path, options: &VerifyOptions) -> VerifyReport {
509 let mut report = VerifyReport::default();
510 match std::fs::symlink_metadata(rootfs) {
511 Ok(metadata) if metadata.is_symlink() => {
512 report.problems.push(Problem {
513 path: "/".to_string(),
514 detail: "verification root must not be a symlink".to_string(),
515 });
516 return report;
517 }
518 Ok(metadata) if !metadata.is_dir() => {
519 report.problems.push(Problem {
520 path: "/".to_string(),
521 detail: "verification root is not a directory".to_string(),
522 });
523 return report;
524 }
525 Ok(_) | Err(_) => {}
526 }
527 for file in &self.files {
528 report.checked += 1;
529 let target = crate::paths::join_under(rootfs, Path::new(&file.path));
530 assert!(target.starts_with(rootfs));
531
532 if crate::paths::has_symlinked_ancestor(rootfs, target.parent().unwrap_or(rootfs)) {
533 report.problems.push(Problem {
534 path: file.path.clone(),
535 detail: "path traverses a symlinked directory inside the rootfs".to_string(),
536 });
537 continue;
538 }
539
540 let Ok(metadata) = std::fs::symlink_metadata(&target) else {
541 report.problems.push(Problem {
542 path: file.path.clone(),
543 detail: "missing".to_string(),
544 });
545 continue;
546 };
547
548 if let Some(problem) = verify_entry(file, &target, &metadata) {
549 report.problems.push(problem);
550 continue;
551 }
552
553 if options.strict
556 && file.kind != "symlink"
557 && let Some(problem) = mode_problem(file, &metadata)
558 {
559 report.problems.push(problem);
560 }
561 }
562
563 if options.strict {
564 self.report_unexpected(rootfs, &mut report);
565 }
566 report
567 }
568
569 fn report_unexpected(&self, rootfs: &Path, report: &mut VerifyReport) {
572 let expected: std::collections::HashSet<PathBuf> = self
573 .files
574 .iter()
575 .map(|f| crate::paths::normalize_absolute(Path::new(&f.path)))
576 .collect();
577
578 let mut budget = VerificationBudget::new(PLAN_ENTRIES_MAX);
579 let mut stack = vec![rootfs.to_path_buf()];
580 while let Some(current) = stack.pop() {
581 assert!(current.starts_with(rootfs), "the walk stays in the rootfs");
582
583 let entries = match std::fs::read_dir(¤t) {
584 Ok(entries) => entries,
585 Err(error) => {
586 report.problems.push(Problem {
590 path: logical_within(rootfs, ¤t),
591 detail: format!(
592 "could not be read while checking for unlisted entries: {error}"
593 ),
594 });
595 continue;
596 }
597 };
598 let mut found = Vec::new();
603 for entry in entries {
604 match entry {
605 Ok(entry) => found.push(entry.path()),
606 Err(error) => {
607 if !budget.visit(report) {
608 return;
609 }
610 report.problems.push(Problem {
611 path: logical_within(rootfs, ¤t),
612 detail: format!(
613 "could not be read while checking for unlisted entries: {error}"
614 ),
615 });
616 continue;
617 }
618 }
619 if found.len() > budget.remaining() {
620 budget.exhaust(report);
621 return;
622 }
623 }
624 found.sort();
627
628 for path in found {
629 if !budget.visit(report) {
630 return;
631 }
632 let Ok(relative) = path.strip_prefix(rootfs) else {
633 continue;
634 };
635 let logical = crate::paths::normalize_absolute(&Path::new("/").join(relative));
636 let metadata = match std::fs::symlink_metadata(&path) {
637 Ok(metadata) => metadata,
638 Err(error) => {
639 report.problems.push(Problem {
640 path: logical.display().to_string(),
641 detail: format!("could not be inspected: {error}"),
642 });
643 continue;
644 }
645 };
646 if metadata.is_dir() && !metadata.is_symlink() {
649 stack.push(path.clone());
650 }
651 if !expected.contains(&logical) {
652 report.unexpected += 1;
653 report.problems.push(Problem {
654 path: logical.display().to_string(),
655 detail: "present in the rootfs but not listed in the manifest".to_string(),
656 });
657 }
658 }
659 }
660 }
661
662 pub fn file_count(&self) -> usize {
664 self.files
665 .iter()
666 .filter(|f| f.kind != PlannedFileKind::Directory.as_str())
667 .count()
668 }
669}
670
671fn read_manifest_with_limit(path: &Path, limit: usize) -> Result<Vec<u8>> {
672 let limit_u64 = u64::try_from(limit).unwrap_or(u64::MAX);
673 let metadata = std::fs::metadata(path).map_err(|error| io(path, error))?;
674 if metadata.len() > limit_u64 {
675 return Err(Error::LimitExceeded {
676 resource: "manifest",
677 limit,
678 });
679 }
680
681 let file = std::fs::File::open(path).map_err(|error| io(path, error))?;
682 let capacity = usize::try_from(metadata.len()).unwrap_or(limit).min(limit);
683 let mut bytes = Vec::with_capacity(capacity);
684 file.take(limit_u64.saturating_add(1))
685 .read_to_end(&mut bytes)
686 .map_err(|error| io(path, error))?;
687 if bytes.len() > limit {
688 return Err(Error::LimitExceeded {
689 resource: "manifest",
690 limit,
691 });
692 }
693 Ok(bytes)
694}
695
696fn validate_manifest_entry_count(entry_count: usize) -> Result<()> {
702 if entry_count > PLAN_ENTRIES_MAX {
703 return Err(Error::LimitExceeded {
704 resource: "manifest entries",
705 limit: PLAN_ENTRIES_MAX,
706 });
707 }
708 Ok(())
709}
710
711struct VerificationBudget {
717 remaining: usize,
718 exhausted: bool,
719}
720
721impl VerificationBudget {
722 fn new(limit: usize) -> VerificationBudget {
723 VerificationBudget {
724 remaining: limit,
725 exhausted: false,
726 }
727 }
728
729 fn remaining(&self) -> usize {
730 self.remaining
731 }
732
733 fn visit(&mut self, report: &mut VerifyReport) -> bool {
736 if self.remaining == 0 {
737 self.exhaust(report);
738 return false;
739 }
740 self.remaining -= 1;
741 true
742 }
743
744 fn exhaust(&mut self, report: &mut VerifyReport) {
745 if self.exhausted {
746 return;
747 }
748 self.exhausted = true;
749 report.problems.push(Problem {
750 path: "/".to_string(),
751 detail: format!(
752 "strict verification exceeded the supported limit of {PLAN_ENTRIES_MAX} verification entries"
753 ),
754 });
755 }
756}
757
758fn logical_within(rootfs: &Path, path: &Path) -> String {
761 let relative = path.strip_prefix(rootfs).unwrap_or(path);
762 crate::paths::normalize_absolute(&Path::new("/").join(relative))
763 .display()
764 .to_string()
765}
766
767fn invalid_manifest(path: &Path, message: String) -> Error {
768 Error::Manifest {
769 path: path.to_path_buf(),
770 message,
771 }
772}
773
774fn is_sha256(digest: &str) -> bool {
775 digest.len() == 64
776 && digest
777 .bytes()
778 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
779}
780
781fn verify_entry(
784 file: &ManifestFile,
785 target: &Path,
786 metadata: &std::fs::Metadata,
787) -> Option<Problem> {
788 match file.kind.as_str() {
789 "directory" => (!metadata.is_dir()).then(|| Problem {
790 path: file.path.clone(),
791 detail: "expected a directory".to_string(),
792 }),
793 "symlink" => verify_symlink(file, target, metadata),
794 "executable" | "interpreter" | "shared-object" | "certificate-bundle"
795 | "runtime-config" | "application-data" => verify_regular(file, target, metadata),
796 _ => Some(Problem {
797 path: file.path.clone(),
798 detail: format!("unknown manifest entry kind `{}`", file.kind),
799 }),
800 }
801}
802
803fn verify_symlink(
806 file: &ManifestFile,
807 target: &Path,
808 metadata: &std::fs::Metadata,
809) -> Option<Problem> {
810 if !metadata.is_symlink() {
811 return Some(Problem {
812 path: file.path.clone(),
813 detail: "expected a symlink".to_string(),
814 });
815 }
816 let actual = std::fs::read_link(target).unwrap_or_default();
817 let expected = file.target.clone().unwrap_or_default();
818 if actual.as_os_str() == expected.as_str() {
819 return None;
820 }
821 Some(Problem {
822 path: file.path.clone(),
823 detail: format!(
824 "link target is `{}`, expected `{}`",
825 actual.display(),
826 expected
827 ),
828 })
829}
830
831fn verify_regular(
833 file: &ManifestFile,
834 target: &Path,
835 metadata: &std::fs::Metadata,
836) -> Option<Problem> {
837 if !metadata.is_file() {
838 return Some(Problem {
839 path: file.path.clone(),
840 detail: "expected a regular file".to_string(),
841 });
842 }
843 let Some(expected) = file.sha256.as_ref() else {
844 return Some(Problem {
845 path: file.path.clone(),
846 detail: "regular file has no sha256 digest".to_string(),
847 });
848 };
849 match sha256_file(target) {
850 Ok((actual, size)) if &actual.0 == expected && size == file.size => None,
851 Ok((_actual, size)) if size != file.size => Some(Problem {
852 path: file.path.clone(),
853 detail: format!("size is {size} bytes, expected {}", file.size),
854 }),
855 Ok((actual, _)) => Some(Problem {
856 path: file.path.clone(),
857 detail: format!("sha256 mismatch (found {}, expected {expected})", actual.0),
858 }),
859 Err(e) => Some(Problem {
860 path: file.path.clone(),
861 detail: format!("unreadable: {e}"),
862 }),
863 }
864}
865
866fn mode_problem(file: &ManifestFile, metadata: &std::fs::Metadata) -> Option<Problem> {
868 use std::os::unix::fs::PermissionsExt;
869 let expected = u32::from_str_radix(&file.mode, 8).ok()?;
870 let actual = metadata.permissions().mode() & 0o7777;
871 (actual != expected).then(|| Problem {
872 path: file.path.clone(),
873 detail: format!("mode is {actual:04o}, expected {expected:04o}"),
874 })
875}
876
877#[derive(Debug, Default, Clone, Copy)]
879pub struct VerifyOptions {
880 pub strict: bool,
883}
884
885#[derive(Debug, Default)]
886pub struct VerifyReport {
887 pub checked: u32,
888 pub unexpected: u32,
890 pub problems: Vec<Problem>,
891}
892
893#[derive(Debug)]
894pub struct Problem {
895 pub path: String,
896 pub detail: String,
897}
898
899impl VerifyReport {
900 pub fn is_ok(&self) -> bool {
901 self.problems.is_empty()
902 }
903
904 pub fn failure_count(&self) -> u32 {
906 u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913
914 #[test]
915 fn manifest_entry_validation_rejects_a_count_over_the_plan_limit() {
916 let error = validate_manifest_entry_count(PLAN_ENTRIES_MAX + 1).unwrap_err();
917 assert!(matches!(
918 error,
919 Error::LimitExceeded {
920 resource: "manifest entries",
921 limit: PLAN_ENTRIES_MAX,
922 }
923 ));
924 }
925
926 #[test]
927 fn manifest_file_deserialization_stops_at_its_entry_limit() {
928 let json = r#"[
929 {"path":"/one","kind":"directory","reason":"include","size":0,"mode":"0755"},
930 {"path":"/two","kind":"directory","reason":"include","size":0,"mode":"0755"}
931 ]"#;
932 let mut deserializer = serde_json::Deserializer::from_str(json);
933
934 let error = deserialize_bounded_manifest_files(&mut deserializer, 1).unwrap_err();
935 assert!(error.to_string().contains("manifest entries"), "{error}");
936 }
937
938 #[test]
939 fn manifest_read_stops_at_its_byte_limit() {
940 let temp = tempfile::NamedTempFile::new().unwrap();
941 std::fs::write(temp.path(), b"12345").unwrap();
942
943 let error = read_manifest_with_limit(temp.path(), 4).unwrap_err();
944 assert!(matches!(
945 error,
946 Error::LimitExceeded {
947 resource: "manifest",
948 limit: 4,
949 }
950 ));
951 }
952
953 #[test]
954 fn verification_budget_stops_before_recording_unbounded_problems() {
955 let mut budget = VerificationBudget::new(1);
956 let mut report = VerifyReport::default();
957
958 assert!(budget.visit(&mut report));
959 assert!(!budget.visit(&mut report));
960 assert_eq!(report.problems.len(), 1);
961 assert!(report.problems[0].detail.contains("verification entries"));
962 }
963}