1use 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;
15pub 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 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 #[serde(skip_serializing_if = "Option::is_none")]
30 pub rootfs: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub tar: Option<String>,
34 #[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 #[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 #[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 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 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 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 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 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 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(¤t) else {
326 continue;
327 };
328 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 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 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
380fn 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
406fn 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
428fn 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
456fn 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
491fn 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#[derive(Debug, Default, Clone, Copy)]
504pub struct VerifyOptions {
505 pub strict: bool,
508}
509
510#[derive(Debug, Default)]
511pub struct VerifyReport {
512 pub checked: u32,
513 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 pub fn failure_count(&self) -> u32 {
531 u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
532 }
533}