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