1#![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
34pub const MAX_TARBALL_BYTES: u64 = 100 * 1024 * 1024;
38
39pub const MAX_ENTRIES: u64 = 10_000;
41
42pub const MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024;
44
45pub 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#[derive(Debug, Clone)]
56pub struct ExtractLimits {
57 pub max_tarball_bytes: u64,
59 pub max_entries: u64,
61 pub max_extracted_bytes: u64,
63 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#[derive(Debug)]
80pub struct ExtractInput<'a> {
81 pub tarball_path: &'a Path,
83 pub dest_root: &'a Path,
85 pub expected: &'a ExtEntry,
88 pub limits: ExtractLimits,
90}
91
92#[derive(Debug, Clone)]
94pub struct ExtractedPlugin {
95 pub plugin_dir: PathBuf,
97 pub manifest: PluginManifest,
99 pub binary_path: PathBuf,
101 pub was_already_present: bool,
103}
104
105pub 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 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 fs::create_dir_all(dest_root).map_err(|e| ExtractError::Io(e.to_string()))?;
136 cleanup_stale_staging(dest_root)?;
137
138 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 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 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 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 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
199fn 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 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 #[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 #[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 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 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 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 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 assert!(!dest_root.join("slack-0.2.0").exists());
643 }
644
645 #[tokio::test]
646 async fn path_traversal_via_dot_dot_rejected() {
647 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 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 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 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 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 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}