Skip to main content

elfpak_core/
manifest.rs

1//! Machine-readable record of a bundle: what was included and why.
2
3use 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
18/// Upper bound on a manifest file `verify` will read.
19///
20/// One entry costs a few hundred bytes, so this comfortably covers a plan at
21/// [`crate::plan::PLAN_ENTRIES_MAX`] while keeping an arbitrary file handed to
22/// `verify` from being loaded whole.
23const MANIFEST_BYTES_MAX: u64 = 512 * 1024 * 1024;
24const MANIFEST_SHA256_VERSION: u32 = 2;
25/// Name of the manifest written beside a bundle.
26pub 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    /// Install path of the application inside the rootfs.
33    pub binary: String,
34    /// Install paths of every application. Empty in manifests before version 3.
35    #[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    /// Where the rootfs was written, used by `elfpak verify`.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub rootfs: Option<String>,
44    /// Where the tar archive was written, when one was requested.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub tar: Option<String>,
47    /// Where an OCI image layout directory was written.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub oci_layout: Option<String>,
50    /// Where an OCI image layout archive was written.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub oci_archive: Option<String>,
53    /// Resolved OCI metadata and the published manifest digest.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub image: Option<ManifestImage>,
56    /// Resolved runtime and dependency policy. Reproducing a bundle requires
57    /// the same configuration, so the configuration is part of the record.
58    #[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    /// `auto`, `always` or `never`; absent in manifests written before the
75    /// bundle could carry a generated loader cache.
76    #[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    /// `None` means the dependency allow-list was not enforced.
83    #[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    /// Octal permission bits, e.g. `0755`.
96    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    /// A manifest of a plan, without recording where the bundle was written.
171    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        // A manifest is an index of a bundle, not a data container. `verify`
288        // is pointed at files this process did not write, so the size is
289        // checked before the bytes are held in memory.
290        let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
291        if !metadata.is_file() {
292            // A FIFO reports a length of zero and then yields bytes forever, so
293            // the size check below would wave it through.
294            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    /// Reject malformed untrusted manifest data before verification relies on
312    /// it. `verify` is also public, so it still treats an unknown kind as a
313    /// problem, but normal CLI use gets a clear load-time error.
314    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    /// Check a materialized rootfs against this manifest. An entry can be
444    /// missing, of the wrong kind or contents, or, under `--strict`, have
445    /// permissions that changed.
446    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            // Permission bits are part of the record, and a mode change is a
492            // change the digests cannot see. Symlink modes are not meaningful.
493            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    /// Anything present in the rootfs that the manifest does not list. Without
508    /// this, `verify` can only prove that nothing was removed or altered.
509    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(&current) {
521                Ok(entries) => entries,
522                Err(error) => {
523                    // Strict verification claims the rootfs holds nothing but
524                    // planned entries. A subtree it could not enumerate is a
525                    // gap in that claim, not an absence of problems.
526                    report.problems.push(Problem {
527                        path: logical_within(rootfs, &current),
528                        detail: format!(
529                            "could not be read while checking for unlisted entries: {error}"
530                        ),
531                    });
532                    continue;
533                }
534            };
535            // Sorted, so that the problems of a failing verification are
536            // reported in the same order on every run.
537            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                // Never descend into a symlink: it is an entry in its own right,
556                // and its target is checked where the target lives.
557                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    /// Entries that carry content, i.e. everything but the directory scaffolding.
572    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
580/// A path inside the rootfs, named the way the manifest names it, so every
581/// problem in one report is keyed in the same path space.
582fn 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
603/// Check one entry against what the manifest recorded for it. `None` means the
604/// entry is what it should be.
605fn 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
625/// A symlink is verified by its target, verbatim: the bundle preserves link
626/// structure, so a link that now points elsewhere is a changed bundle.
627fn 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
653/// A regular file is verified by its digest.
654fn 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
688/// Compare recorded and actual permission bits.
689fn 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/// What `verify` should check beyond "every recorded entry still matches".
700#[derive(Debug, Default, Clone, Copy)]
701pub struct VerifyOptions {
702    /// Also fail on files present in the rootfs but absent from the manifest,
703    /// and on entries whose permission bits changed.
704    pub strict: bool,
705}
706
707#[derive(Debug, Default)]
708pub struct VerifyReport {
709    pub checked: u32,
710    /// Entries found in the rootfs that the manifest does not list.
711    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    /// Problems found, saturated at `u32::MAX`.
727    pub fn failure_count(&self) -> u32 {
728        u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
729    }
730}