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, 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
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: usize = 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    #[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    /// `auto`, `always` or `never`; absent in manifests written before the
76    /// bundle could carry a generated loader cache.
77    #[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    /// `None` means the dependency allow-list was not enforced.
84    #[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    /// Octal permission bits, e.g. `0755`.
97    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
113/// Deserialize manifest entries without first trusting the sequence's size.
114///
115/// The post-deserialization validation remains as defense in depth, but this
116/// visitor is the allocation boundary: it retains at most `limit` entries and
117/// rejects the first additional one.
118fn 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    /// A manifest of a plan, without recording where the bundle was written.
228    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        // A manifest is an index of a bundle, not a data container. `verify`
345        // is pointed at files this process did not write, so the size is
346        // checked before the bytes are held in memory.
347        let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
348        if !metadata.is_file() {
349            // A FIFO reports a length of zero and then yields bytes forever, so
350            // the size check below would wave it through.
351            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    /// Reject malformed untrusted manifest data before verification relies on
373    /// it. `verify` is also public, so it still treats an unknown kind as a
374    /// problem, but normal CLI use gets a clear load-time error.
375    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    /// Check a materialized rootfs against this manifest. An entry can be
506    /// missing, of the wrong kind or contents, or, under `--strict`, have
507    /// permissions that changed.
508    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            // Permission bits are part of the record, and a mode change is a
554            // change the digests cannot see. Symlink modes are not meaningful.
555            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    /// Anything present in the rootfs that the manifest does not list. Without
570    /// this, `verify` can only prove that nothing was removed or altered.
571    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(&current) {
584                Ok(entries) => entries,
585                Err(error) => {
586                    // Strict verification claims the rootfs holds nothing but
587                    // planned entries. A subtree it could not enumerate is a
588                    // gap in that claim, not an absence of problems.
589                    report.problems.push(Problem {
590                        path: logical_within(rootfs, &current),
591                        detail: format!(
592                            "could not be read while checking for unlisted entries: {error}"
593                        ),
594                    });
595                    continue;
596                }
597            };
598            // Keep the deterministic ordering without allowing one hostile
599            // directory to allocate an unbounded list before the walk's limit
600            // has a chance to stop it. One extra entry proves the limit was
601            // crossed, and no further names need to be retained or inspected.
602            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, &current),
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            // Sorted, so that the problems of a failing verification are
625            // reported in the same order on every run.
626            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                // Never descend into a symlink: it is an entry in its own right,
647                // and its target is checked where the target lives.
648                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    /// Entries that carry content, i.e. everything but the directory scaffolding.
663    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
696/// Reject a manifest that could not have been emitted from a valid plan.
697///
698/// The byte limit on the JSON document prevents one oversized allocation; this
699/// entry limit prevents a small-enough document made from millions of tiny
700/// entries from consuming unbounded memory and verification time.
701fn 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
711/// Shared budget for strict filesystem discovery.
712///
713/// A manifest already bounds verification of recorded entries. The strict
714/// walk additionally sees entries the manifest does not name, so it needs the
715/// same cap before retaining directory names or growing the problem report.
716struct 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    /// Consume one discovered filesystem entry. `false` means verification
734    /// has recorded the limit problem and must stop the walk.
735    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
758/// A path inside the rootfs, named the way the manifest names it, so every
759/// problem in one report is keyed in the same path space.
760fn 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
781/// Check one entry against what the manifest recorded for it. `None` means the
782/// entry is what it should be.
783fn 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
803/// A symlink is verified by its target, verbatim: the bundle preserves link
804/// structure, so a link that now points elsewhere is a changed bundle.
805fn 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
831/// A regular file is verified by its digest.
832fn 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
866/// Compare recorded and actual permission bits.
867fn 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/// What `verify` should check beyond "every recorded entry still matches".
878#[derive(Debug, Default, Clone, Copy)]
879pub struct VerifyOptions {
880    /// Also fail on files present in the rootfs but absent from the manifest,
881    /// and on entries whose permission bits changed.
882    pub strict: bool,
883}
884
885#[derive(Debug, Default)]
886pub struct VerifyReport {
887    pub checked: u32,
888    /// Entries found in the rootfs that the manifest does not list.
889    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    /// Problems found, saturated at `u32::MAX`.
905    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}