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 writer.set_root_mode(0o755);
120
121 add_directory_to_squashfs(&mut writer, bundle_dir, bundle_dir)?;
123
124 let mut output = File::create(output_path)
126 .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
127 writer
128 .write(&mut output)
129 .context("failed to write squashfs archive")?;
130
131 Ok(())
132}
133
134#[cfg(feature = "squashfs")]
136fn add_directory_to_squashfs(
137 writer: &mut backhand::FilesystemWriter,
138 base_dir: &Path,
139 current_dir: &Path,
140) -> Result<()> {
141 use std::io::Cursor;
142
143 let entries = fs::read_dir(current_dir)
144 .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
145
146 for entry in entries {
147 let entry = entry?;
148 let path = entry.path();
149 let relative_path = path
150 .strip_prefix(base_dir)
151 .context("failed to compute relative path")?;
152 let name = relative_path.to_string_lossy().to_string();
153
154 if path.is_dir() {
155 writer
156 .push_dir(&name, dir_node_header())
157 .with_context(|| format!("failed to add directory: {}", name))?;
158 add_directory_to_squashfs(writer, base_dir, &path)?;
159 } else {
160 let content = fs::read(&path)
161 .with_context(|| format!("failed to read file: {}", path.display()))?;
162 let cursor = Cursor::new(content);
163 writer
164 .push_file(cursor, &name, file_node_header())
165 .with_context(|| format!("failed to add file: {}", name))?;
166 }
167 }
168
169 Ok(())
170}
171
172#[cfg(feature = "squashfs")]
177fn dir_node_header() -> backhand::NodeHeader {
178 backhand::NodeHeader::new(0o755, 0, 0, 0)
179}
180
181#[cfg(feature = "squashfs")]
182fn file_node_header() -> backhand::NodeHeader {
183 backhand::NodeHeader::new(0o644, 0, 0, 0)
184}
185
186fn create_gtbundle_zip(bundle_dir: &Path, output_path: &Path) -> Result<()> {
188 if !bundle_dir.is_dir() {
189 bail!("bundle directory not found: {}", bundle_dir.display());
190 }
191
192 if let Some(parent) = output_path.parent() {
194 fs::create_dir_all(parent).context("failed to create output directory")?;
195 }
196
197 let file = File::create(output_path)
198 .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
199 let mut zip = ZipWriter::new(file);
200
201 let options = SimpleFileOptions::default()
202 .compression_method(zip::CompressionMethod::Deflated)
203 .unix_permissions(0o644);
204
205 add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
207
208 zip.finish().context("failed to finalize archive")?;
209
210 Ok(())
211}
212
213pub fn extract_gtbundle(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
226 if !gtbundle_path.is_file() {
227 bail!("gtbundle file not found: {}", gtbundle_path.display());
228 }
229
230 let format = detect_bundle_format(gtbundle_path)?;
231 match format {
232 #[cfg(feature = "squashfs")]
233 BundleFormat::SquashFs => extract_gtbundle_squashfs(gtbundle_path, output_dir),
234 BundleFormat::Zip => extract_gtbundle_zip(gtbundle_path, output_dir),
235 }
236}
237
238#[cfg(feature = "squashfs")]
240fn extract_gtbundle_squashfs(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
241 use backhand::FilesystemReader;
242
243 let file = BufReader::new(
244 File::open(gtbundle_path)
245 .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?,
246 );
247 let reader = FilesystemReader::from_reader(file).context("failed to read squashfs archive")?;
248
249 fs::create_dir_all(output_dir).context("failed to create output directory")?;
250
251 for node in reader.files() {
253 let path_str = node.fullpath.to_string_lossy();
254
255 if path_str.contains("..") {
257 bail!("invalid path in archive: {}", path_str);
258 }
259
260 if path_str == "/" || path_str.is_empty() {
262 continue;
263 }
264
265 let relative_path = path_str.trim_start_matches('/');
267 let out_path = output_dir.join(relative_path);
268
269 match &node.inner {
270 backhand::InnerNode::Dir(_) => {
271 fs::create_dir_all(&out_path)?;
272 }
273 backhand::InnerNode::File(file_reader) => {
274 if let Some(parent) = out_path.parent() {
275 fs::create_dir_all(parent)?;
276 }
277 let mut out_file = File::create(&out_path)
278 .with_context(|| format!("failed to create: {}", out_path.display()))?;
279 let content = reader.file(file_reader);
280 let mut decompressed = Vec::new();
281 content
282 .reader()
283 .read_to_end(&mut decompressed)
284 .context("failed to decompress file")?;
285 out_file
286 .write_all(&decompressed)
287 .context("failed to write file")?;
288 }
289 backhand::InnerNode::Symlink(link) => {
290 #[cfg(unix)]
291 {
292 if let Some(parent) = out_path.parent() {
293 fs::create_dir_all(parent)?;
294 }
295 let target = link.link.to_string_lossy();
296 std::os::unix::fs::symlink(&*target, &out_path).with_context(|| {
297 format!("failed to create symlink: {}", out_path.display())
298 })?;
299 }
300 #[cfg(not(unix))]
301 {
302 let _ = link;
304 }
305 }
306 _ => {
307 }
309 }
310 }
311
312 Ok(())
313}
314
315fn extract_gtbundle_zip(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
317 let file = File::open(gtbundle_path)
318 .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?;
319 let mut archive = ZipArchive::new(file).context("failed to read archive")?;
320
321 fs::create_dir_all(output_dir).context("failed to create output directory")?;
322
323 for i in 0..archive.len() {
324 let mut file = archive
325 .by_index(i)
326 .context("failed to read archive entry")?;
327 let name = file.name().to_string();
328
329 if name.contains("..") {
331 bail!("invalid path in archive: {}", name);
332 }
333
334 let out_path = output_dir.join(&name);
335
336 if file.is_dir() {
337 fs::create_dir_all(&out_path)?;
338 } else {
339 if let Some(parent) = out_path.parent() {
340 fs::create_dir_all(parent)?;
341 }
342 let mut out_file = File::create(&out_path)
343 .with_context(|| format!("failed to create: {}", out_path.display()))?;
344 std::io::copy(&mut file, &mut out_file)?;
345
346 #[cfg(unix)]
348 {
349 use std::os::unix::fs::PermissionsExt;
350 if let Some(mode) = file.unix_mode() {
351 fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
352 }
353 }
354 }
355 }
356
357 Ok(())
358}
359
360pub fn extract_gtbundle_to_temp(gtbundle_path: &Path) -> Result<PathBuf> {
364 let temp_dir = std::env::temp_dir().join(format!(
365 "gtbundle-{}",
366 gtbundle_path
367 .file_stem()
368 .and_then(|s| s.to_str())
369 .unwrap_or("bundle")
370 ));
371
372 if temp_dir.exists() {
374 fs::remove_dir_all(&temp_dir).ok();
375 }
376
377 extract_gtbundle(gtbundle_path, &temp_dir)?;
378
379 Ok(temp_dir)
380}
381
382pub fn is_gtbundle_file(path: &Path) -> bool {
384 path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
385}
386
387pub fn is_gtbundle_dir(path: &Path) -> bool {
389 path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
390}
391
392fn add_directory_to_zip<W: Write + std::io::Seek>(
395 zip: &mut ZipWriter<W>,
396 base_dir: &Path,
397 current_dir: &Path,
398 options: SimpleFileOptions,
399) -> Result<()> {
400 let entries = fs::read_dir(current_dir)
401 .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
402
403 for entry in entries {
404 let entry = entry?;
405 let path = entry.path();
406 let relative_path = path
407 .strip_prefix(base_dir)
408 .context("failed to compute relative path")?;
409 let name = relative_path.to_string_lossy();
410
411 if path.is_dir() {
412 zip.add_directory(format!("{}/", name), options)?;
414 add_directory_to_zip(zip, base_dir, &path, options)?;
416 } else {
417 zip.start_file(name.to_string(), options)?;
419 let mut file = File::open(&path)?;
420 let mut buffer = Vec::new();
421 file.read_to_end(&mut buffer)?;
422 zip.write_all(&buffer)?;
423 }
424 }
425
426 Ok(())
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::bundle::{BUNDLE_WORKSPACE_MARKER, LEGACY_BUNDLE_MARKER};
433 use std::fs;
434 use tempfile::tempdir;
435
436 fn create_test_bundle(bundle_dir: &Path) {
437 fs::create_dir_all(bundle_dir).unwrap();
438 fs::write(bundle_dir.join(LEGACY_BUNDLE_MARKER), "name: test").unwrap();
439 fs::create_dir_all(bundle_dir.join("packs")).unwrap();
440 fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
441 }
442
443 fn verify_extracted_bundle(extract_dir: &Path) {
444 assert!(extract_dir.join(LEGACY_BUNDLE_MARKER).exists());
445 assert!(extract_dir.join("packs/test.txt").exists());
446
447 let content = fs::read_to_string(extract_dir.join("packs/test.txt")).unwrap();
448 assert_eq!(content, "hello");
449 }
450
451 fn create_test_bundle_workspace(bundle_dir: &Path) {
452 fs::create_dir_all(bundle_dir).unwrap();
453 fs::write(
454 bundle_dir.join(BUNDLE_WORKSPACE_MARKER),
455 "schema_version: 1\n",
456 )
457 .unwrap();
458 fs::create_dir_all(bundle_dir.join("packs")).unwrap();
459 fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
460 }
461
462 #[test]
463 fn test_create_and_extract_gtbundle_zip() {
464 let temp = tempdir().unwrap();
465 let bundle_dir = temp.path().join("test-bundle");
466 let gtbundle_path = temp.path().join("test.gtbundle");
467 let extract_dir = temp.path().join("extracted");
468
469 create_test_bundle(&bundle_dir);
470
471 create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::Zip).unwrap();
473 assert!(gtbundle_path.exists());
474
475 let format = detect_bundle_format(>bundle_path).unwrap();
477 assert_eq!(format, BundleFormat::Zip);
478
479 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
481 verify_extracted_bundle(&extract_dir);
482 }
483
484 #[cfg(feature = "squashfs")]
485 #[test]
486 fn test_create_and_extract_gtbundle_squashfs() {
487 let temp = tempdir().unwrap();
488 let bundle_dir = temp.path().join("test-bundle");
489 let gtbundle_path = temp.path().join("test.gtbundle");
490 let extract_dir = temp.path().join("extracted");
491
492 create_test_bundle(&bundle_dir);
493
494 create_gtbundle_with_format(&bundle_dir, >bundle_path, BundleFormat::SquashFs).unwrap();
496 assert!(gtbundle_path.exists());
497
498 let format = detect_bundle_format(>bundle_path).unwrap();
500 assert_eq!(format, BundleFormat::SquashFs);
501
502 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
504 verify_extracted_bundle(&extract_dir);
505 }
506
507 #[test]
508 fn test_create_and_extract_gtbundle_default() {
509 let temp = tempdir().unwrap();
510 let bundle_dir = temp.path().join("test-bundle");
511 let gtbundle_path = temp.path().join("test.gtbundle");
512 let extract_dir = temp.path().join("extracted");
513
514 create_test_bundle(&bundle_dir);
515
516 create_gtbundle(&bundle_dir, >bundle_path).unwrap();
518 assert!(gtbundle_path.exists());
519
520 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
522 verify_extracted_bundle(&extract_dir);
523 }
524
525 #[test]
526 fn test_create_and_extract_gtbundle_with_bundle_yaml_root() {
527 let temp = tempdir().unwrap();
528 let bundle_dir = temp.path().join("test-bundle");
529 let gtbundle_path = temp.path().join("test.gtbundle");
530 let extract_dir = temp.path().join("extracted");
531
532 create_test_bundle_workspace(&bundle_dir);
533
534 create_gtbundle(&bundle_dir, >bundle_path).unwrap();
535 extract_gtbundle(>bundle_path, &extract_dir).unwrap();
536
537 assert!(extract_dir.join(BUNDLE_WORKSPACE_MARKER).exists());
538 assert!(extract_dir.join("packs/test.txt").exists());
539 }
540
541 #[test]
542 fn test_is_gtbundle() {
543 let temp = tempdir().unwrap();
544
545 let file_path = temp.path().join("test.gtbundle");
547 fs::write(&file_path, "test").unwrap();
548 assert!(is_gtbundle_file(&file_path));
549 assert!(!is_gtbundle_dir(&file_path));
550
551 let dir_path = temp.path().join("test2.gtbundle");
553 fs::create_dir(&dir_path).unwrap();
554 assert!(!is_gtbundle_file(&dir_path));
555 assert!(is_gtbundle_dir(&dir_path));
556 }
557
558 #[test]
559 fn test_detect_unknown_format() {
560 let temp = tempdir().unwrap();
561 let file_path = temp.path().join("unknown.gtbundle");
562 fs::write(&file_path, "UNKN").unwrap();
563
564 let result = detect_bundle_format(&file_path);
565 assert!(result.is_err());
566 }
567}