Skip to main content

greentic_setup/
gtbundle.rs

1//! .gtbundle archive format support.
2//!
3//! A `.gtbundle` file is an archive containing a complete Greentic bundle.
4//! Supports both SquashFS (default) and ZIP formats.
5//!
6//! ## Format
7//!
8//! ```text
9//! my-bundle.gtbundle (SquashFS or ZIP archive)
10//! ├── greentic.demo.yaml or bundle.yaml
11//! ├── packs/
12//! ├── providers/
13//! ├── resolved/
14//! ├── state/
15//! └── tenants/
16//! ```
17
18use std::collections::HashSet;
19use std::fs::{self, File};
20use std::io::{BufReader, Read, Write};
21use std::path::{Component, Path, PathBuf};
22
23use anyhow::{Context, Result, anyhow, bail};
24use zip::write::SimpleFileOptions;
25use zip::{ZipArchive, ZipWriter};
26
27/// Archive format for gtbundle files.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BundleFormat {
30    /// SquashFS format (read-only compressed filesystem)
31    #[cfg(feature = "squashfs")]
32    SquashFs,
33    /// ZIP format (portable compressed archive)
34    Zip,
35}
36
37// Feature-conditional default: SquashFs when `squashfs` feature enabled, otherwise Zip.
38// Cannot use `#[derive(Default)]` with conditional `#[default]` attributes.
39#[allow(clippy::derivable_impls)]
40impl Default for BundleFormat {
41    fn default() -> Self {
42        #[cfg(feature = "squashfs")]
43        {
44            Self::SquashFs
45        }
46        #[cfg(not(feature = "squashfs"))]
47        {
48            Self::Zip
49        }
50    }
51}
52
53/// Detect the format of a gtbundle file by reading its magic bytes.
54pub fn detect_bundle_format(path: &Path) -> Result<BundleFormat> {
55    let mut file = File::open(path).context("failed to open bundle file")?;
56    let mut magic = [0u8; 4];
57    file.read_exact(&mut magic)
58        .context("failed to read magic bytes")?;
59
60    // SquashFS magic: "hsqs" (little-endian) or "sqsh" (big-endian)
61    if &magic == b"hsqs" || &magic == b"sqsh" {
62        #[cfg(feature = "squashfs")]
63        return Ok(BundleFormat::SquashFs);
64        #[cfg(not(feature = "squashfs"))]
65        bail!("squashfs format detected but squashfs feature is not enabled");
66    }
67
68    // ZIP magic: PK\x03\x04
69    if &magic == b"PK\x03\x04" {
70        return Ok(BundleFormat::Zip);
71    }
72
73    bail!("unknown archive format (magic: {:?})", magic);
74}
75
76/// Create a .gtbundle archive from a bundle directory using the default format.
77///
78/// # Arguments
79/// * `bundle_dir` - Source bundle directory
80/// * `output_path` - Destination .gtbundle file path
81///
82/// # Example
83/// ```ignore
84/// create_gtbundle(Path::new("./my-bundle"), Path::new("./dist/my-bundle.gtbundle"))?;
85/// ```
86pub fn create_gtbundle(bundle_dir: &Path, output_path: &Path) -> Result<()> {
87    create_gtbundle_with_format(bundle_dir, output_path, BundleFormat::default())
88}
89
90/// Create a .gtbundle archive with a specific format.
91pub fn create_gtbundle_with_format(
92    bundle_dir: &Path,
93    output_path: &Path,
94    format: BundleFormat,
95) -> Result<()> {
96    // Phase 0 secret-leak hotfix is enforced inline by the per-format writer
97    // walkers (add_directory_to_squashfs / add_directory_to_zip): they skip
98    // dev-store paths (.greentic/dev/, .greentic/state/dev/, .dev.secrets.env)
99    // and bail on any symlink. Doing it in the same walk that reads bytes
100    // closes the preflight-vs-writer TOCTOU window that Codex's adversarial
101    // review flagged on the earlier denylist approach.
102    // See plans/next-gen-deployment.md P0.1.
103    match format {
104        #[cfg(feature = "squashfs")]
105        BundleFormat::SquashFs => create_gtbundle_squashfs(bundle_dir, output_path),
106        BundleFormat::Zip => create_gtbundle_zip(bundle_dir, output_path),
107    }
108}
109
110// Phase 0 secret-leak hotfix matcher. Used by the writer walkers below to
111// skip dev-store paths from the archive — `.greentic/dev/`,
112// `.greentic/state/dev/`, and any `.dev.secrets.env` file. These are the
113// dev-store paths declared in `greentic-setup/src/secrets.rs:STORE_RELATIVE
114// / STORE_STATE_RELATIVE`. Skipping (vs. bailing) lets the normal setup
115// flow round-trip: ApplyPackSetup writes the dev store under the bundle
116// root, the post-setup repack call here ignores those paths instead of
117// erroring out, and the secrets stay on disk for runtime use until Phase B
118// migrates the in-memory map to SecretRef.
119fn dev_secret_match(relative: &Path) -> Option<&'static str> {
120    let parts: Vec<&str> = relative
121        .components()
122        .filter_map(|component| match component {
123            Component::Normal(part) => part.to_str(),
124            _ => None,
125        })
126        .collect();
127    for window in parts.windows(2) {
128        if window[0] == ".greentic" && window[1] == "dev" {
129            return Some(".greentic/dev/ tree");
130        }
131    }
132    for window in parts.windows(3) {
133        if window[0] == ".greentic" && window[1] == "state" && window[2] == "dev" {
134            return Some(".greentic/state/dev/ tree");
135        }
136    }
137    if parts.last().copied() == Some(".dev.secrets.env") {
138        return Some(".dev.secrets.env file");
139    }
140    None
141}
142
143/// Create a .gtbundle archive using SquashFS format.
144#[cfg(feature = "squashfs")]
145fn create_gtbundle_squashfs(bundle_dir: &Path, output_path: &Path) -> Result<()> {
146    use backhand::FilesystemWriter;
147
148    if !bundle_dir.is_dir() {
149        bail!("bundle directory not found: {}", bundle_dir.display());
150    }
151
152    // Ensure parent directory exists
153    if let Some(parent) = output_path.parent() {
154        fs::create_dir_all(parent).context("failed to create output directory")?;
155    }
156
157    let mut writer = FilesystemWriter::default();
158    // The root inode header inherits `NodeHeader::default()` (mode 0o000)
159    // unless we override it — same trap as the per-entry headers below.
160    writer.set_root_mode(0o755);
161
162    let result = (|| -> Result<()> {
163        add_directory_to_squashfs(&mut writer, bundle_dir, bundle_dir)?;
164        let mut output = File::create(output_path)
165            .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
166        writer
167            .write(&mut output)
168            .context("failed to write squashfs archive")?;
169        Ok(())
170    })();
171
172    if result.is_err() {
173        let _ = fs::remove_file(output_path);
174    }
175    result
176}
177
178/// Add a directory and its contents to a SquashFS filesystem.
179#[cfg(feature = "squashfs")]
180fn add_directory_to_squashfs(
181    writer: &mut backhand::FilesystemWriter,
182    base_dir: &Path,
183    current_dir: &Path,
184) -> Result<()> {
185    use std::io::Cursor;
186
187    let entries = fs::read_dir(current_dir)
188        .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
189
190    for entry in entries {
191        let entry = entry?;
192        let path = entry.path();
193        let relative_path = path
194            .strip_prefix(base_dir)
195            .context("failed to compute relative path")?;
196        let name = relative_path.to_string_lossy().to_string();
197
198        // Phase 0 P0.1: skip dev-store paths in the same walk that reads
199        // bytes (no separate preflight, no TOCTOU window).
200        if dev_secret_match(relative_path).is_some() {
201            continue;
202        }
203
204        // Phase 0 P0.1: reject symlinks. `entry.file_type()` does NOT follow
205        // them; `path.is_dir()` and `fs::read(&path)` below DO follow. A
206        // benign-looking symlink whose target is a dev-secret path would
207        // otherwise leak target bytes under the symlink's safe name.
208        let file_type = entry
209            .file_type()
210            .with_context(|| format!("file type for {}", path.display()))?;
211        if file_type.is_symlink() {
212            bail!(
213                "refusing to archive symlink {} (symlinks are not supported by gtbundle writers and may bypass the dev-secret skip by dereferencing through to a leaked target)",
214                relative_path.display()
215            );
216        }
217
218        if file_type.is_dir() {
219            writer
220                .push_dir(&name, dir_node_header())
221                .with_context(|| format!("failed to add directory: {}", name))?;
222            add_directory_to_squashfs(writer, base_dir, &path)?;
223        } else {
224            let content = fs::read(&path)
225                .with_context(|| format!("failed to read file: {}", path.display()))?;
226            let cursor = Cursor::new(content);
227            writer
228                .push_file(cursor, &name, file_node_header())
229                .with_context(|| format!("failed to add file: {}", name))?;
230        }
231    }
232
233    Ok(())
234}
235
236// `NodeHeader::default()` zero-fills permissions, which yields squashfs
237// archives whose extracted directories have mode `0o000` and cannot be
238// `read_dir()`'d by `gtc start`. Stamp world-readable defaults so any
239// consumer can extract and start the bundle without a manual chmod.
240#[cfg(feature = "squashfs")]
241fn dir_node_header() -> backhand::NodeHeader {
242    backhand::NodeHeader::new(0o755, 0, 0, 0)
243}
244
245#[cfg(feature = "squashfs")]
246fn file_node_header() -> backhand::NodeHeader {
247    backhand::NodeHeader::new(0o644, 0, 0, 0)
248}
249
250/// Create a .gtbundle archive using ZIP format.
251fn create_gtbundle_zip(bundle_dir: &Path, output_path: &Path) -> Result<()> {
252    if !bundle_dir.is_dir() {
253        bail!("bundle directory not found: {}", bundle_dir.display());
254    }
255
256    // Ensure parent directory exists
257    if let Some(parent) = output_path.parent() {
258        fs::create_dir_all(parent).context("failed to create output directory")?;
259    }
260
261    let file = File::create(output_path)
262        .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
263    let mut zip = ZipWriter::new(file);
264
265    let options = SimpleFileOptions::default()
266        .compression_method(zip::CompressionMethod::Deflated)
267        .unix_permissions(0o644);
268
269    let result = (|| -> Result<()> {
270        add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
271        zip.finish().context("failed to finalize archive")?;
272        Ok(())
273    })();
274
275    if result.is_err() {
276        let _ = fs::remove_file(output_path);
277    }
278    result
279}
280
281/// Extract a .gtbundle archive to a directory.
282///
283/// Auto-detects the archive format (SquashFS or ZIP) and extracts accordingly.
284///
285/// # Arguments
286/// * `gtbundle_path` - Source .gtbundle file
287/// * `output_dir` - Destination directory (will be created if needed)
288///
289/// # Example
290/// ```ignore
291/// extract_gtbundle(Path::new("./my-bundle.gtbundle"), Path::new("/tmp/my-bundle"))?;
292/// ```
293pub fn extract_gtbundle(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
294    if !gtbundle_path.is_file() {
295        bail!("gtbundle file not found: {}", gtbundle_path.display());
296    }
297
298    let format = detect_bundle_format(gtbundle_path)?;
299    match format {
300        #[cfg(feature = "squashfs")]
301        BundleFormat::SquashFs => extract_gtbundle_squashfs(gtbundle_path, output_dir),
302        BundleFormat::Zip => extract_gtbundle_zip(gtbundle_path, output_dir),
303    }
304}
305
306/// Extract a .gtbundle archive using SquashFS format.
307///
308/// Phase 0 P0.4 hardened: every archive entry runs through the same safety
309/// helpers (`normalize_archive_inner_path` → `safe_output_path` →
310/// `safe_create_dir_all` → `assert_no_existing_symlink`) used by
311/// `greentic-bundle` and `greentic-start`. Symlink targets are validated
312/// against the extract root; the previous string-substring `..` check is
313/// replaced by structural component validation.
314#[cfg(feature = "squashfs")]
315fn extract_gtbundle_squashfs(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
316    use backhand::FilesystemReader;
317
318    let file = BufReader::new(
319        File::open(gtbundle_path)
320            .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?,
321    );
322    let reader = FilesystemReader::from_reader(file).context("failed to read squashfs archive")?;
323
324    fs::create_dir_all(output_dir).context("failed to create output directory")?;
325
326    let mut seen_paths: HashSet<String> = HashSet::new();
327    for node in reader.files() {
328        let full = node.fullpath.to_string_lossy();
329        let Some(normalized) = normalize_archive_inner_path(full.as_ref())? else {
330            continue;
331        };
332        if !seen_paths.insert(normalized.clone()) {
333            bail!("duplicate squashfs entry rejected: {normalized}");
334        }
335        let out_path = safe_output_path(output_dir, &normalized)?;
336
337        match &node.inner {
338            backhand::InnerNode::Dir(_) => {
339                safe_create_dir_all(output_dir, &out_path)
340                    .with_context(|| format!("create directory {}", out_path.display()))?;
341            }
342            backhand::InnerNode::File(file_reader) => {
343                if let Some(parent) = out_path.parent() {
344                    safe_create_dir_all(output_dir, parent)
345                        .with_context(|| format!("create parent directory {}", parent.display()))?;
346                }
347                assert_no_existing_symlink(&out_path)
348                    .with_context(|| format!("validate destination for {normalized}"))?;
349                let mut out_file = File::create(&out_path)
350                    .with_context(|| format!("failed to create: {}", out_path.display()))?;
351                let content = reader.file(file_reader);
352                let mut decompressed = Vec::new();
353                content
354                    .reader()
355                    .read_to_end(&mut decompressed)
356                    .context("failed to decompress file")?;
357                out_file
358                    .write_all(&decompressed)
359                    .context("failed to write file")?;
360            }
361            backhand::InnerNode::Symlink(link) => {
362                #[cfg(unix)]
363                {
364                    if let Some(parent) = out_path.parent() {
365                        safe_create_dir_all(output_dir, parent).with_context(|| {
366                            format!("create parent directory {}", parent.display())
367                        })?;
368                    }
369                    assert_no_existing_symlink(&out_path)
370                        .with_context(|| format!("validate destination for {normalized}"))?;
371                    assert_symlink_target_within_root(&normalized, &link.link)
372                        .with_context(|| format!("validate symlink target for {normalized}"))?;
373                    std::os::unix::fs::symlink(&link.link, &out_path).with_context(|| {
374                        format!("failed to create symlink: {}", out_path.display())
375                    })?;
376                }
377                #[cfg(not(unix))]
378                {
379                    // Skip symlinks on non-Unix platforms
380                    let _ = link;
381                }
382            }
383            _ => {
384                // Skip other node types (devices, etc.)
385            }
386        }
387    }
388
389    Ok(())
390}
391
392/// Extract a .gtbundle archive using ZIP format.
393///
394/// Phase 0 P0.4 hardened: every entry runs through the shared safety
395/// helpers. Replaces the previous string-substring `..` check with
396/// structural component validation and adds duplicate-path rejection.
397fn extract_gtbundle_zip(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
398    let file = File::open(gtbundle_path)
399        .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?;
400    let mut archive = ZipArchive::new(file).context("failed to read archive")?;
401
402    fs::create_dir_all(output_dir).context("failed to create output directory")?;
403
404    let mut seen_paths: HashSet<String> = HashSet::new();
405    for i in 0..archive.len() {
406        let mut file = archive
407            .by_index(i)
408            .context("failed to read archive entry")?;
409        let raw_name = file.name().to_string();
410        let Some(normalized) = normalize_archive_inner_path(&raw_name)? else {
411            continue;
412        };
413        if !seen_paths.insert(normalized.clone()) {
414            bail!("duplicate zip entry rejected: {normalized}");
415        }
416        let out_path = safe_output_path(output_dir, &normalized)?;
417
418        if file.is_dir() || raw_name.ends_with('/') {
419            safe_create_dir_all(output_dir, &out_path)
420                .with_context(|| format!("create directory {}", out_path.display()))?;
421        } else {
422            if let Some(parent) = out_path.parent() {
423                safe_create_dir_all(output_dir, parent)
424                    .with_context(|| format!("create parent directory {}", parent.display()))?;
425            }
426            assert_no_existing_symlink(&out_path)
427                .with_context(|| format!("validate destination for {normalized}"))?;
428            let mut out_file = File::create(&out_path)
429                .with_context(|| format!("failed to create: {}", out_path.display()))?;
430            std::io::copy(&mut file, &mut out_file)?;
431
432            // Restore permissions on Unix
433            #[cfg(unix)]
434            {
435                use std::os::unix::fs::PermissionsExt;
436                if let Some(mode) = file.unix_mode() {
437                    fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
438                }
439            }
440        }
441    }
442
443    Ok(())
444}
445
446// Phase 0 P0.4 — shared safety helpers for archive extraction. Mirror the
447// hardened readers in greentic-bundle/src/bundle_fs/backhand_writer.rs and
448// greentic-start/src/bundle_ref.rs; inlined per feedback_refactoring_scope.md
449// (no new helper crate for a hotfix). See plans/next-gen-deployment.md P0.4.
450
451fn normalize_archive_inner_path(raw: &str) -> Result<Option<String>> {
452    let trimmed = raw.trim_matches('/');
453    if trimmed.is_empty() {
454        return Ok(None);
455    }
456    let mut parts: Vec<String> = Vec::new();
457    for component in Path::new(trimmed).components() {
458        match component {
459            Component::Normal(part) => {
460                let part = part
461                    .to_str()
462                    .ok_or_else(|| anyhow!("archive path must be valid UTF-8: {raw}"))?;
463                if part.is_empty() {
464                    bail!("archive path has empty component: {raw}");
465                }
466                parts.push(part.to_string());
467            }
468            Component::CurDir => {}
469            Component::ParentDir => {
470                bail!("refusing archive path with parent dir component: {raw}");
471            }
472            Component::RootDir | Component::Prefix(_) => {
473                bail!("refusing absolute archive path: {raw}");
474            }
475        }
476    }
477    if parts.is_empty() {
478        return Ok(None);
479    }
480    Ok(Some(parts.join("/")))
481}
482
483fn safe_output_path(out_dir: &Path, inner_path: &str) -> Result<PathBuf> {
484    let mut out = out_dir.to_path_buf();
485    for component in Path::new(inner_path).components() {
486        match component {
487            Component::Normal(part) => out.push(part),
488            Component::CurDir => {}
489            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
490                bail!("refusing to extract unsafe archive path: {inner_path}");
491            }
492        }
493    }
494    Ok(out)
495}
496
497fn safe_create_dir_all(extract_root: &Path, target: &Path) -> Result<()> {
498    if !target.starts_with(extract_root) {
499        bail!(
500            "refusing to descend outside extract root: {} not under {}",
501            target.display(),
502            extract_root.display()
503        );
504    }
505    let relative = target.strip_prefix(extract_root).map_err(|err| {
506        anyhow!(
507            "make {} relative to extract root {}: {err}",
508            target.display(),
509            extract_root.display()
510        )
511    })?;
512    let mut current = extract_root.to_path_buf();
513    for component in relative.components() {
514        let part = match component {
515            Component::Normal(part) => part,
516            Component::CurDir => continue,
517            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
518                bail!(
519                    "refusing to traverse unsafe component during mkdir: {}",
520                    target.display()
521                );
522            }
523        };
524        current.push(part);
525        match fs::symlink_metadata(&current) {
526            Ok(meta) => {
527                if meta.file_type().is_symlink() {
528                    bail!(
529                        "refusing to descend through symlink at {}",
530                        current.display()
531                    );
532                }
533                if !meta.file_type().is_dir() {
534                    bail!(
535                        "refusing to descend through non-directory at {}",
536                        current.display()
537                    );
538                }
539            }
540            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
541                fs::create_dir(&current)
542                    .with_context(|| format!("create directory {}", current.display()))?;
543            }
544            Err(err) => {
545                return Err(anyhow::Error::new(err)
546                    .context(format!("stat {} during safe mkdir", current.display())));
547            }
548        }
549    }
550    Ok(())
551}
552
553fn assert_no_existing_symlink(destination: &Path) -> Result<()> {
554    match fs::symlink_metadata(destination) {
555        Ok(meta) if meta.file_type().is_symlink() => {
556            bail!(
557                "refusing to write through existing symlink at {}",
558                destination.display()
559            );
560        }
561        Ok(_) | Err(_) => Ok(()),
562    }
563}
564
565#[cfg(unix)]
566fn assert_symlink_target_within_root(symlink_inner_path: &str, target: &Path) -> Result<()> {
567    let parent_depth = Path::new(symlink_inner_path)
568        .parent()
569        .map(|parent| {
570            parent
571                .components()
572                .filter(|component| matches!(component, Component::Normal(_)))
573                .count()
574        })
575        .unwrap_or(0);
576    let mut depth: i64 = parent_depth as i64;
577    for component in target.components() {
578        match component {
579            Component::Normal(_) => depth += 1,
580            Component::CurDir => {}
581            Component::ParentDir => {
582                depth -= 1;
583                if depth < 0 {
584                    bail!(
585                        "refusing symlink target {} from {}: escapes extract root",
586                        target.display(),
587                        symlink_inner_path
588                    );
589                }
590            }
591            Component::RootDir | Component::Prefix(_) => {
592                bail!(
593                    "refusing absolute symlink target {} from {}",
594                    target.display(),
595                    symlink_inner_path
596                );
597            }
598        }
599    }
600    Ok(())
601}
602
603/// Extract a .gtbundle to a temporary directory and return the path.
604///
605/// The caller is responsible for cleaning up the temporary directory.
606pub fn extract_gtbundle_to_temp(gtbundle_path: &Path) -> Result<PathBuf> {
607    let temp_dir = std::env::temp_dir().join(format!(
608        "gtbundle-{}",
609        gtbundle_path
610            .file_stem()
611            .and_then(|s| s.to_str())
612            .unwrap_or("bundle")
613    ));
614
615    // Clean up existing temp directory
616    if temp_dir.exists() {
617        fs::remove_dir_all(&temp_dir).ok();
618    }
619
620    extract_gtbundle(gtbundle_path, &temp_dir)?;
621
622    Ok(temp_dir)
623}
624
625/// Check if a path is a .gtbundle archive file.
626pub fn is_gtbundle_file(path: &Path) -> bool {
627    path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
628}
629
630/// Check if a path is a .gtbundle directory (named *.gtbundle but is a dir).
631pub fn is_gtbundle_dir(path: &Path) -> bool {
632    path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
633}
634
635// ── Internal helpers ─────────────────────────────────────────────────────────
636
637fn add_directory_to_zip<W: Write + std::io::Seek>(
638    zip: &mut ZipWriter<W>,
639    base_dir: &Path,
640    current_dir: &Path,
641    options: SimpleFileOptions,
642) -> Result<()> {
643    let entries = fs::read_dir(current_dir)
644        .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
645
646    for entry in entries {
647        let entry = entry?;
648        let path = entry.path();
649        let relative_path = path
650            .strip_prefix(base_dir)
651            .context("failed to compute relative path")?;
652        let name = relative_path.to_string_lossy();
653
654        // Phase 0 P0.1: skip dev-store paths in the same walk that reads
655        // bytes (no separate preflight, no TOCTOU window).
656        if dev_secret_match(relative_path).is_some() {
657            continue;
658        }
659
660        // Phase 0 P0.1: reject symlinks. `entry.file_type()` does NOT follow
661        // them; `path.is_dir()` and `File::open(&path)` below DO follow. A
662        // benign-looking symlink whose target is a dev-secret path would
663        // otherwise leak target bytes under the symlink's safe name.
664        let file_type = entry
665            .file_type()
666            .with_context(|| format!("file type for {}", path.display()))?;
667        if file_type.is_symlink() {
668            bail!(
669                "refusing to archive symlink {} (symlinks are not supported by gtbundle writers and may bypass the dev-secret skip by dereferencing through to a leaked target)",
670                relative_path.display()
671            );
672        }
673
674        if file_type.is_dir() {
675            // Add directory entry
676            zip.add_directory(format!("{}/", name), options)?;
677            // Recurse
678            add_directory_to_zip(zip, base_dir, &path, options)?;
679        } else {
680            // Add file
681            zip.start_file(name.to_string(), options)?;
682            let mut file = File::open(&path)?;
683            let mut buffer = Vec::new();
684            file.read_to_end(&mut buffer)?;
685            zip.write_all(&buffer)?;
686        }
687    }
688
689    Ok(())
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::bundle::{BUNDLE_WORKSPACE_MARKER, LEGACY_BUNDLE_MARKER};
696    use std::fs;
697    use tempfile::tempdir;
698
699    fn create_test_bundle(bundle_dir: &Path) {
700        fs::create_dir_all(bundle_dir).unwrap();
701        fs::write(bundle_dir.join(LEGACY_BUNDLE_MARKER), "name: test").unwrap();
702        fs::create_dir_all(bundle_dir.join("packs")).unwrap();
703        fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
704    }
705
706    fn verify_extracted_bundle(extract_dir: &Path) {
707        assert!(extract_dir.join(LEGACY_BUNDLE_MARKER).exists());
708        assert!(extract_dir.join("packs/test.txt").exists());
709
710        let content = fs::read_to_string(extract_dir.join("packs/test.txt")).unwrap();
711        assert_eq!(content, "hello");
712    }
713
714    fn create_test_bundle_workspace(bundle_dir: &Path) {
715        fs::create_dir_all(bundle_dir).unwrap();
716        fs::write(
717            bundle_dir.join(BUNDLE_WORKSPACE_MARKER),
718            "schema_version: 1\n",
719        )
720        .unwrap();
721        fs::create_dir_all(bundle_dir.join("packs")).unwrap();
722        fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
723    }
724
725    #[test]
726    fn test_create_and_extract_gtbundle_zip() {
727        let temp = tempdir().unwrap();
728        let bundle_dir = temp.path().join("test-bundle");
729        let gtbundle_path = temp.path().join("test.gtbundle");
730        let extract_dir = temp.path().join("extracted");
731
732        create_test_bundle(&bundle_dir);
733
734        // Create ZIP archive
735        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip).unwrap();
736        assert!(gtbundle_path.exists());
737
738        // Verify format detection
739        let format = detect_bundle_format(&gtbundle_path).unwrap();
740        assert_eq!(format, BundleFormat::Zip);
741
742        // Extract archive
743        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
744        verify_extracted_bundle(&extract_dir);
745    }
746
747    #[cfg(feature = "squashfs")]
748    #[test]
749    fn test_create_and_extract_gtbundle_squashfs() {
750        let temp = tempdir().unwrap();
751        let bundle_dir = temp.path().join("test-bundle");
752        let gtbundle_path = temp.path().join("test.gtbundle");
753        let extract_dir = temp.path().join("extracted");
754
755        create_test_bundle(&bundle_dir);
756
757        // Create SquashFS archive
758        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::SquashFs).unwrap();
759        assert!(gtbundle_path.exists());
760
761        // Verify format detection
762        let format = detect_bundle_format(&gtbundle_path).unwrap();
763        assert_eq!(format, BundleFormat::SquashFs);
764
765        // Extract archive
766        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
767        verify_extracted_bundle(&extract_dir);
768    }
769
770    #[test]
771    fn test_create_and_extract_gtbundle_default() {
772        let temp = tempdir().unwrap();
773        let bundle_dir = temp.path().join("test-bundle");
774        let gtbundle_path = temp.path().join("test.gtbundle");
775        let extract_dir = temp.path().join("extracted");
776
777        create_test_bundle(&bundle_dir);
778
779        // Create archive with default format
780        create_gtbundle(&bundle_dir, &gtbundle_path).unwrap();
781        assert!(gtbundle_path.exists());
782
783        // Extract archive
784        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
785        verify_extracted_bundle(&extract_dir);
786    }
787
788    #[test]
789    fn test_create_and_extract_gtbundle_with_bundle_yaml_root() {
790        let temp = tempdir().unwrap();
791        let bundle_dir = temp.path().join("test-bundle");
792        let gtbundle_path = temp.path().join("test.gtbundle");
793        let extract_dir = temp.path().join("extracted");
794
795        create_test_bundle_workspace(&bundle_dir);
796
797        create_gtbundle(&bundle_dir, &gtbundle_path).unwrap();
798        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
799
800        assert!(extract_dir.join(BUNDLE_WORKSPACE_MARKER).exists());
801        assert!(extract_dir.join("packs/test.txt").exists());
802    }
803
804    #[test]
805    fn test_is_gtbundle() {
806        let temp = tempdir().unwrap();
807
808        // Create a file
809        let file_path = temp.path().join("test.gtbundle");
810        fs::write(&file_path, "test").unwrap();
811        assert!(is_gtbundle_file(&file_path));
812        assert!(!is_gtbundle_dir(&file_path));
813
814        // Create a directory
815        let dir_path = temp.path().join("test2.gtbundle");
816        fs::create_dir(&dir_path).unwrap();
817        assert!(!is_gtbundle_file(&dir_path));
818        assert!(is_gtbundle_dir(&dir_path));
819    }
820
821    #[test]
822    fn test_detect_unknown_format() {
823        let temp = tempdir().unwrap();
824        let file_path = temp.path().join("unknown.gtbundle");
825        fs::write(&file_path, "UNKN").unwrap();
826
827        let result = detect_bundle_format(&file_path);
828        assert!(result.is_err());
829    }
830
831    // Phase 0 secret-leak hotfix regression tests.
832    // See plans/next-gen-deployment.md P0.1.
833    //
834    // Codex adversarial review on PR #109 caught that the original bail-on-detect
835    // approach broke the normal setup→repack flow (ApplyPackSetup writes
836    // .greentic/dev/.dev.secrets.env under the bundle root, then create_gtbundle
837    // bailed). The current implementation skips dev-store paths during the
838    // archive walk instead: the dev store stays on disk for runtime use, but
839    // the .gtbundle artifact never contains it.
840
841    fn extracted_paths(bundle_path: &Path) -> Vec<String> {
842        let temp = tempdir().unwrap();
843        extract_gtbundle(bundle_path, temp.path()).expect("extract");
844        let mut paths = Vec::new();
845        collect_paths(temp.path(), temp.path(), &mut paths);
846        paths.sort();
847        paths
848    }
849
850    fn collect_paths(root: &Path, current: &Path, out: &mut Vec<String>) {
851        let Ok(entries) = fs::read_dir(current) else {
852            return;
853        };
854        for entry in entries.flatten() {
855            let path = entry.path();
856            let rel = path.strip_prefix(root).unwrap();
857            out.push(rel.to_string_lossy().to_string());
858            if path.is_dir() {
859                collect_paths(root, &path, out);
860            }
861        }
862    }
863
864    #[test]
865    fn dev_secret_match_detects_dev_directory() {
866        assert_eq!(
867            dev_secret_match(Path::new(".greentic/dev/whatever.bin")),
868            Some(".greentic/dev/ tree")
869        );
870    }
871
872    #[test]
873    fn dev_secret_match_detects_state_dev_directory() {
874        assert_eq!(
875            dev_secret_match(Path::new(".greentic/state/dev/something")),
876            Some(".greentic/state/dev/ tree")
877        );
878    }
879
880    #[test]
881    fn dev_secret_match_detects_stray_dev_secrets_env_filename() {
882        assert_eq!(
883            dev_secret_match(Path::new("packs/.dev.secrets.env")),
884            Some(".dev.secrets.env file")
885        );
886    }
887
888    #[test]
889    fn dev_secret_match_passes_through_safe_paths() {
890        assert_eq!(dev_secret_match(Path::new("packs/pack-a.gtpack")), None);
891        assert_eq!(
892            dev_secret_match(Path::new("state/setup/provider-a.json")),
893            None
894        );
895    }
896
897    fn assert_no_dev_secret_paths_in_archive(archived: &[String]) {
898        for path in archived {
899            assert!(
900                !path.starts_with(".greentic/dev") && !path.contains("/.greentic/dev"),
901                ".greentic/dev tree leaked into archive: {path}"
902            );
903            assert!(
904                !path.starts_with(".greentic/state/dev") && !path.contains("/.greentic/state/dev"),
905                ".greentic/state/dev tree leaked into archive: {path}"
906            );
907            assert!(
908                !path.ends_with(".dev.secrets.env"),
909                ".dev.secrets.env file leaked into archive: {path}"
910            );
911        }
912    }
913
914    #[test]
915    fn create_gtbundle_zip_skips_dev_secret_directory() {
916        let temp = tempdir().unwrap();
917        let bundle_dir = temp.path().join("bundle");
918        create_test_bundle(&bundle_dir);
919        fs::create_dir_all(bundle_dir.join(".greentic/dev")).unwrap();
920        let leaked = "GTC_TOKEN=must-not-leak";
921        fs::write(bundle_dir.join(".greentic/dev/.dev.secrets.env"), leaked).unwrap();
922
923        let gtbundle_path = temp.path().join("clean.gtbundle");
924        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip)
925            .expect("repack must succeed after dev-store seeding");
926        assert!(gtbundle_path.exists(), "artifact must be produced");
927
928        let archived = extracted_paths(&gtbundle_path);
929        assert_no_dev_secret_paths_in_archive(&archived);
930        let raw = fs::read(&gtbundle_path).unwrap();
931        assert!(
932            !raw.windows(leaked.len())
933                .any(|window| window == leaked.as_bytes()),
934            "raw archive bytes must not contain dev-secret content"
935        );
936        // Source on disk is untouched — runtime still has its dev store.
937        assert!(bundle_dir.join(".greentic/dev/.dev.secrets.env").exists());
938    }
939
940    #[cfg(feature = "squashfs")]
941    #[test]
942    fn create_gtbundle_squashfs_skips_state_dev_directory() {
943        let temp = tempdir().unwrap();
944        let bundle_dir = temp.path().join("bundle");
945        create_test_bundle(&bundle_dir);
946        fs::create_dir_all(bundle_dir.join(".greentic/state/dev")).unwrap();
947        let leaked = "GTC_TOKEN=must-not-leak-state";
948        fs::write(
949            bundle_dir.join(".greentic/state/dev/.dev.secrets.env"),
950            leaked,
951        )
952        .unwrap();
953
954        let gtbundle_path = temp.path().join("clean.gtbundle");
955        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::SquashFs)
956            .expect("repack must succeed after state-dev seeding");
957        assert!(gtbundle_path.exists());
958
959        let archived = extracted_paths(&gtbundle_path);
960        assert_no_dev_secret_paths_in_archive(&archived);
961        let raw = fs::read(&gtbundle_path).unwrap();
962        assert!(
963            !raw.windows(leaked.len())
964                .any(|window| window == leaked.as_bytes())
965        );
966    }
967
968    #[test]
969    fn create_gtbundle_skips_stray_dev_secrets_env_filename() {
970        let temp = tempdir().unwrap();
971        let bundle_dir = temp.path().join("bundle");
972        create_test_bundle(&bundle_dir);
973        let leaked = "STRAY_TOKEN=must-not-ship";
974        fs::write(bundle_dir.join("packs/.dev.secrets.env"), leaked).unwrap();
975
976        let gtbundle_path = temp.path().join("stray.gtbundle");
977        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip)
978            .expect("repack must succeed when stray dev-secrets file present");
979
980        let archived = extracted_paths(&gtbundle_path);
981        assert_no_dev_secret_paths_in_archive(&archived);
982        let raw = fs::read(&gtbundle_path).unwrap();
983        assert!(
984            !raw.windows(leaked.len())
985                .any(|window| window == leaked.as_bytes())
986        );
987    }
988
989    // Phase 0 P0.1: simulate the executors.rs:209-219 + bin/greentic_setup.rs:294
990    // flow. ApplyPackSetup writes .greentic/dev/.dev.secrets.env under the
991    // bundle root, then run_simple_setup calls create_gtbundle on the same
992    // bundle dir. The previous bail-on-detect implementation broke this; the
993    // skip-in-walker implementation must round-trip cleanly.
994    #[test]
995    fn post_setup_repack_round_trips_when_dev_store_present() {
996        let temp = tempdir().unwrap();
997        let bundle_dir = temp.path().join("bundle");
998        create_test_bundle(&bundle_dir);
999
1000        // Step 1: ApplyPackSetup analogue — seed both possible dev-store paths
1001        // and a state/config/*/setup-answers.json with non-secret data that
1002        // MUST be preserved (the secret leak in this file is Phase B's job).
1003        fs::create_dir_all(bundle_dir.join(".greentic/dev")).unwrap();
1004        fs::write(
1005            bundle_dir.join(".greentic/dev/.dev.secrets.env"),
1006            "BOT_TOKEN=leaked-via-dev-store",
1007        )
1008        .unwrap();
1009        fs::create_dir_all(bundle_dir.join(".greentic/state/dev")).unwrap();
1010        fs::write(
1011            bundle_dir.join(".greentic/state/dev/.dev.secrets.env"),
1012            "ALT_TOKEN=leaked-via-state-dev",
1013        )
1014        .unwrap();
1015        fs::create_dir_all(bundle_dir.join("state/config/messaging-telegram")).unwrap();
1016        fs::write(
1017            bundle_dir.join("state/config/messaging-telegram/setup-answers.json"),
1018            r#"{"name":"my-bot","region":"eu-west-1"}"#,
1019        )
1020        .unwrap();
1021
1022        // Step 2: run_simple_setup analogue — repack the same dir.
1023        let gtbundle_path = temp.path().join("configured.gtbundle");
1024        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip)
1025            .expect("post-setup repack must succeed");
1026        assert!(gtbundle_path.exists());
1027
1028        // Step 3: extracted bundle contains exactly the right paths.
1029        let archived = extracted_paths(&gtbundle_path);
1030        assert!(
1031            !archived.iter().any(|p| p.starts_with(".greentic/dev")
1032                || p.starts_with(".greentic/state/dev")
1033                || p.ends_with(".dev.secrets.env")),
1034            "archive must not contain any dev-store path, got: {archived:?}"
1035        );
1036        assert!(
1037            archived
1038                .iter()
1039                .any(|p| p == "state/config/messaging-telegram/setup-answers.json"),
1040            "non-secret setup-answers.json must round-trip (secret leak is Phase B), got: {archived:?}"
1041        );
1042
1043        // Step 4: raw bytes contain neither leaked token.
1044        let raw = fs::read(&gtbundle_path).unwrap();
1045        for forbidden in ["leaked-via-dev-store", "leaked-via-state-dev"] {
1046            assert!(
1047                !raw.windows(forbidden.len())
1048                    .any(|window| window == forbidden.as_bytes()),
1049                "raw archive bytes must not contain {forbidden}"
1050            );
1051        }
1052
1053        // Step 5: source on disk untouched — runtime can still read its store.
1054        assert!(bundle_dir.join(".greentic/dev/.dev.secrets.env").exists());
1055        assert!(
1056            bundle_dir
1057                .join(".greentic/state/dev/.dev.secrets.env")
1058                .exists()
1059        );
1060    }
1061
1062    // Phase 0 P0.1 symlink-bypass regression tests.
1063    //
1064    // The denylist must refuse symlinks because the legacy archive walkers in
1065    // this file unconditionally dereference them: `path.is_dir()` follows
1066    // symlinks, and the else branch reads target bytes via `fs::read` /
1067    // `File::open`. Without this guard, a benign-looking symlink whose target
1068    // is `.greentic/dev/.dev.secrets.env` would ship target bytes into the
1069    // archive under the symlink's safe-looking name.
1070
1071    #[cfg(unix)]
1072    fn make_symlink(target: &Path, link: &Path) {
1073        std::os::unix::fs::symlink(target, link).expect("create symlink");
1074    }
1075
1076    #[cfg(unix)]
1077    #[test]
1078    fn create_gtbundle_zip_refuses_file_symlink_targeting_dev_secret() {
1079        let temp = tempdir().unwrap();
1080        let bundle_dir = temp.path().join("bundle");
1081        create_test_bundle(&bundle_dir);
1082        // Plant the secret OUTSIDE the bundle source — proves the leak is via
1083        // dereference, not via a deny-listed path inside the source tree.
1084        let secret_path = temp.path().join("external.dev.secrets.env");
1085        fs::write(&secret_path, "GTC_TOKEN=must-not-leak").unwrap();
1086        make_symlink(&secret_path, &bundle_dir.join("packs/seed.env"));
1087
1088        let gtbundle_path = temp.path().join("symlink.gtbundle");
1089        let err = create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip)
1090            .expect_err("symlink must be refused");
1091        let msg = format!("{err:#}");
1092        assert!(
1093            msg.contains("refusing to archive symlink"),
1094            "expected symlink refusal; got: {msg}"
1095        );
1096        assert!(
1097            !gtbundle_path.exists(),
1098            "denylisted build must not produce artifact"
1099        );
1100    }
1101
1102    #[cfg(all(unix, feature = "squashfs"))]
1103    #[test]
1104    fn create_gtbundle_squashfs_refuses_directory_symlink_targeting_dev_dir() {
1105        let temp = tempdir().unwrap();
1106        let bundle_dir = temp.path().join("bundle");
1107        create_test_bundle(&bundle_dir);
1108        let external_dev = temp.path().join("external-dev");
1109        fs::create_dir_all(&external_dev).unwrap();
1110        fs::write(external_dev.join(".dev.secrets.env"), "GTC_TOKEN=leaked").unwrap();
1111        make_symlink(&external_dev, &bundle_dir.join("packs/seed-dir"));
1112
1113        let gtbundle_path = temp.path().join("symlink-dir.gtbundle");
1114        let err = create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::SquashFs)
1115            .expect_err("directory symlink must be refused");
1116        assert!(format!("{err:#}").contains("refusing to archive symlink"));
1117        assert!(!gtbundle_path.exists());
1118    }
1119
1120    #[cfg(unix)]
1121    #[test]
1122    fn create_gtbundle_refuses_benign_looking_symlink() {
1123        // Even a symlink with no obviously deny-listed target must be refused:
1124        // we cannot inspect the target safely against all attack shapes, and
1125        // the legacy writers do not preserve symlinks anyway.
1126        let temp = tempdir().unwrap();
1127        let bundle_dir = temp.path().join("bundle");
1128        create_test_bundle(&bundle_dir);
1129        let benign_target = temp.path().join("benign.txt");
1130        fs::write(&benign_target, "benign content").unwrap();
1131        make_symlink(&benign_target, &bundle_dir.join("packs/link.txt"));
1132
1133        let gtbundle_path = temp.path().join("any-symlink.gtbundle");
1134        let err = create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip)
1135            .expect_err("any symlink must be refused");
1136        assert!(format!("{err:#}").contains("refusing to archive symlink"));
1137    }
1138
1139    // Phase 0 P0.4 — extraction-path hardening regression tests.
1140
1141    #[test]
1142    fn extract_zip_rejects_parent_dir_entry() {
1143        let temp = tempdir().unwrap();
1144        let zip_path = temp.path().join("evil.gtbundle");
1145        {
1146            let file = File::create(&zip_path).expect("zip");
1147            let mut zip = ZipWriter::new(file);
1148            zip.start_file("../escape.txt", SimpleFileOptions::default())
1149                .expect("start file");
1150            zip.write_all(b"pwned").expect("write");
1151            zip.finish().expect("finish");
1152        }
1153        let extract = temp.path().join("out");
1154        let err = extract_gtbundle(&zip_path, &extract).expect_err("must reject parent dir");
1155        assert!(format!("{err:#}").contains("parent dir"));
1156        assert!(!temp.path().join("escape.txt").exists());
1157    }
1158
1159    #[test]
1160    fn extract_zip_rejects_absolute_entry_path() {
1161        let temp = tempdir().unwrap();
1162        let zip_path = temp.path().join("absolute.gtbundle");
1163        {
1164            let file = File::create(&zip_path).expect("zip");
1165            let mut zip = ZipWriter::new(file);
1166            zip.start_file("/etc/passwd", SimpleFileOptions::default())
1167                .expect("start file");
1168            zip.write_all(b"pwned").expect("write");
1169            zip.finish().expect("finish");
1170        }
1171        let extract = temp.path().join("out");
1172        let result = extract_gtbundle(&zip_path, &extract);
1173        // The `zip` crate may strip the leading slash itself for entry name
1174        // bookkeeping; either way the extract must not place anything
1175        // outside the extract dir.
1176        if let Ok(()) = result {
1177            assert!(!Path::new("/etc/passwd-overwrite").exists());
1178            // Either way, no file should land outside `extract`.
1179            let etc_overwrite = extract.join("etc/passwd");
1180            // It's fine if the safe path lands it under `extract/etc/passwd` —
1181            // that's inside the extract root.
1182            if etc_overwrite.exists() {
1183                assert!(etc_overwrite.starts_with(&extract));
1184            }
1185        }
1186    }
1187
1188    #[cfg(unix)]
1189    #[test]
1190    fn extract_refuses_zip_writing_through_symlink_ancestor() {
1191        // Plant a symlink at `out/link -> /tmp/outside`, then attempt to
1192        // extract a zip whose entry is `link/inner.txt`. The hardened reader
1193        // must bail at `safe_create_dir_all` before writing.
1194        let temp = tempdir().unwrap();
1195        let outside = temp.path().join("outside");
1196        fs::create_dir(&outside).unwrap();
1197        let extract = temp.path().join("out");
1198        fs::create_dir(&extract).unwrap();
1199        std::os::unix::fs::symlink(&outside, extract.join("link")).unwrap();
1200
1201        let zip_path = temp.path().join("through-link.gtbundle");
1202        {
1203            let file = File::create(&zip_path).expect("zip");
1204            let mut zip = ZipWriter::new(file);
1205            zip.start_file("link/inner.txt", SimpleFileOptions::default())
1206                .expect("start file");
1207            zip.write_all(b"pwned").expect("write");
1208            zip.finish().expect("finish");
1209        }
1210        let err = extract_gtbundle(&zip_path, &extract).expect_err("must refuse symlink ancestor");
1211        assert!(format!("{err:#}").contains("descend through symlink"));
1212        assert!(!outside.join("inner.txt").exists());
1213    }
1214
1215    // Note: the `zip` 8.x writer rejects duplicate filenames at write time, so
1216    // we exercise the duplicate-path reader code via unit tests on the
1217    // shared helpers below.
1218
1219    #[test]
1220    fn normalize_inner_path_handles_common_shapes() {
1221        assert_eq!(
1222            normalize_archive_inner_path("packs/test.txt").unwrap(),
1223            Some("packs/test.txt".to_string())
1224        );
1225        assert_eq!(normalize_archive_inner_path("/").unwrap(), None);
1226        assert_eq!(normalize_archive_inner_path("").unwrap(), None);
1227        assert!(normalize_archive_inner_path("../escape").is_err());
1228        // Leading slashes are trimmed and the path is treated as relative to
1229        // the extract root — many archive formats represent rooted-looking
1230        // entries this way. The end result is still safe because the trimmed
1231        // path lands under output_dir.
1232        assert_eq!(
1233            normalize_archive_inner_path("/etc/passwd").unwrap(),
1234            Some("etc/passwd".to_string())
1235        );
1236    }
1237
1238    #[cfg(unix)]
1239    #[test]
1240    fn symlink_target_within_root_accepts_sibling() {
1241        assert_symlink_target_within_root("packs/a/link", Path::new("../b/file"))
1242            .expect("sibling resolves under root");
1243    }
1244
1245    #[cfg(unix)]
1246    #[test]
1247    fn symlink_target_within_root_rejects_escape() {
1248        let err = assert_symlink_target_within_root("packs/link", Path::new("../../etc"))
1249            .expect_err("must reject escape");
1250        assert!(format!("{err:#}").contains("escapes extract root"));
1251    }
1252
1253    #[cfg(unix)]
1254    #[test]
1255    fn symlink_target_within_root_rejects_absolute() {
1256        let err = assert_symlink_target_within_root("packs/link", Path::new("/etc/passwd"))
1257            .expect_err("must reject absolute");
1258        assert!(format!("{err:#}").contains("absolute symlink target"));
1259    }
1260}