1use std::fs::{self, File};
19use std::io::{BufReader, Read, Write};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result, bail};
23use zip::write::SimpleFileOptions;
24use zip::{ZipArchive, ZipWriter};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum BundleFormat {
29 #[cfg(feature = "squashfs")]
31 SquashFs,
32 Zip,
34}
35
36#[allow(clippy::derivable_impls)]
39impl Default for BundleFormat {
40 fn default() -> Self {
41 #[cfg(feature = "squashfs")]
42 {
43 Self::SquashFs
44 }
45 #[cfg(not(feature = "squashfs"))]
46 {
47 Self::Zip
48 }
49 }
50}
51
52pub fn detect_bundle_format(path: &Path) -> Result<BundleFormat> {
54 let mut file = File::open(path).context("failed to open bundle file")?;
55 let mut magic = [0u8; 4];
56 file.read_exact(&mut magic)
57 .context("failed to read magic bytes")?;
58
59 if &magic == b"hsqs" || &magic == b"sqsh" {
61 #[cfg(feature = "squashfs")]
62 return Ok(BundleFormat::SquashFs);
63 #[cfg(not(feature = "squashfs"))]
64 bail!("squashfs format detected but squashfs feature is not enabled");
65 }
66
67 if &magic == b"PK\x03\x04" {
69 return Ok(BundleFormat::Zip);
70 }
71
72 bail!("unknown archive format (magic: {:?})", magic);
73}
74
75pub fn create_gtbundle(bundle_dir: &Path, output_path: &Path) -> Result<()> {
86 create_gtbundle_with_format(bundle_dir, output_path, BundleFormat::default())
87}
88
89pub fn create_gtbundle_with_format(
91 bundle_dir: &Path,
92 output_path: &Path,
93 format: BundleFormat,
94) -> Result<()> {
95 match format {
96 #[cfg(feature = "squashfs")]
97 BundleFormat::SquashFs => create_gtbundle_squashfs(bundle_dir, output_path),
98 BundleFormat::Zip => create_gtbundle_zip(bundle_dir, output_path),
99 }
100}
101
102#[cfg(feature = "squashfs")]
104fn create_gtbundle_squashfs(bundle_dir: &Path, output_path: &Path) -> Result<()> {
105 use backhand::FilesystemWriter;
106
107 if !bundle_dir.is_dir() {
108 bail!("bundle directory not found: {}", bundle_dir.display());
109 }
110
111 if let Some(parent) = output_path.parent() {
113 fs::create_dir_all(parent).context("failed to create output directory")?;
114 }
115
116 let mut writer = FilesystemWriter::default();
117
118 add_directory_to_squashfs(&mut writer, bundle_dir, bundle_dir)?;
120
121 let mut output = File::create(output_path)
123 .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
124 writer
125 .write(&mut output)
126 .context("failed to write squashfs archive")?;
127
128 Ok(())
129}
130
131#[cfg(feature = "squashfs")]
133fn add_directory_to_squashfs(
134 writer: &mut backhand::FilesystemWriter,
135 base_dir: &Path,
136 current_dir: &Path,
137) -> Result<()> {
138 use backhand::NodeHeader;
139 use std::io::Cursor;
140
141 let entries = fs::read_dir(current_dir)
142 .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
143
144 for entry in entries {
145 let entry = entry?;
146 let path = entry.path();
147 let relative_path = path
148 .strip_prefix(base_dir)
149 .context("failed to compute relative path")?;
150 let name = relative_path.to_string_lossy().to_string();
151
152 if path.is_dir() {
153 writer
155 .push_dir(&name, NodeHeader::default())
156 .with_context(|| format!("failed to add directory: {}", name))?;
157 add_directory_to_squashfs(writer, base_dir, &path)?;
159 } else {
160 let content = fs::read(&path)
162 .with_context(|| format!("failed to read file: {}", path.display()))?;
163 let cursor = Cursor::new(content);
164 writer
165 .push_file(cursor, &name, NodeHeader::default())
166 .with_context(|| format!("failed to add file: {}", name))?;
167 }
168 }
169
170 Ok(())
171}
172
173fn create_gtbundle_zip(bundle_dir: &Path, output_path: &Path) -> Result<()> {
175 if !bundle_dir.is_dir() {
176 bail!("bundle directory not found: {}", bundle_dir.display());
177 }
178
179 if let Some(parent) = output_path.parent() {
181 fs::create_dir_all(parent).context("failed to create output directory")?;
182 }
183
184 let file = File::create(output_path)
185 .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
186 let mut zip = ZipWriter::new(file);
187
188 let options = SimpleFileOptions::default()
189 .compression_method(zip::CompressionMethod::Deflated)
190 .unix_permissions(0o644);
191
192 add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
194
195 zip.finish().context("failed to finalize archive")?;
196
197 Ok(())
198}
199
200pub fn extract_gtbundle(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
213 if !gtbundle_path.is_file() {
214 bail!("gtbundle file not found: {}", gtbundle_path.display());
215 }
216
217 let format = detect_bundle_format(gtbundle_path)?;
218 match format {
219 #[cfg(feature = "squashfs")]
220 BundleFormat::SquashFs => extract_gtbundle_squashfs(gtbundle_path, output_dir),
221 BundleFormat::Zip => extract_gtbundle_zip(gtbundle_path, output_dir),
222 }
223}
224
225#[cfg(feature = "squashfs")]
227fn extract_gtbundle_squashfs(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
228 use backhand::FilesystemReader;
229
230 let file = BufReader::new(
231 File::open(gtbundle_path)
232 .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?,
233 );
234 let reader = FilesystemReader::from_reader(file).context("failed to read squashfs archive")?;
235
236 fs::create_dir_all(output_dir).context("failed to create output directory")?;
237
238 for node in reader.files() {
240 let path_str = node.fullpath.to_string_lossy();
241
242 if path_str.contains("..") {
244 bail!("invalid path in archive: {}", path_str);
245 }
246
247 if path_str == "/" || path_str.is_empty() {
249 continue;
250 }
251
252 let relative_path = path_str.trim_start_matches('/');
254 let out_path = output_dir.join(relative_path);
255
256 match &node.inner {
257 backhand::InnerNode::Dir(_) => {
258 fs::create_dir_all(&out_path)?;
259 }
260 backhand::InnerNode::File(file_reader) => {
261 if let Some(parent) = out_path.parent() {
262 fs::create_dir_all(parent)?;
263 }
264 let mut out_file = File::create(&out_path)
265 .with_context(|| format!("failed to create: {}", out_path.display()))?;
266 let content = reader.file(file_reader);
267 let mut decompressed = Vec::new();
268 content
269 .reader()
270 .read_to_end(&mut decompressed)
271 .context("failed to decompress file")?;
272 out_file
273 .write_all(&decompressed)
274 .context("failed to write file")?;
275 }
276 backhand::InnerNode::Symlink(link) => {
277 #[cfg(unix)]
278 {
279 if let Some(parent) = out_path.parent() {
280 fs::create_dir_all(parent)?;
281 }
282 let target = link.link.to_string_lossy();
283 std::os::unix::fs::symlink(&*target, &out_path).with_context(|| {
284 format!("failed to create symlink: {}", out_path.display())
285 })?;
286 }
287 #[cfg(not(unix))]
288 {
289 let _ = link;
291 }
292 }
293 _ => {
294 }
296 }
297 }
298
299 Ok(())
300}
301
302fn extract_gtbundle_zip(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
304 let file = File::open(gtbundle_path)
305 .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?;
306 let mut archive = ZipArchive::new(file).context("failed to read archive")?;
307
308 fs::create_dir_all(output_dir).context("failed to create output directory")?;
309
310 for i in 0..archive.len() {
311 let mut file = archive
312 .by_index(i)
313 .context("failed to read archive entry")?;
314 let name = file.name().to_string();
315
316 if name.contains("..") {
318 bail!("invalid path in archive: {}", name);
319 }
320
321 let out_path = output_dir.join(&name);
322
323 if file.is_dir() {
324 fs::create_dir_all(&out_path)?;
325 } else {
326 if let Some(parent) = out_path.parent() {
327 fs::create_dir_all(parent)?;
328 }
329 let mut out_file = File::create(&out_path)
330 .with_context(|| format!("failed to create: {}", out_path.display()))?;
331 std::io::copy(&mut file, &mut out_file)?;
332
333 #[cfg(unix)]
335 {
336 use std::os::unix::fs::PermissionsExt;
337 if let Some(mode) = file.unix_mode() {
338 fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
339 }
340 }
341 }
342 }
343
344 Ok(())
345}
346
347pub fn extract_gtbundle_to_temp(gtbundle_path: &Path) -> Result<PathBuf> {
351 let temp_dir = std::env::temp_dir().join(format!(
352 "gtbundle-{}",
353 gtbundle_path
354 .file_stem()
355 .and_then(|s| s.to_str())
356 .unwrap_or("bundle")
357 ));
358
359 if temp_dir.exists() {
361 fs::remove_dir_all(&temp_dir).ok();
362 }
363
364 extract_gtbundle(gtbundle_path, &temp_dir)?;
365
366 Ok(temp_dir)
367}
368
369pub fn is_gtbundle_file(path: &Path) -> bool {
371 path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
372}
373
374pub fn is_gtbundle_dir(path: &Path) -> bool {
376 path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
377}
378
379fn add_directory_to_zip<W: Write + std::io::Seek>(
382 zip: &mut ZipWriter<W>,
383 base_dir: &Path,
384 current_dir: &Path,
385 options: SimpleFileOptions,
386) -> Result<()> {
387 let entries = fs::read_dir(current_dir)
388 .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
389
390 for entry in entries {
391 let entry = entry?;
392 let path = entry.path();
393 let relative_path = path
394 .strip_prefix(base_dir)
395 .context("failed to compute relative path")?;
396 let name = relative_path.to_string_lossy();
397
398 if path.is_dir() {
399 zip.add_directory(format!("{}/", name), options)?;
401 add_directory_to_zip(zip, base_dir, &path, options)?;
403 } else {
404 zip.start_file(name.to_string(), options)?;
406 let mut file = File::open(&path)?;
407 let mut buffer = Vec::new();
408 file.read_to_end(&mut buffer)?;
409 zip.write_all(&buffer)?;
410 }
411 }
412
413 Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::bundle::{BUNDLE_WORKSPACE_MARKER, LEGACY_BUNDLE_MARKER};
420 use std::fs;
421 use tempfile::tempdir;
422
423 fn create_test_bundle(bundle_dir: &Path) {
424 fs::create_dir_all(bundle_dir).unwrap();
425 fs::write(bundle_dir.join(LEGACY_BUNDLE_MARKER), "name: test").unwrap();
426 fs::create_dir_all(bundle_dir.join("packs")).unwrap();
427 fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
428 }
429
430 fn verify_extracted_bundle(extract_dir: &Path) {
431 assert!(extract_dir.join(LEGACY_BUNDLE_MARKER).exists());
432 assert!(extract_dir.join("packs/test.txt").exists());
433
434 let content = fs::read_to_string(extract_dir.join("packs/test.txt")).unwrap();
435 assert_eq!(content, "hello");
436 }
437
438 fn create_test_bundle_workspace(bundle_dir: &Path) {
439 fs::create_dir_all(bundle_dir).unwrap();
440 fs::write(
441 bundle_dir.join(BUNDLE_WORKSPACE_MARKER),
442 "schema_version: 1\n",
443 )
444 .unwrap();
445 fs::create_dir_all(bundle_dir.join("packs")).unwrap();
446 fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
447 }
448
449 #[test]
450 fn test_create_and_extract_gtbundle_zip() {
451 let temp = tempdir().unwrap();
452 let bundle_dir = temp.path().join("test-bundle");
453 let gtbundle_path = temp.path().join("test.gtbundle");
454 let extract_dir = temp.path().join("extracted");
455
456 create_test_bundle(&bundle_dir);
457
458 create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip).unwrap();
460 assert!(gtbundle_path.exists());
461
462 let format = detect_bundle_format(>bundle_path).unwrap();
464 assert_eq!(format, BundleFormat::Zip);
465
466 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
468 verify_extracted_bundle(&extract_dir);
469 }
470
471 #[cfg(feature = "squashfs")]
472 #[test]
473 fn test_create_and_extract_gtbundle_squashfs() {
474 let temp = tempdir().unwrap();
475 let bundle_dir = temp.path().join("test-bundle");
476 let gtbundle_path = temp.path().join("test.gtbundle");
477 let extract_dir = temp.path().join("extracted");
478
479 create_test_bundle(&bundle_dir);
480
481 create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::SquashFs).unwrap();
483 assert!(gtbundle_path.exists());
484
485 let format = detect_bundle_format(>bundle_path).unwrap();
487 assert_eq!(format, BundleFormat::SquashFs);
488
489 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
491 verify_extracted_bundle(&extract_dir);
492 }
493
494 #[test]
495 fn test_create_and_extract_gtbundle_default() {
496 let temp = tempdir().unwrap();
497 let bundle_dir = temp.path().join("test-bundle");
498 let gtbundle_path = temp.path().join("test.gtbundle");
499 let extract_dir = temp.path().join("extracted");
500
501 create_test_bundle(&bundle_dir);
502
503 create_gtbundle(&bundle_dir, >bundle_path).unwrap();
505 assert!(gtbundle_path.exists());
506
507 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
509 verify_extracted_bundle(&extract_dir);
510 }
511
512 #[test]
513 fn test_create_and_extract_gtbundle_with_bundle_yaml_root() {
514 let temp = tempdir().unwrap();
515 let bundle_dir = temp.path().join("test-bundle");
516 let gtbundle_path = temp.path().join("test.gtbundle");
517 let extract_dir = temp.path().join("extracted");
518
519 create_test_bundle_workspace(&bundle_dir);
520
521 create_gtbundle(&bundle_dir, >bundle_path).unwrap();
522 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
523
524 assert!(extract_dir.join(BUNDLE_WORKSPACE_MARKER).exists());
525 assert!(extract_dir.join("packs/test.txt").exists());
526 }
527
528 #[test]
529 fn test_is_gtbundle() {
530 let temp = tempdir().unwrap();
531
532 let file_path = temp.path().join("test.gtbundle");
534 fs::write(&file_path, "test").unwrap();
535 assert!(is_gtbundle_file(&file_path));
536 assert!(!is_gtbundle_dir(&file_path));
537
538 let dir_path = temp.path().join("test2.gtbundle");
540 fs::create_dir(&dir_path).unwrap();
541 assert!(!is_gtbundle_file(&dir_path));
542 assert!(is_gtbundle_dir(&dir_path));
543 }
544
545 #[test]
546 fn test_detect_unknown_format() {
547 let temp = tempdir().unwrap();
548 let file_path = temp.path().join("unknown.gtbundle");
549 fs::write(&file_path, "UNKN").unwrap();
550
551 let result = detect_bundle_format(&file_path);
552 assert!(result.is_err());
553 }
554}