use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{BufReader, Read, Write};
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use zip::write::SimpleFileOptions;
use zip::{ZipArchive, ZipWriter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleFormat {
#[cfg(feature = "squashfs")]
SquashFs,
Zip,
}
#[allow(clippy::derivable_impls)]
impl Default for BundleFormat {
fn default() -> Self {
#[cfg(feature = "squashfs")]
{
Self::SquashFs
}
#[cfg(not(feature = "squashfs"))]
{
Self::Zip
}
}
}
pub fn detect_bundle_format(path: &Path) -> Result<BundleFormat> {
let mut file = File::open(path).context("failed to open bundle file")?;
let mut magic = [0u8; 4];
file.read_exact(&mut magic)
.context("failed to read magic bytes")?;
if &magic == b"hsqs" || &magic == b"sqsh" {
#[cfg(feature = "squashfs")]
return Ok(BundleFormat::SquashFs);
#[cfg(not(feature = "squashfs"))]
bail!("squashfs format detected but squashfs feature is not enabled");
}
if &magic == b"PK\x03\x04" {
return Ok(BundleFormat::Zip);
}
bail!("unknown archive format (magic: {:?})", magic);
}
pub fn create_gtbundle(bundle_dir: &Path, output_path: &Path) -> Result<()> {
create_gtbundle_with_format(bundle_dir, output_path, BundleFormat::default())
}
pub fn create_gtbundle_with_format(
bundle_dir: &Path,
output_path: &Path,
format: BundleFormat,
) -> Result<()> {
match format {
#[cfg(feature = "squashfs")]
BundleFormat::SquashFs => create_gtbundle_squashfs(bundle_dir, output_path),
BundleFormat::Zip => create_gtbundle_zip(bundle_dir, output_path),
}
}
fn dev_secret_match(relative: &Path) -> Option<&'static str> {
let parts: Vec<&str> = relative
.components()
.filter_map(|component| match component {
Component::Normal(part) => part.to_str(),
_ => None,
})
.collect();
for window in parts.windows(2) {
if window[0] == ".greentic" && window[1] == "dev" {
return Some(".greentic/dev/ tree");
}
}
for window in parts.windows(3) {
if window[0] == ".greentic" && window[1] == "state" && window[2] == "dev" {
return Some(".greentic/state/dev/ tree");
}
}
if parts.last().copied() == Some(".dev.secrets.env") {
return Some(".dev.secrets.env file");
}
None
}
#[cfg(feature = "squashfs")]
fn create_gtbundle_squashfs(bundle_dir: &Path, output_path: &Path) -> Result<()> {
use backhand::FilesystemWriter;
if !bundle_dir.is_dir() {
bail!("bundle directory not found: {}", bundle_dir.display());
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).context("failed to create output directory")?;
}
let mut writer = FilesystemWriter::default();
writer.set_root_mode(0o755);
let result = (|| -> Result<()> {
add_directory_to_squashfs(&mut writer, bundle_dir, bundle_dir)?;
let mut output = File::create(output_path)
.with_context(|| format!("failed to create archive: {}", output_path.display()))?;
writer
.write(&mut output)
.context("failed to write squashfs archive")?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(output_path);
}
result
}
#[cfg(feature = "squashfs")]
fn add_directory_to_squashfs(
writer: &mut backhand::FilesystemWriter,
base_dir: &Path,
current_dir: &Path,
) -> Result<()> {
use std::io::Cursor;
let entries = fs::read_dir(current_dir)
.with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let relative_path = path
.strip_prefix(base_dir)
.context("failed to compute relative path")?;
let name = relative_path.to_string_lossy().to_string();
if dev_secret_match(relative_path).is_some() {
continue;
}
let file_type = entry
.file_type()
.with_context(|| format!("file type for {}", path.display()))?;
if file_type.is_symlink() {
bail!(
"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)",
relative_path.display()
);
}
if file_type.is_dir() {
writer
.push_dir(&name, dir_node_header())
.with_context(|| format!("failed to add directory: {}", name))?;
add_directory_to_squashfs(writer, base_dir, &path)?;
} else {
let content = fs::read(&path)
.with_context(|| format!("failed to read file: {}", path.display()))?;
let cursor = Cursor::new(content);
writer
.push_file(cursor, &name, file_node_header())
.with_context(|| format!("failed to add file: {}", name))?;
}
}
Ok(())
}
#[cfg(feature = "squashfs")]
fn dir_node_header() -> backhand::NodeHeader {
backhand::NodeHeader::new(0o755, 0, 0, 0)
}
#[cfg(feature = "squashfs")]
fn file_node_header() -> backhand::NodeHeader {
backhand::NodeHeader::new(0o644, 0, 0, 0)
}
fn create_gtbundle_zip(bundle_dir: &Path, output_path: &Path) -> Result<()> {
if !bundle_dir.is_dir() {
bail!("bundle directory not found: {}", bundle_dir.display());
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).context("failed to create output directory")?;
}
let file = File::create(output_path)
.with_context(|| format!("failed to create archive: {}", output_path.display()))?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
let result = (|| -> Result<()> {
add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
zip.finish().context("failed to finalize archive")?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(output_path);
}
result
}
pub fn extract_gtbundle(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
if !gtbundle_path.is_file() {
bail!("gtbundle file not found: {}", gtbundle_path.display());
}
let format = detect_bundle_format(gtbundle_path)?;
match format {
#[cfg(feature = "squashfs")]
BundleFormat::SquashFs => extract_gtbundle_squashfs(gtbundle_path, output_dir),
BundleFormat::Zip => extract_gtbundle_zip(gtbundle_path, output_dir),
}
}
#[cfg(feature = "squashfs")]
fn extract_gtbundle_squashfs(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
use backhand::FilesystemReader;
let file = BufReader::new(
File::open(gtbundle_path)
.with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?,
);
let reader = FilesystemReader::from_reader(file).context("failed to read squashfs archive")?;
fs::create_dir_all(output_dir).context("failed to create output directory")?;
let mut seen_paths: HashSet<String> = HashSet::new();
for node in reader.files() {
let full = node.fullpath.to_string_lossy();
let Some(normalized) = normalize_archive_inner_path(full.as_ref())? else {
continue;
};
if !seen_paths.insert(normalized.clone()) {
bail!("duplicate squashfs entry rejected: {normalized}");
}
let out_path = safe_output_path(output_dir, &normalized)?;
match &node.inner {
backhand::InnerNode::Dir(_) => {
safe_create_dir_all(output_dir, &out_path)
.with_context(|| format!("create directory {}", out_path.display()))?;
}
backhand::InnerNode::File(file_reader) => {
if let Some(parent) = out_path.parent() {
safe_create_dir_all(output_dir, parent)
.with_context(|| format!("create parent directory {}", parent.display()))?;
}
assert_no_existing_symlink(&out_path)
.with_context(|| format!("validate destination for {normalized}"))?;
let mut out_file = File::create(&out_path)
.with_context(|| format!("failed to create: {}", out_path.display()))?;
let content = reader.file(file_reader);
let mut decompressed = Vec::new();
content
.reader()
.read_to_end(&mut decompressed)
.context("failed to decompress file")?;
out_file
.write_all(&decompressed)
.context("failed to write file")?;
}
backhand::InnerNode::Symlink(link) => {
#[cfg(unix)]
{
if let Some(parent) = out_path.parent() {
safe_create_dir_all(output_dir, parent).with_context(|| {
format!("create parent directory {}", parent.display())
})?;
}
assert_no_existing_symlink(&out_path)
.with_context(|| format!("validate destination for {normalized}"))?;
assert_symlink_target_within_root(&normalized, &link.link)
.with_context(|| format!("validate symlink target for {normalized}"))?;
std::os::unix::fs::symlink(&link.link, &out_path).with_context(|| {
format!("failed to create symlink: {}", out_path.display())
})?;
}
#[cfg(not(unix))]
{
let _ = link;
}
}
_ => {
}
}
}
Ok(())
}
fn extract_gtbundle_zip(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
let file = File::open(gtbundle_path)
.with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?;
let mut archive = ZipArchive::new(file).context("failed to read archive")?;
fs::create_dir_all(output_dir).context("failed to create output directory")?;
let mut seen_paths: HashSet<String> = HashSet::new();
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.context("failed to read archive entry")?;
let raw_name = file.name().to_string();
let Some(normalized) = normalize_archive_inner_path(&raw_name)? else {
continue;
};
if !seen_paths.insert(normalized.clone()) {
bail!("duplicate zip entry rejected: {normalized}");
}
let out_path = safe_output_path(output_dir, &normalized)?;
if file.is_dir() || raw_name.ends_with('/') {
safe_create_dir_all(output_dir, &out_path)
.with_context(|| format!("create directory {}", out_path.display()))?;
} else {
if let Some(parent) = out_path.parent() {
safe_create_dir_all(output_dir, parent)
.with_context(|| format!("create parent directory {}", parent.display()))?;
}
assert_no_existing_symlink(&out_path)
.with_context(|| format!("validate destination for {normalized}"))?;
let mut out_file = File::create(&out_path)
.with_context(|| format!("failed to create: {}", out_path.display()))?;
std::io::copy(&mut file, &mut out_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
}
}
}
}
Ok(())
}
fn normalize_archive_inner_path(raw: &str) -> Result<Option<String>> {
let trimmed = raw.trim_matches('/');
if trimmed.is_empty() {
return Ok(None);
}
let mut parts: Vec<String> = Vec::new();
for component in Path::new(trimmed).components() {
match component {
Component::Normal(part) => {
let part = part
.to_str()
.ok_or_else(|| anyhow!("archive path must be valid UTF-8: {raw}"))?;
if part.is_empty() {
bail!("archive path has empty component: {raw}");
}
parts.push(part.to_string());
}
Component::CurDir => {}
Component::ParentDir => {
bail!("refusing archive path with parent dir component: {raw}");
}
Component::RootDir | Component::Prefix(_) => {
bail!("refusing absolute archive path: {raw}");
}
}
}
if parts.is_empty() {
return Ok(None);
}
Ok(Some(parts.join("/")))
}
fn safe_output_path(out_dir: &Path, inner_path: &str) -> Result<PathBuf> {
let mut out = out_dir.to_path_buf();
for component in Path::new(inner_path).components() {
match component {
Component::Normal(part) => out.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
bail!("refusing to extract unsafe archive path: {inner_path}");
}
}
}
Ok(out)
}
fn safe_create_dir_all(extract_root: &Path, target: &Path) -> Result<()> {
if !target.starts_with(extract_root) {
bail!(
"refusing to descend outside extract root: {} not under {}",
target.display(),
extract_root.display()
);
}
let relative = target.strip_prefix(extract_root).map_err(|err| {
anyhow!(
"make {} relative to extract root {}: {err}",
target.display(),
extract_root.display()
)
})?;
let mut current = extract_root.to_path_buf();
for component in relative.components() {
let part = match component {
Component::Normal(part) => part,
Component::CurDir => continue,
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
bail!(
"refusing to traverse unsafe component during mkdir: {}",
target.display()
);
}
};
current.push(part);
match fs::symlink_metadata(¤t) {
Ok(meta) => {
if meta.file_type().is_symlink() {
bail!(
"refusing to descend through symlink at {}",
current.display()
);
}
if !meta.file_type().is_dir() {
bail!(
"refusing to descend through non-directory at {}",
current.display()
);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(¤t)
.with_context(|| format!("create directory {}", current.display()))?;
}
Err(err) => {
return Err(anyhow::Error::new(err)
.context(format!("stat {} during safe mkdir", current.display())));
}
}
}
Ok(())
}
fn assert_no_existing_symlink(destination: &Path) -> Result<()> {
match fs::symlink_metadata(destination) {
Ok(meta) if meta.file_type().is_symlink() => {
bail!(
"refusing to write through existing symlink at {}",
destination.display()
);
}
Ok(_) | Err(_) => Ok(()),
}
}
#[cfg(unix)]
fn assert_symlink_target_within_root(symlink_inner_path: &str, target: &Path) -> Result<()> {
let parent_depth = Path::new(symlink_inner_path)
.parent()
.map(|parent| {
parent
.components()
.filter(|component| matches!(component, Component::Normal(_)))
.count()
})
.unwrap_or(0);
let mut depth: i64 = parent_depth as i64;
for component in target.components() {
match component {
Component::Normal(_) => depth += 1,
Component::CurDir => {}
Component::ParentDir => {
depth -= 1;
if depth < 0 {
bail!(
"refusing symlink target {} from {}: escapes extract root",
target.display(),
symlink_inner_path
);
}
}
Component::RootDir | Component::Prefix(_) => {
bail!(
"refusing absolute symlink target {} from {}",
target.display(),
symlink_inner_path
);
}
}
}
Ok(())
}
pub fn extract_gtbundle_to_temp(gtbundle_path: &Path) -> Result<PathBuf> {
let temp_dir = std::env::temp_dir().join(format!(
"gtbundle-{}",
gtbundle_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("bundle")
));
if temp_dir.exists() {
fs::remove_dir_all(&temp_dir).ok();
}
extract_gtbundle(gtbundle_path, &temp_dir)?;
Ok(temp_dir)
}
pub fn is_gtbundle_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
}
pub fn is_gtbundle_dir(path: &Path) -> bool {
path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
}
fn add_directory_to_zip<W: Write + std::io::Seek>(
zip: &mut ZipWriter<W>,
base_dir: &Path,
current_dir: &Path,
options: SimpleFileOptions,
) -> Result<()> {
let entries = fs::read_dir(current_dir)
.with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let relative_path = path
.strip_prefix(base_dir)
.context("failed to compute relative path")?;
let name = relative_path.to_string_lossy();
if dev_secret_match(relative_path).is_some() {
continue;
}
let file_type = entry
.file_type()
.with_context(|| format!("file type for {}", path.display()))?;
if file_type.is_symlink() {
bail!(
"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)",
relative_path.display()
);
}
if file_type.is_dir() {
zip.add_directory(format!("{}/", name), options)?;
add_directory_to_zip(zip, base_dir, &path, options)?;
} else {
zip.start_file(name.to_string(), options)?;
let mut file = File::open(&path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
zip.write_all(&buffer)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundle::{BUNDLE_WORKSPACE_MARKER, LEGACY_BUNDLE_MARKER};
use std::fs;
use tempfile::tempdir;
fn create_test_bundle(bundle_dir: &Path) {
fs::create_dir_all(bundle_dir).unwrap();
fs::write(bundle_dir.join(LEGACY_BUNDLE_MARKER), "name: test").unwrap();
fs::create_dir_all(bundle_dir.join("packs")).unwrap();
fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
}
fn verify_extracted_bundle(extract_dir: &Path) {
assert!(extract_dir.join(LEGACY_BUNDLE_MARKER).exists());
assert!(extract_dir.join("packs/test.txt").exists());
let content = fs::read_to_string(extract_dir.join("packs/test.txt")).unwrap();
assert_eq!(content, "hello");
}
fn create_test_bundle_workspace(bundle_dir: &Path) {
fs::create_dir_all(bundle_dir).unwrap();
fs::write(
bundle_dir.join(BUNDLE_WORKSPACE_MARKER),
"schema_version: 1\n",
)
.unwrap();
fs::create_dir_all(bundle_dir.join("packs")).unwrap();
fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
}
#[test]
fn test_create_and_extract_gtbundle_zip() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("test-bundle");
let gtbundle_path = temp.path().join("test.gtbundle");
let extract_dir = temp.path().join("extracted");
create_test_bundle(&bundle_dir);
create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip).unwrap();
assert!(gtbundle_path.exists());
let format = detect_bundle_format(>bundle_path).unwrap();
assert_eq!(format, BundleFormat::Zip);
extract_gtbundle(>bundle_path, &extract_dir).unwrap();
verify_extracted_bundle(&extract_dir);
}
#[cfg(feature = "squashfs")]
#[test]
fn test_create_and_extract_gtbundle_squashfs() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("test-bundle");
let gtbundle_path = temp.path().join("test.gtbundle");
let extract_dir = temp.path().join("extracted");
create_test_bundle(&bundle_dir);
create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::SquashFs).unwrap();
assert!(gtbundle_path.exists());
let format = detect_bundle_format(>bundle_path).unwrap();
assert_eq!(format, BundleFormat::SquashFs);
extract_gtbundle(>bundle_path, &extract_dir).unwrap();
verify_extracted_bundle(&extract_dir);
}
#[test]
fn test_create_and_extract_gtbundle_default() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("test-bundle");
let gtbundle_path = temp.path().join("test.gtbundle");
let extract_dir = temp.path().join("extracted");
create_test_bundle(&bundle_dir);
create_gtbundle(&bundle_dir, >bundle_path).unwrap();
assert!(gtbundle_path.exists());
extract_gtbundle(>bundle_path, &extract_dir).unwrap();
verify_extracted_bundle(&extract_dir);
}
#[test]
fn test_create_and_extract_gtbundle_with_bundle_yaml_root() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("test-bundle");
let gtbundle_path = temp.path().join("test.gtbundle");
let extract_dir = temp.path().join("extracted");
create_test_bundle_workspace(&bundle_dir);
create_gtbundle(&bundle_dir, >bundle_path).unwrap();
extract_gtbundle(>bundle_path, &extract_dir).unwrap();
assert!(extract_dir.join(BUNDLE_WORKSPACE_MARKER).exists());
assert!(extract_dir.join("packs/test.txt").exists());
}
#[test]
fn test_is_gtbundle() {
let temp = tempdir().unwrap();
let file_path = temp.path().join("test.gtbundle");
fs::write(&file_path, "test").unwrap();
assert!(is_gtbundle_file(&file_path));
assert!(!is_gtbundle_dir(&file_path));
let dir_path = temp.path().join("test2.gtbundle");
fs::create_dir(&dir_path).unwrap();
assert!(!is_gtbundle_file(&dir_path));
assert!(is_gtbundle_dir(&dir_path));
}
#[test]
fn test_detect_unknown_format() {
let temp = tempdir().unwrap();
let file_path = temp.path().join("unknown.gtbundle");
fs::write(&file_path, "UNKN").unwrap();
let result = detect_bundle_format(&file_path);
assert!(result.is_err());
}
fn extracted_paths(bundle_path: &Path) -> Vec<String> {
let temp = tempdir().unwrap();
extract_gtbundle(bundle_path, temp.path()).expect("extract");
let mut paths = Vec::new();
collect_paths(temp.path(), temp.path(), &mut paths);
paths.sort();
paths
}
fn collect_paths(root: &Path, current: &Path, out: &mut Vec<String>) {
let Ok(entries) = fs::read_dir(current) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let rel = path.strip_prefix(root).unwrap();
out.push(rel.to_string_lossy().to_string());
if path.is_dir() {
collect_paths(root, &path, out);
}
}
}
#[test]
fn dev_secret_match_detects_dev_directory() {
assert_eq!(
dev_secret_match(Path::new(".greentic/dev/whatever.bin")),
Some(".greentic/dev/ tree")
);
}
#[test]
fn dev_secret_match_detects_state_dev_directory() {
assert_eq!(
dev_secret_match(Path::new(".greentic/state/dev/something")),
Some(".greentic/state/dev/ tree")
);
}
#[test]
fn dev_secret_match_detects_stray_dev_secrets_env_filename() {
assert_eq!(
dev_secret_match(Path::new("packs/.dev.secrets.env")),
Some(".dev.secrets.env file")
);
}
#[test]
fn dev_secret_match_passes_through_safe_paths() {
assert_eq!(dev_secret_match(Path::new("packs/pack-a.gtpack")), None);
assert_eq!(
dev_secret_match(Path::new("state/setup/provider-a.json")),
None
);
}
fn assert_no_dev_secret_paths_in_archive(archived: &[String]) {
for path in archived {
assert!(
!path.starts_with(".greentic/dev") && !path.contains("/.greentic/dev"),
".greentic/dev tree leaked into archive: {path}"
);
assert!(
!path.starts_with(".greentic/state/dev") && !path.contains("/.greentic/state/dev"),
".greentic/state/dev tree leaked into archive: {path}"
);
assert!(
!path.ends_with(".dev.secrets.env"),
".dev.secrets.env file leaked into archive: {path}"
);
}
}
#[test]
fn create_gtbundle_zip_skips_dev_secret_directory() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
fs::create_dir_all(bundle_dir.join(".greentic/dev")).unwrap();
let leaked = "GTC_TOKEN=must-not-leak";
fs::write(bundle_dir.join(".greentic/dev/.dev.secrets.env"), leaked).unwrap();
let gtbundle_path = temp.path().join("clean.gtbundle");
create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip)
.expect("repack must succeed after dev-store seeding");
assert!(gtbundle_path.exists(), "artifact must be produced");
let archived = extracted_paths(>bundle_path);
assert_no_dev_secret_paths_in_archive(&archived);
let raw = fs::read(>bundle_path).unwrap();
assert!(
!raw.windows(leaked.len())
.any(|window| window == leaked.as_bytes()),
"raw archive bytes must not contain dev-secret content"
);
assert!(bundle_dir.join(".greentic/dev/.dev.secrets.env").exists());
}
#[cfg(feature = "squashfs")]
#[test]
fn create_gtbundle_squashfs_skips_state_dev_directory() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
fs::create_dir_all(bundle_dir.join(".greentic/state/dev")).unwrap();
let leaked = "GTC_TOKEN=must-not-leak-state";
fs::write(
bundle_dir.join(".greentic/state/dev/.dev.secrets.env"),
leaked,
)
.unwrap();
let gtbundle_path = temp.path().join("clean.gtbundle");
create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::SquashFs)
.expect("repack must succeed after state-dev seeding");
assert!(gtbundle_path.exists());
let archived = extracted_paths(>bundle_path);
assert_no_dev_secret_paths_in_archive(&archived);
let raw = fs::read(>bundle_path).unwrap();
assert!(
!raw.windows(leaked.len())
.any(|window| window == leaked.as_bytes())
);
}
#[test]
fn create_gtbundle_skips_stray_dev_secrets_env_filename() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
let leaked = "STRAY_TOKEN=must-not-ship";
fs::write(bundle_dir.join("packs/.dev.secrets.env"), leaked).unwrap();
let gtbundle_path = temp.path().join("stray.gtbundle");
create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip)
.expect("repack must succeed when stray dev-secrets file present");
let archived = extracted_paths(>bundle_path);
assert_no_dev_secret_paths_in_archive(&archived);
let raw = fs::read(>bundle_path).unwrap();
assert!(
!raw.windows(leaked.len())
.any(|window| window == leaked.as_bytes())
);
}
#[test]
fn post_setup_repack_round_trips_when_dev_store_present() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
fs::create_dir_all(bundle_dir.join(".greentic/dev")).unwrap();
fs::write(
bundle_dir.join(".greentic/dev/.dev.secrets.env"),
"BOT_TOKEN=leaked-via-dev-store",
)
.unwrap();
fs::create_dir_all(bundle_dir.join(".greentic/state/dev")).unwrap();
fs::write(
bundle_dir.join(".greentic/state/dev/.dev.secrets.env"),
"ALT_TOKEN=leaked-via-state-dev",
)
.unwrap();
fs::create_dir_all(bundle_dir.join("state/config/messaging-telegram")).unwrap();
fs::write(
bundle_dir.join("state/config/messaging-telegram/setup-answers.json"),
r#"{"name":"my-bot","region":"eu-west-1"}"#,
)
.unwrap();
let gtbundle_path = temp.path().join("configured.gtbundle");
create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip)
.expect("post-setup repack must succeed");
assert!(gtbundle_path.exists());
let archived = extracted_paths(>bundle_path);
assert!(
!archived.iter().any(|p| p.starts_with(".greentic/dev")
|| p.starts_with(".greentic/state/dev")
|| p.ends_with(".dev.secrets.env")),
"archive must not contain any dev-store path, got: {archived:?}"
);
assert!(
archived
.iter()
.any(|p| p == "state/config/messaging-telegram/setup-answers.json"),
"non-secret setup-answers.json must round-trip (secret leak is Phase B), got: {archived:?}"
);
let raw = fs::read(>bundle_path).unwrap();
for forbidden in ["leaked-via-dev-store", "leaked-via-state-dev"] {
assert!(
!raw.windows(forbidden.len())
.any(|window| window == forbidden.as_bytes()),
"raw archive bytes must not contain {forbidden}"
);
}
assert!(bundle_dir.join(".greentic/dev/.dev.secrets.env").exists());
assert!(
bundle_dir
.join(".greentic/state/dev/.dev.secrets.env")
.exists()
);
}
#[cfg(unix)]
fn make_symlink(target: &Path, link: &Path) {
std::os::unix::fs::symlink(target, link).expect("create symlink");
}
#[cfg(unix)]
#[test]
fn create_gtbundle_zip_refuses_file_symlink_targeting_dev_secret() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
let secret_path = temp.path().join("external.dev.secrets.env");
fs::write(&secret_path, "GTC_TOKEN=must-not-leak").unwrap();
make_symlink(&secret_path, &bundle_dir.join("packs/seed.env"));
let gtbundle_path = temp.path().join("symlink.gtbundle");
let err = create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip)
.expect_err("symlink must be refused");
let msg = format!("{err:#}");
assert!(
msg.contains("refusing to archive symlink"),
"expected symlink refusal; got: {msg}"
);
assert!(
!gtbundle_path.exists(),
"denylisted build must not produce artifact"
);
}
#[cfg(all(unix, feature = "squashfs"))]
#[test]
fn create_gtbundle_squashfs_refuses_directory_symlink_targeting_dev_dir() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
let external_dev = temp.path().join("external-dev");
fs::create_dir_all(&external_dev).unwrap();
fs::write(external_dev.join(".dev.secrets.env"), "GTC_TOKEN=leaked").unwrap();
make_symlink(&external_dev, &bundle_dir.join("packs/seed-dir"));
let gtbundle_path = temp.path().join("symlink-dir.gtbundle");
let err = create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::SquashFs)
.expect_err("directory symlink must be refused");
assert!(format!("{err:#}").contains("refusing to archive symlink"));
assert!(!gtbundle_path.exists());
}
#[cfg(unix)]
#[test]
fn create_gtbundle_refuses_benign_looking_symlink() {
let temp = tempdir().unwrap();
let bundle_dir = temp.path().join("bundle");
create_test_bundle(&bundle_dir);
let benign_target = temp.path().join("benign.txt");
fs::write(&benign_target, "benign content").unwrap();
make_symlink(&benign_target, &bundle_dir.join("packs/link.txt"));
let gtbundle_path = temp.path().join("any-symlink.gtbundle");
let err = create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip)
.expect_err("any symlink must be refused");
assert!(format!("{err:#}").contains("refusing to archive symlink"));
}
#[test]
fn extract_zip_rejects_parent_dir_entry() {
let temp = tempdir().unwrap();
let zip_path = temp.path().join("evil.gtbundle");
{
let file = File::create(&zip_path).expect("zip");
let mut zip = ZipWriter::new(file);
zip.start_file("../escape.txt", SimpleFileOptions::default())
.expect("start file");
zip.write_all(b"pwned").expect("write");
zip.finish().expect("finish");
}
let extract = temp.path().join("out");
let err = extract_gtbundle(&zip_path, &extract).expect_err("must reject parent dir");
assert!(format!("{err:#}").contains("parent dir"));
assert!(!temp.path().join("escape.txt").exists());
}
#[test]
fn extract_zip_rejects_absolute_entry_path() {
let temp = tempdir().unwrap();
let zip_path = temp.path().join("absolute.gtbundle");
{
let file = File::create(&zip_path).expect("zip");
let mut zip = ZipWriter::new(file);
zip.start_file("/etc/passwd", SimpleFileOptions::default())
.expect("start file");
zip.write_all(b"pwned").expect("write");
zip.finish().expect("finish");
}
let extract = temp.path().join("out");
let result = extract_gtbundle(&zip_path, &extract);
if let Ok(()) = result {
assert!(!Path::new("/etc/passwd-overwrite").exists());
let etc_overwrite = extract.join("etc/passwd");
if etc_overwrite.exists() {
assert!(etc_overwrite.starts_with(&extract));
}
}
}
#[cfg(unix)]
#[test]
fn extract_refuses_zip_writing_through_symlink_ancestor() {
let temp = tempdir().unwrap();
let outside = temp.path().join("outside");
fs::create_dir(&outside).unwrap();
let extract = temp.path().join("out");
fs::create_dir(&extract).unwrap();
std::os::unix::fs::symlink(&outside, extract.join("link")).unwrap();
let zip_path = temp.path().join("through-link.gtbundle");
{
let file = File::create(&zip_path).expect("zip");
let mut zip = ZipWriter::new(file);
zip.start_file("link/inner.txt", SimpleFileOptions::default())
.expect("start file");
zip.write_all(b"pwned").expect("write");
zip.finish().expect("finish");
}
let err = extract_gtbundle(&zip_path, &extract).expect_err("must refuse symlink ancestor");
assert!(format!("{err:#}").contains("descend through symlink"));
assert!(!outside.join("inner.txt").exists());
}
#[test]
fn normalize_inner_path_handles_common_shapes() {
assert_eq!(
normalize_archive_inner_path("packs/test.txt").unwrap(),
Some("packs/test.txt".to_string())
);
assert_eq!(normalize_archive_inner_path("/").unwrap(), None);
assert_eq!(normalize_archive_inner_path("").unwrap(), None);
assert!(normalize_archive_inner_path("../escape").is_err());
assert_eq!(
normalize_archive_inner_path("/etc/passwd").unwrap(),
Some("etc/passwd".to_string())
);
}
#[cfg(unix)]
#[test]
fn symlink_target_within_root_accepts_sibling() {
assert_symlink_target_within_root("packs/a/link", Path::new("../b/file"))
.expect("sibling resolves under root");
}
#[cfg(unix)]
#[test]
fn symlink_target_within_root_rejects_escape() {
let err = assert_symlink_target_within_root("packs/link", Path::new("../../etc"))
.expect_err("must reject escape");
assert!(format!("{err:#}").contains("escapes extract root"));
}
#[cfg(unix)]
#[test]
fn symlink_target_within_root_rejects_absolute() {
let err = assert_symlink_target_within_root("packs/link", Path::new("/etc/passwd"))
.expect_err("must reject absolute");
assert!(format!("{err:#}").contains("absolute symlink target"));
}
}