Skip to main content

nexo_ext_installer/
extract.rs

1//! Extract a sha-verified plugin tarball into the
2//! daemon's plugin discovery directory.
3//!
4//! Pipeline:
5//! 1. Idempotent re-install check (`<dest_root>/<id>-<version>/`
6//!    already present + manifest matches expected).
7//! 2. Cleanup of any stray `.staging-*` from prior crashed runs.
8//! 3. Allocate a unique staging dir.
9//! 4. Stream-extract the tarball (sync, behind `spawn_blocking`),
10//!    enforcing per-entry path safety + entry-type whitelist +
11//!    size budgets.
12//! 5. Re-parse `nexo-plugin.toml` from staging and validate
13//!    `id`/`version` match the resolver-advertised entry; reject
14//!    on mismatch.
15//! 6. Verify `bin/<id>` exists and is executable on Unix.
16//! 7. Atomic-rename staging → final dir.
17//!
18//! The crate intentionally does NOT read configuration: the
19//! caller resolves `dest_root` from
20//! `plugins.discovery.search_paths[0]` and passes it in.
21#![allow(clippy::result_large_err)]
22
23use std::fs;
24use std::path::{Component, Path, PathBuf};
25
26use flate2::read::GzDecoder;
27use nexo_ext_registry::ExtEntry;
28use nexo_plugin_manifest::PluginManifest;
29use rand::Rng;
30use tar::{Archive, EntryType};
31
32use crate::extract_error::ExtractError;
33
34// ── Defaults exposed for `ExtractLimits` ───────────────────────────
35
36/// Default cap on the compressed tarball file size.
37pub const MAX_TARBALL_BYTES: u64 = 100 * 1024 * 1024;
38
39/// Default cap on the number of entries (files + dirs) inside a tarball.
40pub const MAX_ENTRIES: u64 = 10_000;
41
42/// Default cap on the sum of all entry sizes after extraction.
43pub const MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024;
44
45/// Default cap on a single entry's header-declared size.
46pub const MAX_ENTRY_BYTES: u64 = 100 * 1024 * 1024;
47
48const STAGING_PREFIX: &str = ".staging-";
49const MANIFEST_FILE: &str = "nexo-plugin.toml";
50const BIN_DIR: &str = "bin";
51
52/// Bounds applied during extraction. Defaults are suitable for
53/// the plugin tarballs we expect (a single binary + manifest +
54/// optional small assets).
55#[derive(Debug, Clone)]
56pub struct ExtractLimits {
57    /// Maximum compressed tarball size.
58    pub max_tarball_bytes: u64,
59    /// Maximum number of entries.
60    pub max_entries: u64,
61    /// Maximum sum of all entry sizes after extraction.
62    pub max_extracted_bytes: u64,
63    /// Maximum header-declared size of any single entry.
64    pub max_entry_bytes: u64,
65}
66
67impl Default for ExtractLimits {
68    fn default() -> Self {
69        Self {
70            max_tarball_bytes: MAX_TARBALL_BYTES,
71            max_entries: MAX_ENTRIES,
72            max_extracted_bytes: MAX_EXTRACTED_BYTES,
73            max_entry_bytes: MAX_ENTRY_BYTES,
74        }
75    }
76}
77
78/// Inputs to [`extract_verified_tarball`].
79#[derive(Debug)]
80pub struct ExtractInput<'a> {
81    /// Path to the verified `.tar.gz` on disk.
82    pub tarball_path: &'a Path,
83    /// Root dir under which `<plugin_id>-<version>/` will be created.
84    pub dest_root: &'a Path,
85    /// Plugin entry the resolver built. Used as the source of
86    /// truth for expected id + version + binary name.
87    pub expected: &'a ExtEntry,
88    /// Limits applied during extraction.
89    pub limits: ExtractLimits,
90}
91
92/// Successful extraction result.
93#[derive(Debug, Clone)]
94pub struct ExtractedPlugin {
95    /// Final on-disk dir: `<dest_root>/<plugin_id>-<version>/`.
96    pub plugin_dir: PathBuf,
97    /// Manifest re-parsed from `<plugin_dir>/nexo-plugin.toml`.
98    pub manifest: PluginManifest,
99    /// Path to the executable: `<plugin_dir>/bin/<plugin_id>`.
100    pub binary_path: PathBuf,
101    /// `true` when the dir was already present (idempotent re-install).
102    pub was_already_present: bool,
103}
104
105// ── Public API ─────────────────────────────────────────────────────
106
107/// Extract a verified `.tar.gz` into `dest_root`, validating
108/// content against `expected`.
109pub async fn extract_verified_tarball(
110    input: ExtractInput<'_>,
111) -> Result<ExtractedPlugin, ExtractError> {
112    let ExtractInput {
113        tarball_path,
114        dest_root,
115        expected,
116        limits,
117    } = input;
118
119    let final_dir = dest_root.join(format!("{}-{}", expected.id, expected.version));
120    let binary_path = final_dir.join(BIN_DIR).join(&expected.id);
121
122    // Idempotent re-install check.
123    if final_dir.join(MANIFEST_FILE).exists() {
124        let manifest = parse_manifest(&final_dir.join(MANIFEST_FILE))?;
125        check_manifest_matches(&manifest, expected)?;
126        return Ok(ExtractedPlugin {
127            plugin_dir: final_dir,
128            manifest,
129            binary_path,
130            was_already_present: true,
131        });
132    }
133
134    // Ensure dest_root exists; cleanup stale staging dirs.
135    fs::create_dir_all(dest_root).map_err(|e| ExtractError::Io(e.to_string()))?;
136    cleanup_stale_staging(dest_root)?;
137
138    // Allocate unique staging dir.
139    let suffix: u64 = rand::thread_rng().gen();
140    let staging_dir = dest_root.join(format!("{}{}-{:x}", STAGING_PREFIX, expected.id, suffix));
141    fs::create_dir_all(&staging_dir).map_err(|e| ExtractError::Io(e.to_string()))?;
142
143    // Run sync extract under spawn_blocking. Owned copies for the
144    // closure; tarball + staging paths cloned to satisfy 'static.
145    let extract_outcome = {
146        let tarball = tarball_path.to_path_buf();
147        let staging = staging_dir.clone();
148        let limits = limits.clone();
149        tokio::task::spawn_blocking(move || extract_to_staging(&tarball, &staging, &limits)).await
150    };
151
152    if let Err(e) = match extract_outcome {
153        Ok(inner) => inner,
154        Err(join) => Err(ExtractError::JoinError(join.to_string())),
155    } {
156        let _ = fs::remove_dir_all(&staging_dir);
157        return Err(e);
158    }
159
160    // Re-parse manifest from staging and validate.
161    let staging_manifest_path = staging_dir.join(MANIFEST_FILE);
162    let manifest = match parse_manifest(&staging_manifest_path) {
163        Ok(m) => m,
164        Err(e) => {
165            let _ = fs::remove_dir_all(&staging_dir);
166            return Err(e);
167        }
168    };
169    if let Err(e) = check_manifest_matches(&manifest, expected) {
170        let _ = fs::remove_dir_all(&staging_dir);
171        return Err(e);
172    }
173
174    // Verify expected binary path.
175    let staging_binary = staging_dir.join(BIN_DIR).join(&expected.id);
176    if !staging_binary.exists() {
177        let _ = fs::remove_dir_all(&staging_dir);
178        return Err(ExtractError::BinaryMissing { path: binary_path });
179    }
180    chmod_executable(&staging_binary);
181
182    // Atomic rename staging → final.
183    if let Err(e) = fs::rename(&staging_dir, &final_dir) {
184        let _ = fs::remove_dir_all(&staging_dir);
185        return Err(ExtractError::Io(format!(
186            "rename staging → final failed: {}",
187            e
188        )));
189    }
190
191    Ok(ExtractedPlugin {
192        plugin_dir: final_dir,
193        manifest,
194        binary_path,
195        was_already_present: false,
196    })
197}
198
199// ── Helpers ────────────────────────────────────────────────────────
200
201fn validate_entry_path(p: &Path) -> Result<(), ExtractError> {
202    let display = p.display().to_string();
203    if p.is_absolute() {
204        return Err(ExtractError::UnsafePath {
205            path: display,
206            reason: "entry path is absolute",
207        });
208    }
209    for c in p.components() {
210        match c {
211            Component::Normal(s) => {
212                let s = s.to_str().ok_or(ExtractError::UnsafePath {
213                    path: display.clone(),
214                    reason: "entry component is not valid UTF-8",
215                })?;
216                if s.contains('\0') {
217                    return Err(ExtractError::UnsafePath {
218                        path: display,
219                        reason: "entry component contains NUL byte",
220                    });
221                }
222            }
223            Component::ParentDir => {
224                return Err(ExtractError::UnsafePath {
225                    path: display,
226                    reason: "entry contains parent component (`..`)",
227                });
228            }
229            Component::RootDir => {
230                return Err(ExtractError::UnsafePath {
231                    path: display,
232                    reason: "entry contains root separator",
233                });
234            }
235            Component::Prefix(_) => {
236                return Err(ExtractError::UnsafePath {
237                    path: display,
238                    reason: "entry contains windows path prefix",
239                });
240            }
241            Component::CurDir => {}
242        }
243    }
244    Ok(())
245}
246
247fn cleanup_stale_staging(dest_root: &Path) -> Result<(), ExtractError> {
248    let read = match fs::read_dir(dest_root) {
249        Ok(r) => r,
250        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
251        Err(e) => return Err(ExtractError::Io(e.to_string())),
252    };
253    for entry in read {
254        let entry = match entry {
255            Ok(e) => e,
256            Err(_) => continue,
257        };
258        let name = entry.file_name();
259        let Some(name_str) = name.to_str() else {
260            continue;
261        };
262        if name_str.starts_with(STAGING_PREFIX) {
263            let _ = fs::remove_dir_all(entry.path());
264        }
265    }
266    Ok(())
267}
268
269fn entry_kind_label(t: EntryType) -> &'static str {
270    match t {
271        EntryType::Symlink => "symlink",
272        EntryType::Link => "hardlink",
273        EntryType::Char => "character device",
274        EntryType::Block => "block device",
275        EntryType::Fifo => "fifo",
276        EntryType::Continuous => "continuous",
277        EntryType::GNULongName => "gnu long name",
278        EntryType::GNULongLink => "gnu long link",
279        EntryType::GNUSparse => "gnu sparse",
280        EntryType::XGlobalHeader => "pax global header",
281        EntryType::XHeader => "pax extended header",
282        _ => "non-regular",
283    }
284}
285
286fn extract_to_staging(
287    tarball_path: &Path,
288    staging_dir: &Path,
289    limits: &ExtractLimits,
290) -> Result<(), ExtractError> {
291    let metadata = fs::metadata(tarball_path).map_err(|e| ExtractError::Io(e.to_string()))?;
292    let size = metadata.len();
293    if size > limits.max_tarball_bytes {
294        return Err(ExtractError::TarballTooLarge {
295            path: tarball_path.to_path_buf(),
296            actual: size,
297            limit: limits.max_tarball_bytes,
298        });
299    }
300
301    let file = fs::File::open(tarball_path).map_err(|e| ExtractError::Io(e.to_string()))?;
302    let decoder = GzDecoder::new(file);
303    let mut archive = Archive::new(decoder);
304    archive.set_preserve_permissions(true);
305    archive.set_overwrite(false);
306
307    let mut entry_count: u64 = 0;
308    let mut total_bytes: u64 = 0;
309
310    let entries = archive
311        .entries()
312        .map_err(|e| ExtractError::Io(format!("read tar entries: {}", e)))?;
313
314    for entry_result in entries {
315        let mut entry =
316            entry_result.map_err(|e| ExtractError::Io(format!("read tar entry: {}", e)))?;
317
318        let header = entry.header().clone();
319        let kind = header.entry_type();
320
321        let path_owned = entry
322            .path()
323            .map_err(|e| ExtractError::Io(format!("read entry path: {}", e)))?
324            .into_owned();
325        let path_str = path_owned.display().to_string();
326
327        match kind {
328            EntryType::Regular | EntryType::Directory => {}
329            other => {
330                return Err(ExtractError::DisallowedEntryType {
331                    path: path_str,
332                    kind: entry_kind_label(other),
333                });
334            }
335        }
336
337        validate_entry_path(&path_owned)?;
338
339        entry_count += 1;
340        if entry_count > limits.max_entries {
341            return Err(ExtractError::TooManyEntries {
342                limit: limits.max_entries,
343            });
344        }
345
346        let entry_size = header
347            .size()
348            .map_err(|e| ExtractError::Io(format!("read entry size: {}", e)))?;
349        if entry_size > limits.max_entry_bytes {
350            return Err(ExtractError::EntryTooLarge {
351                path: path_str,
352                actual: entry_size,
353                limit: limits.max_entry_bytes,
354            });
355        }
356        total_bytes = total_bytes.saturating_add(entry_size);
357        if total_bytes > limits.max_extracted_bytes {
358            return Err(ExtractError::ExtractedTooLarge {
359                limit: limits.max_extracted_bytes,
360            });
361        }
362
363        entry
364            .unpack_in(staging_dir)
365            .map_err(|e| ExtractError::Io(format!("unpack entry `{}`: {}", path_str, e)))?;
366    }
367
368    Ok(())
369}
370
371fn parse_manifest(path: &Path) -> Result<PluginManifest, ExtractError> {
372    let body = fs::read_to_string(path).map_err(|e| ExtractError::ManifestInvalid {
373        path: path.to_path_buf(),
374        reason: format!("read failed: {}", e),
375    })?;
376    toml::from_str::<PluginManifest>(&body).map_err(|e| ExtractError::ManifestInvalid {
377        path: path.to_path_buf(),
378        reason: e.to_string(),
379    })
380}
381
382fn check_manifest_matches(
383    actual: &PluginManifest,
384    expected: &ExtEntry,
385) -> Result<(), ExtractError> {
386    if actual.plugin.id != expected.id || actual.plugin.version != expected.version {
387        return Err(ExtractError::ManifestMismatch {
388            expected_id: expected.id.clone(),
389            expected_version: expected.version.clone(),
390            got_id: actual.plugin.id.clone(),
391            got_version: actual.plugin.version.clone(),
392        });
393    }
394    Ok(())
395}
396
397#[cfg(unix)]
398fn chmod_executable(path: &Path) {
399    use std::os::unix::fs::PermissionsExt;
400    let Ok(metadata) = fs::metadata(path) else {
401        return;
402    };
403    let mut perms = metadata.permissions();
404    let mode = perms.mode();
405    if mode & 0o111 == 0 {
406        perms.set_mode((mode & 0o777) | 0o755);
407        let _ = fs::set_permissions(path, perms);
408    }
409}
410
411#[cfg(not(unix))]
412fn chmod_executable(_path: &Path) {}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use nexo_ext_registry::{ExtDownload, ExtTier};
418    use semver::Version;
419    use std::io::Write;
420    use tempfile::TempDir;
421
422    // ── Test fixtures ──────────────────────────────────────────────
423
424    fn manifest_toml(id: &str, version: &str) -> String {
425        format!(
426            r#"
427[plugin]
428id = "{id}"
429version = "{version}"
430name = "{id}"
431description = "test plugin"
432min_nexo_version = ">=0.1.0"
433"#
434        )
435    }
436
437    enum FakeEntry<'a> {
438        File(&'a str, &'a [u8]),
439        Symlink(&'a str, &'a str),
440    }
441
442    fn make_test_tarball(entries: &[FakeEntry<'_>]) -> tempfile::NamedTempFile {
443        use flate2::write::GzEncoder;
444        use flate2::Compression;
445        use tar::{Builder, Header};
446
447        let file = tempfile::NamedTempFile::new().unwrap();
448        let encoder = GzEncoder::new(file.reopen().unwrap(), Compression::default());
449        let mut builder = Builder::new(encoder);
450
451        for e in entries {
452            match e {
453                FakeEntry::File(path, body) => {
454                    let mut header = Header::new_gnu();
455                    header.set_path(path).unwrap();
456                    header.set_size(body.len() as u64);
457                    header.set_mode(0o644);
458                    header.set_entry_type(EntryType::Regular);
459                    header.set_cksum();
460                    builder.append(&header, &body[..]).unwrap();
461                }
462                FakeEntry::Symlink(path, target) => {
463                    let mut header = Header::new_gnu();
464                    header.set_size(0);
465                    header.set_mode(0o777);
466                    header.set_entry_type(EntryType::Symlink);
467                    builder
468                        .append_link(&mut header, path, std::path::Path::new(target))
469                        .unwrap();
470                }
471            }
472        }
473        builder.into_inner().unwrap().finish().unwrap();
474        file
475    }
476
477    fn build_happy_tarball(id: &str, version: &str) -> tempfile::NamedTempFile {
478        let manifest = manifest_toml(id, version);
479        let bin_path = format!("bin/{}", id);
480        make_test_tarball(&[
481            FakeEntry::File(MANIFEST_FILE, manifest.as_bytes()),
482            FakeEntry::File(&bin_path, b"#!/bin/sh\necho hi\n"),
483        ])
484    }
485
486    fn make_expected(id: &str, version: &str) -> ExtEntry {
487        ExtEntry {
488            id: id.to_string(),
489            version: Version::parse(version).unwrap(),
490            name: id.to_string(),
491            description: "test".into(),
492            homepage: "https://example.test".into(),
493            tier: ExtTier::Community,
494            min_nexo_version: semver::VersionReq::parse(">=0.1.0").unwrap(),
495            downloads: vec![ExtDownload {
496                target: "x86_64-unknown-linux-gnu".into(),
497                url: "https://example.test/t.tar.gz".parse().unwrap(),
498                sha256: "0".repeat(64),
499                size_bytes: 1,
500            }],
501            manifest_url: "https://example.test/nexo-plugin.toml".into(),
502            signing: None,
503            authors: vec![],
504        }
505    }
506
507    // ── Helper-level tests (step 4 + 5) ────────────────────────────
508
509    #[test]
510    fn validate_entry_path_accepts_normal_relative() {
511        validate_entry_path(Path::new("bin/foo")).unwrap();
512        validate_entry_path(Path::new("nexo-plugin.toml")).unwrap();
513        validate_entry_path(Path::new("a/b/c")).unwrap();
514    }
515
516    #[test]
517    fn validate_entry_path_rejects_parent_component() {
518        let err = validate_entry_path(Path::new("../etc/passwd")).unwrap_err();
519        assert!(matches!(err, ExtractError::UnsafePath { .. }));
520    }
521
522    #[test]
523    fn validate_entry_path_rejects_absolute() {
524        let err = validate_entry_path(Path::new("/etc/passwd")).unwrap_err();
525        assert!(matches!(err, ExtractError::UnsafePath { .. }));
526    }
527
528    #[test]
529    fn validate_entry_path_rejects_nested_parent() {
530        let err = validate_entry_path(Path::new("bin/../../../escape")).unwrap_err();
531        assert!(matches!(err, ExtractError::UnsafePath { .. }));
532    }
533
534    #[test]
535    fn cleanup_stale_staging_removes_only_prefix() {
536        let tmp = TempDir::new().unwrap();
537        let stale = tmp.path().join(".staging-foo-deadbeef");
538        fs::create_dir_all(&stale).unwrap();
539        let mut f = fs::File::create(stale.join("junk")).unwrap();
540        f.write_all(b"x").unwrap();
541        let keep = tmp.path().join("real-plugin-1.0.0");
542        fs::create_dir_all(&keep).unwrap();
543
544        cleanup_stale_staging(tmp.path()).unwrap();
545
546        assert!(!stale.exists(), "stale staging dir should be removed");
547        assert!(keep.exists(), "non-staging dirs must be preserved");
548    }
549
550    // ── Public-API tests (step 7) ──────────────────────────────────
551
552    #[tokio::test]
553    async fn happy_path_extracts_and_returns_binary_path() {
554        let tmp = TempDir::new().unwrap();
555        let dest_root = tmp.path();
556        let tarball = build_happy_tarball("slack", "0.2.0");
557        let expected = make_expected("slack", "0.2.0");
558
559        let result = extract_verified_tarball(ExtractInput {
560            tarball_path: tarball.path(),
561            dest_root,
562            expected: &expected,
563            limits: ExtractLimits::default(),
564        })
565        .await
566        .expect("extract should succeed");
567
568        assert!(!result.was_already_present);
569        assert_eq!(result.plugin_dir, dest_root.join("slack-0.2.0"));
570        assert_eq!(result.binary_path, dest_root.join("slack-0.2.0/bin/slack"));
571        assert!(result.binary_path.exists());
572        assert_eq!(result.manifest.plugin.id, "slack");
573
574        #[cfg(unix)]
575        {
576            use std::os::unix::fs::PermissionsExt;
577            let mode = fs::metadata(&result.binary_path)
578                .unwrap()
579                .permissions()
580                .mode();
581            assert!(mode & 0o111 != 0, "binary must be executable: {:o}", mode);
582        }
583    }
584
585    #[tokio::test]
586    async fn idempotent_skip_when_dir_exists() {
587        let tmp = TempDir::new().unwrap();
588        let dest_root = tmp.path();
589        let tarball = build_happy_tarball("slack", "0.2.0");
590        let expected = make_expected("slack", "0.2.0");
591
592        // First call: real extract.
593        let _ = extract_verified_tarball(ExtractInput {
594            tarball_path: tarball.path(),
595            dest_root,
596            expected: &expected,
597            limits: ExtractLimits::default(),
598        })
599        .await
600        .unwrap();
601
602        // Second call: must short-circuit.
603        let result = extract_verified_tarball(ExtractInput {
604            tarball_path: tarball.path(),
605            dest_root,
606            expected: &expected,
607            limits: ExtractLimits::default(),
608        })
609        .await
610        .unwrap();
611
612        assert!(result.was_already_present);
613        assert_eq!(result.manifest.plugin.id, "slack");
614    }
615
616    #[tokio::test]
617    async fn mismatched_manifest_returns_error_and_cleans_staging() {
618        let tmp = TempDir::new().unwrap();
619        let dest_root = tmp.path();
620        // Tarball claims id=evil, expected says slack.
621        let tarball = build_happy_tarball("evil", "0.2.0");
622        let expected = make_expected("slack", "0.2.0");
623
624        let err = extract_verified_tarball(ExtractInput {
625            tarball_path: tarball.path(),
626            dest_root,
627            expected: &expected,
628            limits: ExtractLimits::default(),
629        })
630        .await
631        .unwrap_err();
632
633        assert!(matches!(err, ExtractError::ManifestMismatch { .. }));
634        // No staging leftovers.
635        let leftovers: Vec<_> = fs::read_dir(dest_root)
636            .unwrap()
637            .filter_map(|e| e.ok())
638            .filter(|e| e.file_name().to_string_lossy().starts_with(STAGING_PREFIX))
639            .collect();
640        assert!(leftovers.is_empty(), "staging dirs must be cleaned up");
641        // No final dir.
642        assert!(!dest_root.join("slack-0.2.0").exists());
643    }
644
645    #[tokio::test]
646    async fn path_traversal_via_dot_dot_rejected() {
647        // `tar::Builder::set_path` rejects `..` at tarball-build
648        // time, so we craft the offending header by hand against
649        // the GNU header `name` field.
650        use flate2::write::GzEncoder;
651        use flate2::Compression;
652        use tar::{Builder, Header};
653
654        let tmp = TempDir::new().unwrap();
655        let manifest = manifest_toml("slack", "0.2.0");
656
657        let tarball = tempfile::NamedTempFile::new().unwrap();
658        let encoder = GzEncoder::new(tarball.reopen().unwrap(), Compression::default());
659        let mut builder = Builder::new(encoder);
660
661        let mut h1 = Header::new_gnu();
662        h1.set_path(MANIFEST_FILE).unwrap();
663        h1.set_size(manifest.len() as u64);
664        h1.set_mode(0o644);
665        h1.set_entry_type(EntryType::Regular);
666        h1.set_cksum();
667        builder.append(&h1, manifest.as_bytes()).unwrap();
668
669        let mut h2 = Header::new_gnu();
670        h2.set_path("bin/slack").unwrap();
671        h2.set_size(1);
672        h2.set_mode(0o755);
673        h2.set_entry_type(EntryType::Regular);
674        h2.set_cksum();
675        builder.append(&h2, &b"x"[..]).unwrap();
676
677        // Raw `..` injection: write directly into the GNU name field.
678        let evil = b"../../escape";
679        let mut h3 = Header::new_gnu();
680        h3.as_old_mut().name[..evil.len()].copy_from_slice(evil);
681        h3.set_size(1);
682        h3.set_mode(0o644);
683        h3.set_entry_type(EntryType::Regular);
684        h3.set_cksum();
685        builder.append(&h3, &b"x"[..]).unwrap();
686
687        builder.into_inner().unwrap().finish().unwrap();
688
689        let expected = make_expected("slack", "0.2.0");
690        let err = extract_verified_tarball(ExtractInput {
691            tarball_path: tarball.path(),
692            dest_root: tmp.path(),
693            expected: &expected,
694            limits: ExtractLimits::default(),
695        })
696        .await
697        .unwrap_err();
698
699        assert!(
700            matches!(err, ExtractError::UnsafePath { .. }),
701            "got {:?}",
702            err
703        );
704    }
705
706    #[tokio::test]
707    async fn absolute_path_rejected() {
708        let tmp = TempDir::new().unwrap();
709        let manifest = manifest_toml("slack", "0.2.0");
710        // tar's set_path normalizes absolute paths, so we must build
711        // a header by hand.
712        use flate2::write::GzEncoder;
713        use flate2::Compression;
714        use tar::{Builder, Header};
715
716        let tarball = tempfile::NamedTempFile::new().unwrap();
717        let encoder = GzEncoder::new(tarball.reopen().unwrap(), Compression::default());
718        let mut builder = Builder::new(encoder);
719
720        let mut h1 = Header::new_gnu();
721        h1.set_path(MANIFEST_FILE).unwrap();
722        h1.set_size(manifest.len() as u64);
723        h1.set_mode(0o644);
724        h1.set_entry_type(EntryType::Regular);
725        h1.set_cksum();
726        builder.append(&h1, manifest.as_bytes()).unwrap();
727
728        let mut h2 = Header::new_gnu();
729        h2.set_path("bin/slack").unwrap();
730        h2.set_size(8);
731        h2.set_mode(0o755);
732        h2.set_entry_type(EntryType::Regular);
733        h2.set_cksum();
734        builder.append(&h2, &b"#!/bin/sh"[..8]).unwrap();
735
736        // Manually craft an absolute-path header. `set_path` strips
737        // the leading `/`; bypass via direct name field.
738        let mut h3 = Header::new_gnu();
739        h3.as_old_mut().name[..11].copy_from_slice(b"/etc/passwd");
740        h3.set_size(1);
741        h3.set_mode(0o644);
742        h3.set_entry_type(EntryType::Regular);
743        h3.set_cksum();
744        builder.append(&h3, &b"x"[..]).unwrap();
745
746        builder.into_inner().unwrap().finish().unwrap();
747
748        let expected = make_expected("slack", "0.2.0");
749        let err = extract_verified_tarball(ExtractInput {
750            tarball_path: tarball.path(),
751            dest_root: tmp.path(),
752            expected: &expected,
753            limits: ExtractLimits::default(),
754        })
755        .await
756        .unwrap_err();
757
758        assert!(
759            matches!(err, ExtractError::UnsafePath { .. }),
760            "got {:?}",
761            err
762        );
763    }
764
765    #[tokio::test]
766    async fn symlink_entry_rejected() {
767        let tmp = TempDir::new().unwrap();
768        let manifest = manifest_toml("slack", "0.2.0");
769        let tarball = make_test_tarball(&[
770            FakeEntry::File(MANIFEST_FILE, manifest.as_bytes()),
771            FakeEntry::File("bin/slack", b"x"),
772            FakeEntry::Symlink("link-out", "/etc/passwd"),
773        ]);
774        let expected = make_expected("slack", "0.2.0");
775
776        let err = extract_verified_tarball(ExtractInput {
777            tarball_path: tarball.path(),
778            dest_root: tmp.path(),
779            expected: &expected,
780            limits: ExtractLimits::default(),
781        })
782        .await
783        .unwrap_err();
784
785        assert!(
786            matches!(
787                err,
788                ExtractError::DisallowedEntryType {
789                    kind: "symlink",
790                    ..
791                }
792            ),
793            "got {:?}",
794            err
795        );
796    }
797
798    #[tokio::test]
799    async fn entry_count_limit_enforced() {
800        let tmp = TempDir::new().unwrap();
801        let manifest = manifest_toml("slack", "0.2.0");
802        let mut entries = vec![
803            FakeEntry::File(MANIFEST_FILE, manifest.as_bytes()),
804            FakeEntry::File("bin/slack", b"x"),
805        ];
806        let extras = ["a", "b", "c", "d", "e", "f"];
807        for path in &extras {
808            entries.push(FakeEntry::File(path, b"x"));
809        }
810        let tarball = make_test_tarball(&entries);
811        let expected = make_expected("slack", "0.2.0");
812
813        let err = extract_verified_tarball(ExtractInput {
814            tarball_path: tarball.path(),
815            dest_root: tmp.path(),
816            expected: &expected,
817            limits: ExtractLimits {
818                max_entries: 5,
819                ..ExtractLimits::default()
820            },
821        })
822        .await
823        .unwrap_err();
824
825        assert!(
826            matches!(err, ExtractError::TooManyEntries { limit: 5 }),
827            "got {:?}",
828            err
829        );
830    }
831
832    #[tokio::test]
833    async fn binary_missing_after_extract() {
834        let tmp = TempDir::new().unwrap();
835        let manifest = manifest_toml("slack", "0.2.0");
836        // Manifest only — no bin/slack.
837        let tarball = make_test_tarball(&[FakeEntry::File(MANIFEST_FILE, manifest.as_bytes())]);
838        let expected = make_expected("slack", "0.2.0");
839
840        let err = extract_verified_tarball(ExtractInput {
841            tarball_path: tarball.path(),
842            dest_root: tmp.path(),
843            expected: &expected,
844            limits: ExtractLimits::default(),
845        })
846        .await
847        .unwrap_err();
848
849        assert!(
850            matches!(err, ExtractError::BinaryMissing { .. }),
851            "got {:?}",
852            err
853        );
854        // Staging cleaned up.
855        let leftovers: Vec<_> = fs::read_dir(tmp.path())
856            .unwrap()
857            .filter_map(|e| e.ok())
858            .filter(|e| e.file_name().to_string_lossy().starts_with(STAGING_PREFIX))
859            .collect();
860        assert!(leftovers.is_empty());
861    }
862}