use std::fs::{self, File};
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, 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),
}
}
#[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();
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(())
}
#[cfg(feature = "squashfs")]
fn add_directory_to_squashfs(
writer: &mut backhand::FilesystemWriter,
base_dir: &Path,
current_dir: &Path,
) -> Result<()> {
use backhand::NodeHeader;
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 path.is_dir() {
writer
.push_dir(&name, NodeHeader::default())
.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, NodeHeader::default())
.with_context(|| format!("failed to add file: {}", name))?;
}
}
Ok(())
}
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);
add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
zip.finish().context("failed to finalize archive")?;
Ok(())
}
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")?;
for node in reader.files() {
let path_str = node.fullpath.to_string_lossy();
if path_str.contains("..") {
bail!("invalid path in archive: {}", path_str);
}
if path_str == "/" || path_str.is_empty() {
continue;
}
let relative_path = path_str.trim_start_matches('/');
let out_path = output_dir.join(relative_path);
match &node.inner {
backhand::InnerNode::Dir(_) => {
fs::create_dir_all(&out_path)?;
}
backhand::InnerNode::File(file_reader) => {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
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() {
fs::create_dir_all(parent)?;
}
let target = link.link.to_string_lossy();
std::os::unix::fs::symlink(&*target, &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")?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.context("failed to read archive entry")?;
let name = file.name().to_string();
if name.contains("..") {
bail!("invalid path in archive: {}", name);
}
let out_path = output_dir.join(&name);
if file.is_dir() {
fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
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(())
}
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 path.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());
}
}