greentic_bundle/bundle_fs/
backhand_writer.rs1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{BufReader, Read};
4use std::path::{Component, Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use backhand::{FilesystemReader, FilesystemWriter, InnerNode, NodeHeader};
8use walkdir::WalkDir;
9
10use super::{BundleEntry, BundleEntryKind, BundleFsReader, BundleFsWriter};
11
12const ROOT_PERMISSIONS: u16 = 0o755;
13const DIR_PERMISSIONS: u16 = 0o755;
14const FILE_PERMISSIONS: u16 = 0o644;
15const SYMLINK_PERMISSIONS: u16 = 0o777;
16const NORMALIZED_TIME: u32 = 0;
17
18pub struct BackhandBundleFsWriter;
19
20pub struct BackhandBundleFsReader;
21
22impl BundleFsWriter for BackhandBundleFsWriter {
23 fn write_bundle(&self, input_dir: &Path, output_file: &Path) -> Result<()> {
24 write_bundle_with_backhand(input_dir, output_file).with_context(|| {
25 format!(
26 "Failed to create .gtbundle using Rust-native SquashFS writer from {} to {}",
27 input_dir.display(),
28 output_file.display()
29 )
30 })
31 }
32}
33
34impl BundleFsReader for BackhandBundleFsReader {
35 fn list_bundle(&self, bundle_file: &Path) -> Result<Vec<BundleEntry>> {
36 let filesystem = open_backhand_filesystem(bundle_file)?;
37 let mut entries = Vec::new();
38 for node in filesystem.files() {
39 let Some(path) = normalized_node_path(&node.fullpath)? else {
40 continue;
41 };
42 let kind = match &node.inner {
43 InnerNode::File(_) => BundleEntryKind::File,
44 InnerNode::Dir(_) => BundleEntryKind::Directory,
45 InnerNode::Symlink(_) => BundleEntryKind::Symlink,
46 InnerNode::CharacterDevice(_)
47 | InnerNode::BlockDevice(_)
48 | InnerNode::NamedPipe
49 | InnerNode::Socket => BundleEntryKind::Other,
50 };
51 entries.push(BundleEntry { path, kind });
52 }
53 entries.sort_by(|left, right| left.path.cmp(&right.path));
54 Ok(entries)
55 }
56
57 fn extract_bundle(&self, bundle_file: &Path, output_dir: &Path) -> Result<()> {
58 let filesystem = open_backhand_filesystem(bundle_file)?;
59 std::fs::create_dir_all(output_dir)
60 .with_context(|| format!("create extraction directory {}", output_dir.display()))?;
61 let mut seen_paths: HashSet<String> = HashSet::new();
66 for node in filesystem.files() {
67 let Some(path) = normalized_node_path(&node.fullpath)? else {
68 continue;
69 };
70 if !seen_paths.insert(path.clone()) {
71 bail!("duplicate bundle entry rejected: {path}");
72 }
73 let destination = safe_output_path(output_dir, &path)?;
74 match &node.inner {
75 InnerNode::Dir(_) => {
76 safe_create_dir_all(output_dir, &destination)
77 .with_context(|| format!("create directory {}", destination.display()))?;
78 }
79 InnerNode::File(file) => {
80 if let Some(parent) = destination.parent() {
81 safe_create_dir_all(output_dir, parent).with_context(|| {
82 format!("create parent directory {}", parent.display())
83 })?;
84 }
85 assert_no_existing_symlink(&destination)
86 .with_context(|| format!("validate file destination {path}"))?;
87 let mut source = filesystem.file(file).reader();
88 let mut target = File::create(&destination)
89 .with_context(|| format!("create file {}", destination.display()))?;
90 std::io::copy(&mut source, &mut target)
91 .with_context(|| format!("extract file {path}"))?;
92 }
93 InnerNode::Symlink(symlink) => {
94 if let Some(parent) = destination.parent() {
95 safe_create_dir_all(output_dir, parent).with_context(|| {
96 format!("create parent directory {}", parent.display())
97 })?;
98 }
99 assert_no_existing_symlink(&destination)
100 .with_context(|| format!("validate symlink destination {path}"))?;
101 assert_symlink_target_within_root(&path, &symlink.link)
111 .with_context(|| format!("validate symlink target for {path}"))?;
112 create_symlink(&symlink.link, &destination)
113 .with_context(|| format!("extract symlink {path}"))?;
114 }
115 InnerNode::CharacterDevice(_)
116 | InnerNode::BlockDevice(_)
117 | InnerNode::NamedPipe
118 | InnerNode::Socket => {
119 bail!("unsupported SquashFS entry type while extracting {path}");
120 }
121 }
122 }
123 Ok(())
124 }
125}
126
127pub fn read_bundle_file_with_backhand(bundle_file: &Path, inner_path: &str) -> Result<Vec<u8>> {
128 let filesystem = open_backhand_filesystem(bundle_file)?;
129 let normalized_inner = normalize_inner_path(inner_path)?;
130 for node in filesystem.files() {
131 let Some(path) = normalized_node_path(&node.fullpath)? else {
132 continue;
133 };
134 if path != normalized_inner {
135 continue;
136 }
137 let InnerNode::File(file) = &node.inner else {
138 bail!("{inner_path} is not a file in {}", bundle_file.display());
139 };
140 let mut reader = filesystem.file(file).reader();
141 let mut bytes = Vec::new();
142 reader
143 .read_to_end(&mut bytes)
144 .with_context(|| format!("read {inner_path} from {}", bundle_file.display()))?;
145 return Ok(bytes);
146 }
147 bail!(
148 "bundle entry {inner_path} not found in {}",
149 bundle_file.display()
150 )
151}
152
153fn write_bundle_with_backhand(input_dir: &Path, output_file: &Path) -> Result<()> {
154 if !input_dir.is_dir() {
155 bail!(
156 "bundle input directory does not exist: {}",
157 input_dir.display()
158 );
159 }
160 super::assert_no_dev_secret_paths(input_dir)?;
161 if let Some(parent) = output_file.parent() {
162 std::fs::create_dir_all(parent)
163 .with_context(|| format!("create artifact parent {}", parent.display()))?;
164 }
165 if output_file.exists() {
166 std::fs::remove_file(output_file)
167 .with_context(|| format!("remove existing artifact {}", output_file.display()))?;
168 }
169
170 let mut writer = FilesystemWriter::default();
171 writer.set_time(NORMALIZED_TIME);
172 writer.set_root_uid(0);
173 writer.set_root_gid(0);
174 writer.set_root_mode(ROOT_PERMISSIONS);
175 writer.set_only_root_id();
176 writer.set_no_padding();
177 writer.set_emit_compression_options(true);
180
181 for entry in sorted_entries(input_dir)? {
182 let relative_path = normalized_relative_path(input_dir, &entry)?;
183 let metadata = std::fs::symlink_metadata(&entry)
184 .with_context(|| format!("read metadata for {}", entry.display()))?;
185 let file_type = metadata.file_type();
186
187 if file_type.is_dir() {
188 writer
189 .push_dir(&relative_path, header(DIR_PERMISSIONS))
190 .with_context(|| format!("add directory {relative_path} to SquashFS"))?;
191 } else if file_type.is_file() {
192 let file = File::open(&entry)
193 .with_context(|| format!("open staged file {}", entry.display()))?;
194 writer
195 .push_file(file, &relative_path, header(FILE_PERMISSIONS))
196 .with_context(|| format!("add file {relative_path} to SquashFS"))?;
197 } else if file_type.is_symlink() {
198 let target = std::fs::read_link(&entry)
199 .with_context(|| format!("read symlink target {}", entry.display()))?;
200 let target = normalized_link_target(&target)?;
201 writer
202 .push_symlink(target, &relative_path, header(SYMLINK_PERMISSIONS))
203 .with_context(|| format!("add symlink {relative_path} to SquashFS"))?;
204 } else {
205 bail!(
206 "unsupported staged bundle entry type at {}; only files, directories, and symlinks can be bundled",
207 entry.display()
208 );
209 }
210 }
211
212 let mut output = File::create(output_file)
213 .with_context(|| format!("create artifact {}", output_file.display()))?;
214 writer
215 .write(&mut output)
216 .with_context(|| format!("write SquashFS artifact {}", output_file.display()))?;
217 Ok(())
218}
219
220fn open_backhand_filesystem(bundle_file: &Path) -> Result<FilesystemReader<'static>> {
221 let file = File::open(bundle_file)
222 .with_context(|| format!("open bundle {}", bundle_file.display()))?;
223 FilesystemReader::from_reader(BufReader::new(file))
224 .with_context(|| format!("read SquashFS bundle {}", bundle_file.display()))
225}
226
227fn header(permissions: u16) -> NodeHeader {
228 NodeHeader {
229 permissions,
230 uid: 0,
231 gid: 0,
232 mtime: NORMALIZED_TIME,
233 }
234}
235
236fn sorted_entries(input_dir: &Path) -> Result<Vec<PathBuf>> {
237 let mut entries = Vec::new();
238 for entry in WalkDir::new(input_dir)
239 .min_depth(1)
240 .follow_links(false)
241 .sort_by_file_name()
242 {
243 let entry = entry.with_context(|| format!("walk staged bundle {}", input_dir.display()))?;
244 entries.push(entry.into_path());
245 }
246 entries.sort_by_key(|path| normalized_relative_path(input_dir, path).unwrap_or_default());
247 Ok(entries)
248}
249
250fn normalized_relative_path(input_dir: &Path, path: &Path) -> Result<String> {
251 let relative = path.strip_prefix(input_dir).with_context(|| {
252 format!(
253 "make {} relative to {}",
254 path.display(),
255 input_dir.display()
256 )
257 })?;
258 normalized_path(relative)
259}
260
261fn normalized_link_target(path: &Path) -> Result<String> {
262 normalized_path(path)
263}
264
265fn normalized_node_path(path: &Path) -> Result<Option<String>> {
266 if path == Path::new("/") {
267 return Ok(None);
268 }
269 let stripped = path.strip_prefix("/").unwrap_or(path);
270 Ok(Some(normalized_path(stripped)?))
271}
272
273fn normalize_inner_path(path: &str) -> Result<String> {
274 normalized_path(Path::new(path.trim_matches('/')))
275}
276
277fn normalized_path(path: &Path) -> Result<String> {
278 let mut parts = Vec::new();
279 for component in path.components() {
280 match component {
281 Component::Normal(part) => {
282 let part = part.to_str().ok_or_else(|| {
283 anyhow!("bundle paths must be valid UTF-8: {}", path.display())
284 })?;
285 if part.is_empty() {
286 bail!(
287 "bundle path contains an empty component: {}",
288 path.display()
289 );
290 }
291 parts.push(part.to_string());
292 }
293 Component::CurDir => {}
294 Component::ParentDir => parts.push("..".to_string()),
295 Component::RootDir | Component::Prefix(_) => {
296 bail!("bundle paths must be relative: {}", path.display());
297 }
298 }
299 }
300 if parts.is_empty() {
301 bail!("bundle path cannot be empty");
302 }
303 Ok(parts.join("/"))
304}
305
306fn safe_output_path(output_dir: &Path, inner_path: &str) -> Result<PathBuf> {
307 let mut out = output_dir.to_path_buf();
308 for component in Path::new(inner_path).components() {
309 match component {
310 Component::Normal(part) => out.push(part),
311 Component::CurDir => {}
312 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
313 bail!("refusing to extract unsafe bundle path: {inner_path}");
314 }
315 }
316 }
317 Ok(out)
318}
319
320fn safe_create_dir_all(extract_root: &Path, target: &Path) -> Result<()> {
328 if !target.starts_with(extract_root) {
329 bail!(
330 "refusing to descend outside extract root: {} not under {}",
331 target.display(),
332 extract_root.display()
333 );
334 }
335 let relative = target.strip_prefix(extract_root).map_err(|err| {
336 anyhow!(
337 "make {} relative to extract root {}: {err}",
338 target.display(),
339 extract_root.display()
340 )
341 })?;
342 let mut current = extract_root.to_path_buf();
343 for component in relative.components() {
344 let part = match component {
345 Component::Normal(part) => part,
346 Component::CurDir => continue,
347 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
348 bail!(
349 "refusing to traverse unsafe component during mkdir: {}",
350 target.display()
351 );
352 }
353 };
354 current.push(part);
355 match std::fs::symlink_metadata(¤t) {
356 Ok(meta) => {
357 if meta.file_type().is_symlink() {
358 bail!(
359 "refusing to descend through symlink at {}",
360 current.display()
361 );
362 }
363 if !meta.file_type().is_dir() {
364 bail!(
365 "refusing to descend through non-directory at {}",
366 current.display()
367 );
368 }
369 }
370 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
371 std::fs::create_dir(¤t)
372 .with_context(|| format!("create directory {}", current.display()))?;
373 }
374 Err(err) => {
375 return Err(err)
376 .with_context(|| format!("stat {} during safe mkdir", current.display()));
377 }
378 }
379 }
380 Ok(())
381}
382
383fn assert_no_existing_symlink(destination: &Path) -> Result<()> {
387 match std::fs::symlink_metadata(destination) {
388 Ok(meta) if meta.file_type().is_symlink() => {
389 bail!(
390 "refusing to write through existing symlink at {}",
391 destination.display()
392 );
393 }
394 Ok(_) | Err(_) => Ok(()),
395 }
396}
397
398fn assert_symlink_target_within_root(symlink_inner_path: &str, target: &Path) -> Result<()> {
408 let parent_depth = Path::new(symlink_inner_path)
409 .parent()
410 .map(|parent| {
411 parent
412 .components()
413 .filter(|component| matches!(component, Component::Normal(_)))
414 .count()
415 })
416 .unwrap_or(0);
417 let mut depth: i64 = parent_depth as i64;
418 for component in target.components() {
419 match component {
420 Component::Normal(_) => depth += 1,
421 Component::CurDir => {}
422 Component::ParentDir => {
423 depth -= 1;
424 if depth < 0 {
425 bail!(
426 "refusing symlink target {} from {}: escapes extract root",
427 target.display(),
428 symlink_inner_path
429 );
430 }
431 }
432 Component::RootDir | Component::Prefix(_) => {
433 bail!(
434 "refusing absolute symlink target {} from {}",
435 target.display(),
436 symlink_inner_path
437 );
438 }
439 }
440 }
441 Ok(())
442}
443
444#[cfg(unix)]
445fn create_symlink(target: &Path, destination: &Path) -> std::io::Result<()> {
446 std::os::unix::fs::symlink(target, destination)
447}
448
449#[cfg(windows)]
450fn create_symlink(target: &Path, destination: &Path) -> std::io::Result<()> {
451 std::os::windows::fs::symlink_file(target, destination)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::{
457 assert_no_existing_symlink, assert_symlink_target_within_root, normalized_path,
458 safe_create_dir_all,
459 };
460 use std::path::Path;
461 use tempfile::TempDir;
462
463 #[test]
464 fn normalizes_paths_with_forward_slashes() {
465 assert_eq!(
466 normalized_path(Path::new("assets/example.txt")).unwrap(),
467 "assets/example.txt"
468 );
469 }
470
471 #[test]
474 fn safe_create_dir_all_creates_missing_dirs() {
475 let temp = TempDir::new().expect("tempdir");
476 let root = temp.path();
477 let target = root.join("a/b/c");
478 safe_create_dir_all(root, &target).expect("mkdir");
479 assert!(target.is_dir());
480 }
481
482 #[test]
483 fn safe_create_dir_all_accepts_existing_real_dirs() {
484 let temp = TempDir::new().expect("tempdir");
485 let root = temp.path();
486 std::fs::create_dir_all(root.join("a/b")).expect("seed");
487 safe_create_dir_all(root, &root.join("a/b/c")).expect("mkdir");
488 assert!(root.join("a/b/c").is_dir());
489 }
490
491 #[cfg(unix)]
492 #[test]
493 fn safe_create_dir_all_rejects_traversal_through_symlink() {
494 let temp = TempDir::new().expect("tempdir");
495 let root = temp.path();
496 let outside = temp.path().join("outside");
497 std::fs::create_dir(&outside).expect("outside");
498 std::os::unix::fs::symlink(&outside, root.join("escape")).expect("symlink");
501 let err = safe_create_dir_all(root, &root.join("escape/inner"))
502 .expect_err("must reject symlink ancestor");
503 assert!(
504 format!("{err:#}").contains("descend through symlink"),
505 "unexpected error: {err:#}"
506 );
507 assert!(!outside.join("inner").exists());
509 }
510
511 #[test]
512 fn safe_create_dir_all_rejects_target_outside_root() {
513 let temp = TempDir::new().expect("tempdir");
514 let root = temp.path().join("root");
515 std::fs::create_dir(&root).expect("root");
516 let outside = temp.path().join("outside");
517 let err =
518 safe_create_dir_all(&root, &outside).expect_err("must reject target outside root");
519 assert!(format!("{err:#}").contains("outside extract root"));
520 }
521
522 #[cfg(unix)]
525 #[test]
526 fn assert_no_existing_symlink_rejects_symlink_at_destination() {
527 let temp = TempDir::new().expect("tempdir");
528 let root = temp.path();
529 std::os::unix::fs::symlink("/tmp/nope", root.join("link")).expect("symlink");
530 let err = assert_no_existing_symlink(&root.join("link"))
531 .expect_err("must reject existing symlink");
532 assert!(format!("{err:#}").contains("write through existing symlink"));
533 }
534
535 #[test]
536 fn assert_no_existing_symlink_accepts_missing_destination() {
537 let temp = TempDir::new().expect("tempdir");
538 assert_no_existing_symlink(&temp.path().join("missing")).expect("missing is fine");
539 }
540
541 #[test]
542 fn assert_no_existing_symlink_accepts_existing_file() {
543 let temp = TempDir::new().expect("tempdir");
544 let path = temp.path().join("real.txt");
545 std::fs::write(&path, "x").expect("write");
546 assert_no_existing_symlink(&path).expect("real file is fine");
547 }
548
549 #[test]
552 fn symlink_target_within_root_accepts_sibling() {
553 assert_symlink_target_within_root("packs/a/link", Path::new("../b/file"))
554 .expect("sibling resolves under root");
555 }
556
557 #[test]
558 fn symlink_target_within_root_rejects_absolute_target() {
559 let err = assert_symlink_target_within_root("packs/link", Path::new("/etc/passwd"))
560 .expect_err("must reject absolute");
561 assert!(format!("{err:#}").contains("absolute symlink target"));
562 }
563
564 #[test]
565 fn symlink_target_within_root_rejects_escaping_target() {
566 let err = assert_symlink_target_within_root("packs/link", Path::new("../../etc"))
569 .expect_err("must reject escape");
570 assert!(format!("{err:#}").contains("escapes extract root"));
571 }
572
573 #[test]
574 fn symlink_target_within_root_accepts_walk_back_to_root() {
575 assert_symlink_target_within_root("packs/inner/link", Path::new("../../allowed/file"))
578 .expect("walk back to root is within bounds");
579 }
580}