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 = 3;
15const MANIFEST_SHA256_VERSION: u32 = 2;
16pub 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 pub binary: String,
25 #[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 #[serde(skip_serializing_if = "Option::is_none")]
34 pub rootfs: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub tar: Option<String>,
38 #[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 #[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 #[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 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 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 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 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 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 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(¤t) else {
386 continue;
387 };
388 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 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 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
440fn 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
466fn 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
488fn 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
516fn 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
551fn 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#[derive(Debug, Default, Clone, Copy)]
564pub struct VerifyOptions {
565 pub strict: bool,
568}
569
570#[derive(Debug, Default)]
571pub struct VerifyReport {
572 pub checked: u32,
573 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 pub fn failure_count(&self) -> u32 {
591 u32::try_from(self.problems.len()).unwrap_or(u32::MAX)
592 }
593}