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;
17const MANIFEST_SHA256_VERSION: u32 = 2;
18/// Name of the manifest written beside a bundle.
19pub 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    /// Install path of the application inside the rootfs.
26    pub binary: String,
27    /// Install paths of every application. Empty in manifests before version 3.
28    #[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    /// Where the rootfs was written, used by `elfpak verify`.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub rootfs: Option<String>,
37    /// Where the tar archive was written, when one was requested.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub tar: Option<String>,
40    /// Where an OCI image layout directory was written.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub oci_layout: Option<String>,
43    /// Where an OCI image layout archive was written.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub oci_archive: Option<String>,
46    /// Resolved OCI metadata and the published manifest digest.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub image: Option<ManifestImage>,
49    /// Resolved runtime and dependency policy. Reproducing a bundle requires
50    /// the same configuration, so the configuration is part of the record.
51    #[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    /// `auto`, `always` or `never`; absent in manifests written before the
68    /// bundle could carry a generated loader cache.
69    #[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    /// `None` means the dependency allow-list was not enforced.
76    #[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    /// Octal permission bits, e.g. `0755`.
89    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    /// A manifest of a plan, without recording where the bundle was written.
164    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    /// Reject malformed untrusted manifest data before verification relies on
290    /// it. `verify` is also public, so it still treats an unknown kind as a
291    /// problem, but normal CLI use gets a clear load-time error.
292    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    /// Check a materialized rootfs against this manifest. An entry can be
422    /// missing, of the wrong kind or contents, or, under `--strict`, have
423    /// permissions that changed.
424    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            // Permission bits are part of the record, and a mode change is a
470            // change the digests cannot see. Symlink modes are not meaningful.
471            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    /// Anything present in the rootfs that the manifest does not list. Without
486    /// this, `verify` can only prove that nothing was removed or altered.
487    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(&current) else {
499                continue;
500            };
501            // Sorted, so that the problems of a failing verification are
502            // reported in the same order on every run.
503            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                // Never descend into a symlink: it is an entry in its own right,
515                // and its target is checked where the target lives.
516                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    /// Entries that carry content, i.e. everything but the directory scaffolding.
531    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
553/// A final symlink is a valid manifest entry; only ancestor symlinks would
554/// redirect metadata reads or hashing outside the supplied rootfs.
555fn 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
579/// Check one entry against what the manifest recorded for it. `None` means the
580/// entry is what it should be.
581fn 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
601/// A symlink is verified by its target, verbatim: the bundle preserves link
602/// structure, so a link that now points elsewhere is a changed bundle.
603fn 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
629/// A regular file is verified by its digest.
630fn 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
664/// Compare recorded and actual permission bits.
665fn 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/// What `verify` should check beyond "every recorded entry still matches".
676#[derive(Debug, Default, Clone, Copy)]
677pub struct VerifyOptions {
678    /// Also fail on files present in the rootfs but absent from the manifest,
679    /// and on entries whose permission bits changed.
680    pub strict: bool,
681}
682
683#[derive(Debug, Default)]
684pub struct VerifyReport {
685    pub checked: u32,
686    /// Entries found in the rootfs that the manifest does not list.
687    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    /// Problems found, saturated at `u32::MAX`.
703    pub fn failure_count(&self) -> u32 {
704        u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
705    }
706}