1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BundleFormat {
30 #[cfg(feature = "squashfs")]
32 SquashFs,
33 Zip,
35}
36
37#[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
53pub 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 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 if &magic == b"PK\x03\x04" {
70 return Ok(BundleFormat::Zip);
71 }
72
73 bail!("unknown archive format (magic: {:?})", magic);
74}
75
76pub fn create_gtbundle(bundle_dir: &Path, output_path: &Path) -> Result<()> {
87 create_gtbundle_with_format(bundle_dir, output_path, BundleFormat::default())
88}
89
90pub fn create_gtbundle_with_format(
92 bundle_dir: &Path,
93 output_path: &Path,
94 format: BundleFormat,
95) -> Result<()> {
96 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
110fn 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#[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 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 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#[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 if dev_secret_match(relative_path).is_some() {
201 continue;
202 }
203
204 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#[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
250fn 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 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
281pub 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#[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 let _ = link;
381 }
382 }
383 _ => {
384 }
386 }
387 }
388
389 Ok(())
390}
391
392fn 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 #[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
446fn 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(¤t) {
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(¤t)
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
603pub 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 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
625pub fn is_gtbundle_file(path: &Path) -> bool {
627 path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
628}
629
630pub fn is_gtbundle_dir(path: &Path) -> bool {
632 path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
633}
634
635fn 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 if dev_secret_match(relative_path).is_some() {
657 continue;
658 }
659
660 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 zip.add_directory(format!("{}/", name), options)?;
677 add_directory_to_zip(zip, base_dir, &path, options)?;
679 } else {
680 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_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip).unwrap();
736 assert!(gtbundle_path.exists());
737
738 let format = detect_bundle_format(>bundle_path).unwrap();
740 assert_eq!(format, BundleFormat::Zip);
741
742 extract_gtbundle(>bundle_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_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::SquashFs).unwrap();
759 assert!(gtbundle_path.exists());
760
761 let format = detect_bundle_format(>bundle_path).unwrap();
763 assert_eq!(format, BundleFormat::SquashFs);
764
765 extract_gtbundle(>bundle_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_gtbundle(&bundle_dir, >bundle_path).unwrap();
781 assert!(gtbundle_path.exists());
782
783 extract_gtbundle(>bundle_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, >bundle_path).unwrap();
798 extract_gtbundle(>bundle_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 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 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 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, >bundle_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(>bundle_path);
929 assert_no_dev_secret_paths_in_archive(&archived);
930 let raw = fs::read(>bundle_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 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, >bundle_path, BundleFormat::SquashFs)
956 .expect("repack must succeed after state-dev seeding");
957 assert!(gtbundle_path.exists());
958
959 let archived = extracted_paths(>bundle_path);
960 assert_no_dev_secret_paths_in_archive(&archived);
961 let raw = fs::read(>bundle_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, >bundle_path, BundleFormat::Zip)
978 .expect("repack must succeed when stray dev-secrets file present");
979
980 let archived = extracted_paths(>bundle_path);
981 assert_no_dev_secret_paths_in_archive(&archived);
982 let raw = fs::read(>bundle_path).unwrap();
983 assert!(
984 !raw.windows(leaked.len())
985 .any(|window| window == leaked.as_bytes())
986 );
987 }
988
989 #[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 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 let gtbundle_path = temp.path().join("configured.gtbundle");
1024 create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip)
1025 .expect("post-setup repack must succeed");
1026 assert!(gtbundle_path.exists());
1027
1028 let archived = extracted_paths(>bundle_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 let raw = fs::read(>bundle_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 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 #[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 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, >bundle_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, >bundle_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 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, >bundle_path, BundleFormat::Zip)
1135 .expect_err("any symlink must be refused");
1136 assert!(format!("{err:#}").contains("refusing to archive symlink"));
1137 }
1138
1139 #[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 if let Ok(()) = result {
1177 assert!(!Path::new("/etc/passwd-overwrite").exists());
1178 let etc_overwrite = extract.join("etc/passwd");
1180 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 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 #[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 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}