1use anyhow::{Context, Result, bail, ensure};
3use flate2::{Compression, read::GzDecoder, write::GzEncoder};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::{
7 collections::{BTreeMap, BTreeSet},
8 fs,
9 io::{Cursor, Read},
10 path::{Component, Path},
11};
12
13pub const TARGETS: [&str; 9] = [
14 "linux-x86_64",
15 "linux-i686",
16 "linux-aarch64",
17 "linux-armv7hf",
18 "windows-x86_64",
19 "windows-i686",
20 "windows-aarch64",
21 "macos-x86_64",
22 "macos-aarch64",
23];
24pub const MAX_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024;
25pub const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024;
26pub const MAX_ENTRIES: usize = 20_000;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Manifest {
31 #[serde(default = "schema_version")]
32 pub schema_version: u32,
33 pub app_id: String,
34 pub version: String,
35 pub command: String,
36 pub targets: BTreeMap<String, Target>,
37}
38fn schema_version() -> u32 {
39 1
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Target {
45 pub binary: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub install_script: Option<String>,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct ValidationReport {
52 pub valid: bool,
53 pub errors: Vec<String>,
54 pub manifest: Option<Manifest>,
55}
56
57pub fn valid_app_id(value: &str) -> bool {
58 value.len() >= 3 && valid_existing_app_id(value)
59}
60
61pub fn valid_existing_app_id(value: &str) -> bool {
64 (1..=30).contains(&value.len())
65 && value
66 .bytes()
67 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
68}
69
70pub fn strict_version(value: &str) -> bool {
71 semver::Version::parse(value)
72 .is_ok_and(|v| v.pre.is_empty() && v.build.is_empty() && v.to_string() == value)
73}
74
75pub fn safe_path(value: &Path) -> bool {
76 !value.as_os_str().is_empty()
77 && value.components().all(|c| match c {
78 Component::Normal(part) => {
79 let part = part.to_string_lossy();
80 let base = part.split('.').next().unwrap_or("").to_ascii_uppercase();
81 !part.ends_with(['.', ' '])
82 && !part.chars().any(|c| c.is_control())
83 && !matches!(
84 base.as_str(),
85 "CON"
86 | "PRN"
87 | "AUX"
88 | "NUL"
89 | "COM1"
90 | "COM2"
91 | "COM3"
92 | "COM4"
93 | "COM5"
94 | "COM6"
95 | "COM7"
96 | "COM8"
97 | "COM9"
98 | "LPT1"
99 | "LPT2"
100 | "LPT3"
101 | "LPT4"
102 | "LPT5"
103 | "LPT6"
104 | "LPT7"
105 | "LPT8"
106 | "LPT9"
107 )
108 }
109 _ => false,
110 })
111 && !value
112 .to_string_lossy()
113 .contains(['\\', ':', '\0', '<', '>', '"', '|', '?', '*'])
114}
115
116fn portable_filesystem_path(path: &Path) -> Result<String> {
118 path.components()
119 .map(|component| match component {
120 Component::Normal(part) => part
121 .to_str()
122 .map(str::to_owned)
123 .context("package filenames must be valid UTF-8"),
124 _ => bail!("package file path must contain only relative normal components"),
125 })
126 .collect::<Result<Vec<_>>>()
127 .map(|parts| parts.join("/"))
128}
129
130impl Manifest {
131 pub fn errors(&self) -> Vec<String> {
132 let mut errors = vec![];
133 if self.schema_version != 1 {
134 errors.push("schema_version: expected 1".into());
135 }
136 if !valid_existing_app_id(&self.app_id) {
137 errors.push(
138 "app_id: expected an existing 1–30 character lowercase app identifier; new IDs require at least 3 characters".into(),
139 );
140 }
141 if !strict_version(&self.version) {
142 errors.push("version: expected x.y.z without prerelease or build metadata".into());
143 }
144 if self.command.is_empty()
145 || self.command.len() > 80
146 || !safe_path(Path::new(&self.command))
147 || !self
148 .command
149 .bytes()
150 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
151 {
152 errors.push("command: expected 1–80 letters, digits, hyphens or underscores, without a path or extension".into());
153 }
154 if self.targets.is_empty() {
155 errors.push("targets: at least one supported target is required".into());
156 }
157 for (name, target) in &self.targets {
158 if !TARGETS.contains(&name.as_str()) {
159 errors.push(format!(
160 "targets.{name}: unsupported target; choose from {}",
161 TARGETS.join(", ")
162 ));
163 }
164 for (field, path) in [
165 ("binary", Some(&target.binary)),
166 ("install_script", target.install_script.as_ref()),
167 ] {
168 if let Some(path) = path
169 && !safe_path(Path::new(path))
170 {
171 errors.push(format!("targets.{name}.{field}: `{path}` must be a relative path without parent segments, drive prefixes or backslashes"));
172 }
173 }
174 }
175 errors
176 }
177}
178
179fn parse_manifest(bytes: &[u8]) -> (Option<Manifest>, Vec<String>) {
181 use serde_yaml::Value;
182 let value: Value = match serde_yaml::from_slice(bytes) {
183 Ok(value) => value,
184 Err(error) => return (None, vec![format!("apps.yaml: invalid YAML: {error}")]),
185 };
186 let Some(root) = value.as_mapping() else {
187 return (
188 None,
189 vec!["apps.yaml: expected a mapping of manifest fields".into()],
190 );
191 };
192 fn unknown(map: &serde_yaml::Mapping, fields: &[&str], prefix: &str, errors: &mut Vec<String>) {
193 for key in map.keys() {
194 if !key.as_str().is_some_and(|key| fields.contains(&key)) {
195 errors.push(format!(
196 "{prefix}: unknown field {}",
197 key.as_str().unwrap_or("<non-string key>")
198 ));
199 }
200 }
201 }
202 fn string(
203 map: &serde_yaml::Mapping,
204 key: &str,
205 prefix: &str,
206 errors: &mut Vec<String>,
207 ) -> String {
208 match map.get(Value::String(key.into())).and_then(Value::as_str) {
209 Some(value) => value.to_owned(),
210 None => {
211 errors.push(format!(
212 "{prefix}{key}: required string is missing or has the wrong type"
213 ));
214 String::new()
215 }
216 }
217 }
218 let mut errors = Vec::new();
219 unknown(
220 root,
221 &["schema_version", "app_id", "version", "command", "targets"],
222 "apps.yaml",
223 &mut errors,
224 );
225 let schema_version = match root.get(Value::String("schema_version".into())) {
226 None => 1,
227 Some(value) => match value.as_u64().and_then(|n| u32::try_from(n).ok()) {
228 Some(value) => value,
229 None => {
230 errors.push("schema_version: expected a positive integer".into());
231 1
232 }
233 },
234 };
235 let app_id = string(root, "app_id", "", &mut errors);
236 let version = string(root, "version", "", &mut errors);
237 let command = string(root, "command", "", &mut errors);
238 let mut targets = BTreeMap::new();
239 match root
240 .get(Value::String("targets".into()))
241 .and_then(Value::as_mapping)
242 {
243 Some(map) => {
244 for (name, value) in map {
245 let Some(name) = name.as_str() else {
246 errors.push("targets: every target name must be a string".into());
247 continue;
248 };
249 let Some(fields) = value.as_mapping() else {
250 errors.push(format!(
251 "targets.{name}: expected binary and optional install_script fields"
252 ));
253 continue;
254 };
255 unknown(
256 fields,
257 &["binary", "install_script"],
258 &format!("targets.{name}"),
259 &mut errors,
260 );
261 let binary = string(fields, "binary", &format!("targets.{name}."), &mut errors);
262 let install_script = match fields.get(Value::String("install_script".into())) {
263 None | Some(Value::Null) => None,
264 Some(value) => match value.as_str() {
265 Some(value) => Some(value.into()),
266 None => {
267 errors
268 .push(format!("targets.{name}.install_script: expected a string"));
269 None
270 }
271 },
272 };
273 targets.insert(
274 name.into(),
275 Target {
276 binary,
277 install_script,
278 },
279 );
280 }
281 }
282 None => {
283 errors.push("targets: required target mapping is missing or has the wrong type".into())
284 }
285 }
286 let manifest = Manifest {
287 schema_version,
288 app_id,
289 version,
290 command,
291 targets,
292 };
293 for error in manifest.errors() {
295 let field = error.split(':').next().unwrap_or("");
296 if !errors
297 .iter()
298 .any(|existing| existing.starts_with(&format!("{field}:")))
299 {
300 errors.push(error);
301 }
302 }
303 (Some(manifest), errors)
304}
305
306pub fn validate_directory(root: &Path) -> ValidationReport {
307 let mut report = ValidationReport::default();
308 if !root.is_dir() {
309 report
310 .errors
311 .push(format!("{}: not a directory", root.display()));
312 return report;
313 }
314 let (manifest, errors) = match fs::read(root.join("apps.yaml")) {
315 Ok(bytes) => parse_manifest(&bytes),
316 Err(error) => (
317 None,
318 vec![format!("apps.yaml: cannot read required manifest: {error}")],
319 ),
320 };
321 report.errors.extend(errors);
322 if let Some(manifest) = &manifest {
323 for (name, target) in &manifest.targets {
324 for (field, path) in [
325 ("binary", Some(&target.binary)),
326 ("install_script", target.install_script.as_ref()),
327 ] {
328 if let Some(path) = path
329 && safe_path(Path::new(path))
330 {
331 match fs::symlink_metadata(root.join(path)) {
332 Ok(meta) if meta.file_type().is_file() => {}
333 _ => report.errors.push(format!(
334 "targets.{name}.{field}: `{path}` must be an existing regular file"
335 )),
336 }
337 }
338 }
339 }
340 }
341 let mut size = 0;
342 let mut count = 0;
343 for entry in walkdir::WalkDir::new(root).follow_links(false).min_depth(1) {
344 match entry {
345 Ok(e) => {
346 count += 1;
347 let portable =
348 portable_filesystem_path(e.path().strip_prefix(root).unwrap_or(e.path()));
349 if !portable
350 .as_ref()
351 .is_ok_and(|path| safe_path(Path::new(path)))
352 {
353 report.errors.push(format!(
354 "{}: unsafe cross-platform package path",
355 e.path().display()
356 ));
357 }
358 if e.file_type().is_symlink()
359 || !(e.file_type().is_file() || e.file_type().is_dir())
360 {
361 report.errors.push(format!(
362 "{}: links and special files are not allowed",
363 e.path().display()
364 ));
365 }
366 if let Ok(m) = e.metadata() {
367 size += m.len();
368 }
369 }
370 Err(e) => report.errors.push(e.to_string()),
371 }
372 }
373 if count > MAX_ENTRIES {
374 report.errors.push(format!(
375 "archive has {count} entries; maximum is {MAX_ENTRIES}"
376 ));
377 }
378 if size > MAX_EXTRACTED_BYTES {
379 report.errors.push(format!(
380 "uncompressed package exceeds {MAX_EXTRACTED_BYTES} bytes"
381 ));
382 }
383 report.valid = report.errors.is_empty();
384 report.manifest = manifest;
385 report
386}
387
388pub fn pack_directory(root: &Path) -> Result<Vec<u8>> {
390 let report = validate_directory(root);
391 ensure!(
392 report.valid,
393 "package validation failed:\n{}",
394 report.errors.join("\n")
395 );
396 let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
397 let mut paths = walkdir::WalkDir::new(root)
398 .follow_links(false)
399 .min_depth(1)
400 .into_iter()
401 .collect::<std::result::Result<Vec<_>, _>>()?;
402 paths.sort_by(|a, b| a.path().cmp(b.path()));
403 for entry in paths {
404 if !entry.file_type().is_file() {
405 continue;
406 }
407 let relative = portable_filesystem_path(entry.path().strip_prefix(root)?)?;
408 ensure!(
409 safe_path(Path::new(&relative)),
410 "unsafe archive path {}",
411 relative
412 );
413 let mut file = fs::File::open(entry.path())?;
414 let metadata = file.metadata()?;
415 let mut header = tar::Header::new_gnu();
416 header.set_size(metadata.len());
417 header.set_uid(0);
418 header.set_gid(0);
419 header.set_mtime(0);
420 #[cfg(unix)]
421 {
422 use std::os::unix::fs::PermissionsExt;
423 header.set_mode(if metadata.permissions().mode() & 0o111 != 0 {
424 0o755
425 } else {
426 0o644
427 });
428 }
429 #[cfg(not(unix))]
430 header.set_mode(0o644);
431 header.set_cksum();
432 archive.append_data(&mut header, &relative, &mut file)?;
433 }
434 let bytes = archive.into_inner()?.finish()?;
435 ensure!(
436 bytes.len() as u64 <= MAX_ARCHIVE_BYTES,
437 "compressed package exceeds {MAX_ARCHIVE_BYTES} bytes"
438 );
439 Ok(bytes)
440}
441
442pub fn sha256(bytes: &[u8]) -> String {
443 hex::encode(Sha256::digest(bytes))
444}
445
446pub fn inspect_archive(bytes: &[u8]) -> Result<Manifest> {
449 let temp = tempfile::tempdir()?;
450 extract_archive(bytes, temp.path())
451}
452
453pub fn extract_archive(bytes: &[u8], destination: &Path) -> Result<Manifest> {
455 ensure!(
456 bytes.len() as u64 <= MAX_ARCHIVE_BYTES,
457 "compressed package exceeds {MAX_ARCHIVE_BYTES} bytes"
458 );
459 ensure!(
460 destination.is_dir(),
461 "{}: extraction destination is not a directory",
462 destination.display()
463 );
464 ensure!(
465 fs::read_dir(destination)?.next().is_none(),
466 "extraction destination must be empty"
467 );
468 ensure!(
469 !fs::symlink_metadata(destination)?.file_type().is_symlink(),
470 "extraction destination must not be a symlink"
471 );
472 let reader = GzDecoder::new(Cursor::new(bytes))
473 .take(MAX_EXTRACTED_BYTES + (MAX_ENTRIES as u64 * 1024) + 1);
474 let mut archive = tar::Archive::new(reader);
475 let mut seen = BTreeSet::new();
476 let mut total = 0u64;
477 for entry in archive
478 .entries()
479 .context("package must be a valid .tar.gz")?
480 {
481 let mut entry = entry.context("invalid tar entry")?;
482 let path = entry.path()?.into_owned();
483 ensure!(safe_path(&path), "unsafe archive path {}", path.display());
484 ensure!(
485 seen.insert(path.clone()),
486 "duplicate archive path {}",
487 path.display()
488 );
489 ensure!(
490 seen.len() <= MAX_ENTRIES,
491 "archive contains too many entries"
492 );
493 let kind = entry.header().entry_type();
494 ensure!(
495 kind.is_file() || kind.is_dir(),
496 "{}: links and special archive files are not allowed",
497 path.display()
498 );
499 total = total
500 .checked_add(entry.size())
501 .context("archive size overflow")?;
502 ensure!(
503 total <= MAX_EXTRACTED_BYTES,
504 "uncompressed archive is too large"
505 );
506 let output = destination.join(&path);
507 if kind.is_dir() {
508 fs::create_dir_all(&output)?;
509 continue;
510 }
511 fs::create_dir_all(output.parent().context("file has no parent")?)?;
512 let mut out = fs::OpenOptions::new()
513 .write(true)
514 .create_new(true)
515 .open(&output)?;
516 std::io::copy(&mut entry, &mut out)?;
517 #[cfg(unix)]
518 {
519 use std::os::unix::fs::PermissionsExt;
520 fs::set_permissions(
521 &output,
522 fs::Permissions::from_mode(if entry.header().mode()? & 0o111 != 0 {
523 0o755
524 } else {
525 0o644
526 }),
527 )?;
528 }
529 }
530 let report = validate_directory(destination);
531 ensure!(
532 report.valid,
533 "package validation failed:\n{}",
534 report.errors.join("\n")
535 );
536 match report.manifest {
537 Some(m) => Ok(m),
538 None => bail!("apps.yaml: missing manifest"),
539 }
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544pub struct InstallScript {
545 pub path: String,
547 pub sha256: String,
549 pub size: u64,
550}
551pub const MAX_INSTALL_SCRIPT_BYTES: u64 = 16 * 1024 * 1024;
553
554pub fn read_archive_file(bytes: &[u8], path: &str, limit: u64) -> Result<Option<Vec<u8>>> {
558 fn normal(path: &str) -> String {
559 path.split('/')
560 .filter(|part| !part.is_empty() && *part != ".")
561 .collect::<Vec<_>>()
562 .join("/")
563 }
564 let wanted = normal(path);
565 let reader = GzDecoder::new(Cursor::new(bytes))
566 .take(MAX_EXTRACTED_BYTES + (MAX_ENTRIES as u64 * 1024) + 1);
567 let mut archive = tar::Archive::new(reader);
568 for entry in archive
569 .entries()
570 .context("package must be a valid .tar.gz")?
571 {
572 let mut entry = entry.context("invalid tar entry")?;
573 let entry_path = entry.path()?.to_string_lossy().replace('\\', "/");
574 if normal(&entry_path) != wanted || !entry.header().entry_type().is_file() {
575 continue;
576 }
577 ensure!(
578 entry.size() <= limit,
579 "{path} is {} bytes, more than the {limit} byte limit",
580 entry.size()
581 );
582 let mut content = Vec::with_capacity(entry.size() as usize);
583 entry.read_to_end(&mut content)?;
584 return Ok(Some(content));
585 }
586 Ok(None)
587}
588
589pub fn install_script(
593 bytes: &[u8],
594 manifest: &Manifest,
595 target: &str,
596) -> Result<Option<(InstallScript, Vec<u8>)>> {
597 let Some(path) = manifest
598 .targets
599 .get(target)
600 .with_context(|| format!("the package does not describe target {target}"))?
601 .install_script
602 .clone()
603 else {
604 return Ok(None);
605 };
606 let content =
607 read_archive_file(bytes, &path, MAX_INSTALL_SCRIPT_BYTES)?.with_context(|| {
608 format!("apps.yaml names install script {path}, but the archive has no such file")
609 })?;
610 Ok(Some((
611 InstallScript {
612 path,
613 sha256: sha256(&content),
614 size: content.len() as u64,
615 },
616 content,
617 )))
618}
619
620pub fn current_target() -> Result<&'static str> {
621 let os = std::env::consts::OS;
622 let arch = std::env::consts::ARCH;
623 match (os, arch) {
624 ("linux", "x86_64") => Ok("linux-x86_64"),
625 ("linux", "x86") => Ok("linux-i686"),
626 ("linux", "aarch64") => Ok("linux-aarch64"),
627 ("linux", "arm") => Ok("linux-armv7hf"),
628 ("windows", "x86_64") => Ok("windows-x86_64"),
629 ("windows", "x86") => Ok("windows-i686"),
630 ("windows", "aarch64") => Ok("windows-aarch64"),
631 ("macos", "x86_64") => Ok("macos-x86_64"),
632 ("macos", "aarch64") => Ok("macos-aarch64"),
633 _ => bail!(
634 "unsupported OS/architecture {os}-{arch}; supported targets: {}",
635 TARGETS.join(", ")
636 ),
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 #[test]
644 fn validation_reports_all_fields() {
645 let m = Manifest {
646 schema_version: 8,
647 app_id: "X".into(),
648 version: "1".into(),
649 command: "../../bad".into(),
650 targets: BTreeMap::new(),
651 };
652 assert_eq!(m.errors().len(), 5);
653 }
654 #[test]
655 fn validates_every_target_and_reports_unreferenced_unsafe_files() {
656 let dir = tempfile::tempdir().unwrap();
657 let manifest = Manifest {
658 schema_version: 1,
659 app_id: "portable".into(),
660 version: "1.2.3".into(),
661 command: "portable".into(),
662 targets: TARGETS
663 .iter()
664 .map(|target| {
665 (
666 target.to_string(),
667 Target {
668 binary: format!(
669 "bin/{target}/portable{}",
670 if target.starts_with("windows") {
671 ".exe"
672 } else {
673 ""
674 }
675 ),
676 install_script: None,
677 },
678 )
679 })
680 .collect(),
681 };
682 fs::write(
683 dir.path().join("apps.yaml"),
684 serde_yaml::to_string(&manifest).unwrap(),
685 )
686 .unwrap();
687 for target in manifest.targets.values() {
688 let binary = dir.path().join(&target.binary);
689 fs::create_dir_all(binary.parent().unwrap()).unwrap();
690 fs::write(binary, b"compiled target fixture").unwrap();
691 }
692 let archive = pack_directory(dir.path()).unwrap();
693 assert_eq!(inspect_archive(&archive).unwrap().targets.len(), 9);
694 #[cfg(unix)]
695 {
696 fs::write(dir.path().join("NUL.txt"), b"not portable").unwrap();
697 let report = validate_directory(dir.path());
698 assert!(!report.valid);
699 assert!(
700 report
701 .errors
702 .iter()
703 .any(|error| error.contains("unsafe cross-platform package path"))
704 );
705 }
706 }
707
708 #[test]
709 fn paths_are_cross_platform_safe() {
710 assert_eq!(
711 portable_filesystem_path(&Path::new("bin").join("native").join("apps")).unwrap(),
712 "bin/native/apps"
713 );
714 for p in ["../evil", "/evil", "bin/../evil", "C:evil", "bin\\evil", ""] {
715 assert!(!safe_path(Path::new(p)), "{p}");
716 }
717 for p in [
718 "bin/NUL.exe",
719 "bin/COM1",
720 "bin/trailing.",
721 "bin/trailing ",
722 "bin/has?mark",
723 ] {
724 assert!(!safe_path(Path::new(p)), "{p}");
725 }
726 assert!(safe_path(Path::new("bin/cli")));
727 }
728 #[test]
729 fn deterministic_roundtrip_and_missing_binary() {
730 let dir = tempfile::tempdir().unwrap();
731 fs::write(dir.path().join("apps.yaml"), "schema_version: 1\napp_id: example\nversion: 1.2.3\ncommand: example\ntargets:\n linux-x86_64:\n binary: example\n").unwrap();
732 assert!(!validate_directory(dir.path()).valid);
733 fs::write(dir.path().join("example"), "hello").unwrap();
734 let first = pack_directory(dir.path()).unwrap();
735 assert_eq!(first, pack_directory(dir.path()).unwrap());
736 assert_eq!(inspect_archive(&first).unwrap().app_id, "example");
737 }
738 #[test]
739 fn reads_the_install_script_of_a_target_without_extracting() {
740 let dir = tempfile::tempdir().unwrap();
741 fs::write(dir.path().join("apps.yaml"), "schema_version: 1\napp_id: example\nversion: 1.2.3\ncommand: example\ntargets:\n linux-x86_64:\n binary: example\n install_script: scripts/setup.sh\n macos-aarch64:\n binary: example\n").unwrap();
742 fs::write(dir.path().join("example"), "hello").unwrap();
743 fs::create_dir_all(dir.path().join("scripts")).unwrap();
744 fs::write(
745 dir.path().join("scripts/setup.sh"),
746 "#!/bin/sh\necho setup\n",
747 )
748 .unwrap();
749 let bytes = pack_directory(dir.path()).unwrap();
750 let manifest = inspect_archive(&bytes).unwrap();
751 let (script, content) = install_script(&bytes, &manifest, "linux-x86_64")
752 .unwrap()
753 .unwrap();
754 assert_eq!(content, b"#!/bin/sh\necho setup\n");
755 assert_eq!(script.path, "scripts/setup.sh");
756 assert_eq!(script.sha256, sha256(b"#!/bin/sh\necho setup\n"));
757 assert_eq!(script.size, 21);
758 assert!(
759 install_script(&bytes, &manifest, "macos-aarch64")
760 .unwrap()
761 .is_none()
762 );
763 assert!(install_script(&bytes, &manifest, "windows-x86_64").is_err());
764 assert_eq!(
765 read_archive_file(&bytes, "./scripts/setup.sh", 1024)
766 .unwrap()
767 .unwrap(),
768 content
769 );
770 assert!(
771 read_archive_file(&bytes, "missing", 1024)
772 .unwrap()
773 .is_none()
774 );
775 assert!(read_archive_file(&bytes, "scripts/setup.sh", 4).is_err());
776 }
777 #[test]
778 fn rejects_symlink_archive() {
779 let mut a = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
780 let mut h = tar::Header::new_gnu();
781 h.set_size(0);
782 h.set_mode(0o777);
783 h.set_entry_type(tar::EntryType::Symlink);
784 h.set_link_name("/tmp").unwrap();
785 h.set_cksum();
786 a.append_data(&mut h, "escape", Cursor::new([])).unwrap();
787 let bytes = a.into_inner().unwrap().finish().unwrap();
788 assert!(
789 inspect_archive(&bytes)
790 .unwrap_err()
791 .to_string()
792 .contains("links")
793 );
794 }
795}