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    hash::sha256_file,
6    plan::{BundlePlan, InclusionReason, PlannedFileKind},
7};
8use serde::{Deserialize, Serialize};
9use std::{
10    io::Write,
11    path::{Path, PathBuf},
12};
13
14pub const MANIFEST_VERSION: u32 = 2;
15/// Name of the manifest written beside a bundle.
16pub const MANIFEST_NAME_DEFAULT: &str = "elfpak-manifest.json";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Manifest {
20    pub manifest_version: u32,
21    pub elfpak_version: String,
22    /// Install path of the application inside the rootfs.
23    pub binary: String,
24    pub architecture: String,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub interpreter: Option<String>,
27    pub source_root: String,
28    /// Where the rootfs was written, used by `elfpak verify`.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub rootfs: Option<String>,
31    /// Where the tar archive was written, when one was requested.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub tar: Option<String>,
34    /// Resolved runtime and dependency policy. Reproducing a bundle requires
35    /// the same configuration, so the configuration is part of the record.
36    #[serde(default)]
37    pub policy: ManifestPolicy,
38    pub files: Vec<ManifestFile>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub warnings: Vec<String>,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct ManifestPolicy {
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub preset: Option<String>,
47    pub ca_certificates: bool,
48    pub tmp: bool,
49    pub passwd_group: bool,
50    pub nsswitch: bool,
51    pub tzdata: bool,
52    /// `auto`, `always` or `never`; absent in manifests written before the
53    /// bundle could carry a generated loader cache.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub ld_so_cache: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub user: Option<String>,
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub includes: Vec<String>,
60    /// `None` means the dependency allow-list was not enforced.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub allow_libraries: Option<Vec<String>>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ManifestFile {
67    pub path: String,
68    pub kind: String,
69    pub reason: Reason,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub sha256: Option<String>,
72    pub size: u64,
73    /// Octal permission bits, e.g. `0755`.
74    pub mode: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub target: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(untagged)]
81pub enum Reason {
82    Simple(String),
83    NeededBy { needed_by: String, soname: String },
84    RuntimePolicy { runtime_policy: String },
85}
86
87impl From<&InclusionReason> for Reason {
88    fn from(reason: &InclusionReason) -> Reason {
89        match reason {
90            InclusionReason::Application => Reason::Simple("application".to_string()),
91            InclusionReason::Interpreter => Reason::Simple("interpreter".to_string()),
92            InclusionReason::ExplicitInclude => Reason::Simple("include".to_string()),
93            InclusionReason::NeededBy { binary, soname } => Reason::NeededBy {
94                needed_by: binary.display().to_string(),
95                soname: soname.clone(),
96            },
97            InclusionReason::RuntimePolicy { feature } => Reason::RuntimePolicy {
98                runtime_policy: feature.as_str().to_string(),
99            },
100        }
101    }
102}
103
104impl Manifest {
105    /// A manifest of a plan, without recording where the bundle was written.
106    pub fn from_plan(plan: &BundlePlan, source_root: &Path, rootfs: Option<&Path>) -> Manifest {
107        Manifest::from_plan_with_outputs(plan, source_root, rootfs, None)
108    }
109
110    pub fn from_plan_with_outputs(
111        plan: &BundlePlan,
112        source_root: &Path,
113        rootfs: Option<&Path>,
114        tar: Option<&Path>,
115    ) -> Manifest {
116        let files: Vec<ManifestFile> = plan
117            .files
118            .iter()
119            .map(|file| ManifestFile {
120                path: file.destination.display().to_string(),
121                kind: file.kind.as_str().to_string(),
122                reason: Reason::from(&file.reason),
123                sha256: file.sha256.as_ref().map(|d| d.0.clone()),
124                size: file.size,
125                mode: format!("{:04o}", file.mode),
126                target: file.link_target.as_ref().map(|t| t.display().to_string()),
127            })
128            .collect();
129
130        Manifest {
131            manifest_version: MANIFEST_VERSION,
132            elfpak_version: env!("CARGO_PKG_VERSION").to_string(),
133            binary: plan.executable.destination.display().to_string(),
134            architecture: plan.architecture.machine.to_string(),
135            interpreter: plan.interpreter.as_ref().map(|p| p.display().to_string()),
136            source_root: source_root.display().to_string(),
137            rootfs: rootfs.map(|p| p.display().to_string()),
138            tar: tar.map(|p| p.display().to_string()),
139            policy: ManifestPolicy {
140                preset: plan.preset.map(|p| p.to_string()),
141                ca_certificates: plan.runtime_policy.ca_certificates,
142                tmp: plan.runtime_policy.tmp,
143                passwd_group: plan.runtime_policy.passwd_group,
144                nsswitch: plan.runtime_policy.nsswitch,
145                tzdata: plan.runtime_policy.tzdata,
146                ld_so_cache: Some(plan.runtime_policy.ld_so_cache.to_string()),
147                user: plan.runtime_policy.user.as_ref().map(|u| u.to_string()),
148                includes: plan
149                    .runtime_policy
150                    .includes
151                    .iter()
152                    .map(|p| p.display().to_string())
153                    .collect(),
154                allow_libraries: plan.dependency_policy.allow.clone(),
155            },
156            files,
157            warnings: plan
158                .warnings
159                .iter()
160                .map(|w| format!("{}: {}", w.code, w.message))
161                .collect(),
162        }
163    }
164
165    pub fn to_json(&self) -> String {
166        serde_json::to_string_pretty(self).expect("a manifest is plain data")
167    }
168
169    pub fn write(&self, path: &Path) -> Result<()> {
170        let parent = path
171            .parent()
172            .filter(|parent| !parent.as_os_str().is_empty())
173            .unwrap_or_else(|| Path::new("."));
174        std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
175        let mut json = self.to_json();
176        json.push('\n');
177        let mut stage = tempfile::Builder::new()
178            .prefix(".elfpak-manifest-")
179            .tempfile_in(parent)
180            .map_err(|e| io(parent, e))?;
181        set_output_permissions(stage.path(), path)?;
182        stage.write_all(json.as_bytes()).map_err(|e| io(path, e))?;
183        stage.as_file().sync_all().map_err(|e| io(path, e))?;
184        stage.persist(path).map_err(|e| io(path, e.error))?;
185        Ok(())
186    }
187
188    pub fn load(path: &Path) -> Result<Manifest> {
189        let bytes = std::fs::read(path).map_err(|e| io(path, e))?;
190        let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|e| Error::Manifest {
191            path: path.to_path_buf(),
192            message: e.to_string(),
193        })?;
194        manifest.validate(path)?;
195        Ok(manifest)
196    }
197
198    /// Reject malformed untrusted manifest data before verification relies on
199    /// it. `verify` is also public, so it still treats an unknown kind as a
200    /// problem, but normal CLI use gets a clear load-time error.
201    fn validate(&self, manifest_path: &Path) -> Result<()> {
202        if self.manifest_version == 0 || self.manifest_version > MANIFEST_VERSION {
203            return Err(invalid_manifest(
204                manifest_path,
205                format!("unsupported manifest version {}", self.manifest_version),
206            ));
207        }
208        let mut paths = std::collections::HashSet::new();
209        for file in &self.files {
210            let path = Path::new(&file.path);
211            if !path.is_absolute()
212                || path != crate::paths::normalize_absolute(path)
213                || !paths.insert(path.to_path_buf())
214            {
215                return Err(invalid_manifest(
216                    manifest_path,
217                    format!("invalid or duplicate path `{}`", file.path),
218                ));
219            }
220            let mode = u32::from_str_radix(&file.mode, 8).ok();
221            if mode.is_none_or(|mode| mode > 0o7777) {
222                return Err(invalid_manifest(
223                    manifest_path,
224                    format!("invalid mode `{}` for `{}`", file.mode, file.path),
225                ));
226            }
227            match file.kind.as_str() {
228                "directory" if file.size == 0 && file.sha256.is_none() && file.target.is_none() => {
229                }
230                "symlink" if file.size == 0 && file.sha256.is_none() && file.target.is_some() => {}
231                "executable" | "interpreter" | "shared-object" | "certificate-bundle"
232                | "runtime-config" | "application-data"
233                    if file.target.is_none()
234                        && file.sha256.as_ref().is_some_and(|digest| {
235                            self.manifest_version < MANIFEST_VERSION || is_sha256(digest)
236                        }) => {}
237                _ => {
238                    return Err(invalid_manifest(
239                        manifest_path,
240                        format!("inconsistent entry `{}`", file.path),
241                    ));
242                }
243            }
244        }
245        Ok(())
246    }
247
248    /// Check a materialized rootfs against this manifest. An entry can be
249    /// missing, of the wrong kind or contents, or, under `--strict`, have
250    /// permissions that changed.
251    pub fn verify(&self, rootfs: &Path, options: &VerifyOptions) -> VerifyReport {
252        let mut report = VerifyReport::default();
253        match std::fs::symlink_metadata(rootfs) {
254            Ok(metadata) if metadata.is_symlink() => {
255                report.problems.push(Problem {
256                    path: "/".to_string(),
257                    detail: "verification root must not be a symlink".to_string(),
258                });
259                return report;
260            }
261            Ok(metadata) if !metadata.is_dir() => {
262                report.problems.push(Problem {
263                    path: "/".to_string(),
264                    detail: "verification root is not a directory".to_string(),
265                });
266                return report;
267            }
268            Ok(_) | Err(_) => {}
269        }
270        for file in &self.files {
271            report.checked += 1;
272            let target = crate::paths::join_under(rootfs, Path::new(&file.path));
273            assert!(target.starts_with(rootfs));
274
275            if has_symlinked_ancestor(rootfs, &target) {
276                report.problems.push(Problem {
277                    path: file.path.clone(),
278                    detail: "path traverses a symlinked directory inside the rootfs".to_string(),
279                });
280                continue;
281            }
282
283            let Ok(metadata) = std::fs::symlink_metadata(&target) else {
284                report.problems.push(Problem {
285                    path: file.path.clone(),
286                    detail: "missing".to_string(),
287                });
288                continue;
289            };
290
291            if let Some(problem) = verify_entry(file, &target, &metadata) {
292                report.problems.push(problem);
293                continue;
294            }
295
296            // Permission bits are part of the record, and a mode change is a
297            // change the digests cannot see. Symlink modes are not meaningful.
298            if options.strict
299                && file.kind != "symlink"
300                && let Some(problem) = mode_problem(file, &metadata)
301            {
302                report.problems.push(problem);
303            }
304        }
305
306        if options.strict {
307            self.report_unexpected(rootfs, &mut report);
308        }
309        report
310    }
311
312    /// Anything present in the rootfs that the manifest does not list. Without
313    /// this, `verify` can only prove that nothing was removed or altered.
314    fn report_unexpected(&self, rootfs: &Path, report: &mut VerifyReport) {
315        let expected: std::collections::HashSet<PathBuf> = self
316            .files
317            .iter()
318            .map(|f| crate::paths::normalize_absolute(Path::new(&f.path)))
319            .collect();
320
321        let mut stack = vec![rootfs.to_path_buf()];
322        while let Some(current) = stack.pop() {
323            assert!(current.starts_with(rootfs), "the walk stays in the rootfs");
324
325            let Ok(entries) = std::fs::read_dir(&current) else {
326                continue;
327            };
328            // Sorted, so that the problems of a failing verification are
329            // reported in the same order on every run.
330            let mut found: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
331            found.sort();
332
333            for path in found {
334                let Ok(relative) = path.strip_prefix(rootfs) else {
335                    continue;
336                };
337                let logical = crate::paths::normalize_absolute(&Path::new("/").join(relative));
338                let Ok(metadata) = std::fs::symlink_metadata(&path) else {
339                    continue;
340                };
341                // Never descend into a symlink: it is an entry in its own right,
342                // and its target is checked where the target lives.
343                if metadata.is_dir() && !metadata.is_symlink() {
344                    stack.push(path.clone());
345                }
346                if !expected.contains(&logical) {
347                    report.unexpected += 1;
348                    report.problems.push(Problem {
349                        path: logical.display().to_string(),
350                        detail: "present in the rootfs but not listed in the manifest".to_string(),
351                    });
352                }
353            }
354        }
355    }
356
357    /// Entries that carry content, i.e. everything but the directory scaffolding.
358    pub fn file_count(&self) -> usize {
359        self.files
360            .iter()
361            .filter(|f| f.kind != PlannedFileKind::Directory.as_str())
362            .count()
363    }
364}
365
366fn invalid_manifest(path: &Path, message: String) -> Error {
367    Error::Manifest {
368        path: path.to_path_buf(),
369        message,
370    }
371}
372
373fn is_sha256(digest: &str) -> bool {
374    digest.len() == 64
375        && digest
376            .bytes()
377            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
378}
379
380/// A final symlink is a valid manifest entry; only ancestor symlinks would
381/// redirect metadata reads or hashing outside the supplied rootfs.
382fn has_symlinked_ancestor(rootfs: &Path, target: &Path) -> bool {
383    let mut current = target.parent();
384    while let Some(path) = current {
385        if path == rootfs {
386            return false;
387        }
388        match std::fs::symlink_metadata(path) {
389            Ok(metadata) if metadata.is_symlink() => return true,
390            Ok(_) | Err(_) => {}
391        }
392        current = path.parent();
393    }
394    true
395}
396
397fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
398    use std::os::unix::fs::PermissionsExt;
399
400    let permissions = std::fs::metadata(destination)
401        .map(|metadata| metadata.permissions())
402        .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
403    std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
404}
405
406/// Check one entry against what the manifest recorded for it. `None` means the
407/// entry is what it should be.
408fn verify_entry(
409    file: &ManifestFile,
410    target: &Path,
411    metadata: &std::fs::Metadata,
412) -> Option<Problem> {
413    match file.kind.as_str() {
414        "directory" => (!metadata.is_dir()).then(|| Problem {
415            path: file.path.clone(),
416            detail: "expected a directory".to_string(),
417        }),
418        "symlink" => verify_symlink(file, target, metadata),
419        "executable" | "interpreter" | "shared-object" | "certificate-bundle"
420        | "runtime-config" | "application-data" => verify_regular(file, target, metadata),
421        _ => Some(Problem {
422            path: file.path.clone(),
423            detail: format!("unknown manifest entry kind `{}`", file.kind),
424        }),
425    }
426}
427
428/// A symlink is verified by its target, verbatim: the bundle preserves link
429/// structure, so a link that now points elsewhere is a changed bundle.
430fn verify_symlink(
431    file: &ManifestFile,
432    target: &Path,
433    metadata: &std::fs::Metadata,
434) -> Option<Problem> {
435    if !metadata.is_symlink() {
436        return Some(Problem {
437            path: file.path.clone(),
438            detail: "expected a symlink".to_string(),
439        });
440    }
441    let actual = std::fs::read_link(target).unwrap_or_default();
442    let expected = file.target.clone().unwrap_or_default();
443    if actual.as_os_str() == expected.as_str() {
444        return None;
445    }
446    Some(Problem {
447        path: file.path.clone(),
448        detail: format!(
449            "link target is `{}`, expected `{}`",
450            actual.display(),
451            expected
452        ),
453    })
454}
455
456/// A regular file is verified by its digest.
457fn verify_regular(
458    file: &ManifestFile,
459    target: &Path,
460    metadata: &std::fs::Metadata,
461) -> Option<Problem> {
462    if !metadata.is_file() {
463        return Some(Problem {
464            path: file.path.clone(),
465            detail: "expected a regular file".to_string(),
466        });
467    }
468    let Some(expected) = file.sha256.as_ref() else {
469        return Some(Problem {
470            path: file.path.clone(),
471            detail: "regular file has no sha256 digest".to_string(),
472        });
473    };
474    match sha256_file(target) {
475        Ok((actual, size)) if &actual.0 == expected && size == file.size => None,
476        Ok((_actual, size)) if size != file.size => Some(Problem {
477            path: file.path.clone(),
478            detail: format!("size is {size} bytes, expected {}", file.size),
479        }),
480        Ok((actual, _)) => Some(Problem {
481            path: file.path.clone(),
482            detail: format!("sha256 mismatch (found {}, expected {expected})", actual.0),
483        }),
484        Err(e) => Some(Problem {
485            path: file.path.clone(),
486            detail: format!("unreadable: {e}"),
487        }),
488    }
489}
490
491/// Compare recorded and actual permission bits.
492fn mode_problem(file: &ManifestFile, metadata: &std::fs::Metadata) -> Option<Problem> {
493    use std::os::unix::fs::PermissionsExt;
494    let expected = u32::from_str_radix(&file.mode, 8).ok()?;
495    let actual = metadata.permissions().mode() & 0o7777;
496    (actual != expected).then(|| Problem {
497        path: file.path.clone(),
498        detail: format!("mode is {actual:04o}, expected {expected:04o}"),
499    })
500}
501
502/// What `verify` should check beyond "every recorded entry still matches".
503#[derive(Debug, Default, Clone, Copy)]
504pub struct VerifyOptions {
505    /// Also fail on files present in the rootfs but absent from the manifest,
506    /// and on entries whose permission bits changed.
507    pub strict: bool,
508}
509
510#[derive(Debug, Default)]
511pub struct VerifyReport {
512    pub checked: u32,
513    /// Entries found in the rootfs that the manifest does not list.
514    pub unexpected: u32,
515    pub problems: Vec<Problem>,
516}
517
518#[derive(Debug)]
519pub struct Problem {
520    pub path: String,
521    pub detail: String,
522}
523
524impl VerifyReport {
525    pub fn is_ok(&self) -> bool {
526        self.problems.is_empty()
527    }
528
529    /// Problems found, saturated at `u32::MAX`.
530    pub fn failure_count(&self) -> u32 {
531        u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
532    }
533}