Skip to main content

silicon_apps_package/
lib.rs

1//! Portable, deterministic Silicon Apps archives. No function executes package content.
2use 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
61/// Historical Accounts apps (for example `dm`) retain their existing identifiers.
62/// New app creation still uses [`valid_app_id`]; manifests reference an existing app.
63pub 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
116/// Filesystem paths use native separators; archive and manifest paths always use `/`.
117fn 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
179// Parse fields independently so a missing/incorrect field does not hide other errors.
180fn 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    // Do not repeat a semantic error for a field already rejected structurally.
294    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
388/// Build identical bytes for identical file content and executable modes, independent of timestamps.
389pub 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
446/// Inspect without executing any content. Reject path traversal, duplicate entries, hard/symlinks,
447/// device files, bombs and missing target artifacts before returning the manifest.
448pub fn inspect_archive(bytes: &[u8]) -> Result<Manifest> {
449    let temp = tempfile::tempdir()?;
450    extract_archive(bytes, temp.path())
451}
452
453/// Extract into a new, empty directory owned by the caller. Never follows archive links.
454pub 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/// An install script as packaged for one target.
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544pub struct InstallScript {
545    /// The script's path inside the package, as written in apps.yaml.
546    pub path: String,
547    /// SHA-256 of the script's exact bytes, lowercase hex.
548    pub sha256: String,
549    pub size: u64,
550}
551/// Install scripts larger than this are refused when read for display or signing.
552pub const MAX_INSTALL_SCRIPT_BYTES: u64 = 16 * 1024 * 1024;
553
554/// Read one regular file from a `.tar.gz` without extracting anything else.
555/// Returns `None` when the archive has no regular file at `path`. Leading `./`
556/// segments are ignored on both sides.
557pub 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
589/// The install script `target` runs, with its digest and bytes, or `None`
590/// when the target has no install script. Check the archive with
591/// [`inspect_archive`] first; this reads only the script.
592pub 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}