greentic_bundle/bundle_fs/
mod.rs1mod backhand_writer;
2mod native_mksquashfs_writer;
3mod native_unsquashfs_reader;
4
5use std::path::{Component, Path};
6
7use anyhow::{Context, Result, bail};
8use walkdir::WalkDir;
9
10pub use backhand_writer::{BackhandBundleFsReader, BackhandBundleFsWriter};
11pub use native_mksquashfs_writer::MksquashfsBundleFsWriter;
12pub use native_unsquashfs_reader::UnsquashfsBundleFsReader;
13
14pub const WRITER_ENV: &str = "GREENTIC_BUNDLE_SQUASHFS_WRITER";
15pub const READER_ENV: &str = "GREENTIC_BUNDLE_SQUASHFS_READER";
16
17pub trait BundleFsWriter {
18 fn write_bundle(&self, input_dir: &Path, output_file: &Path) -> Result<()>;
19}
20
21pub trait BundleFsReader {
22 fn list_bundle(&self, bundle_file: &Path) -> Result<Vec<BundleEntry>>;
23 fn extract_bundle(&self, bundle_file: &Path, output_dir: &Path) -> Result<()>;
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct BundleEntry {
28 pub path: String,
29 pub kind: BundleEntryKind,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum BundleEntryKind {
34 File,
35 Directory,
36 Symlink,
37 Other,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BundleFsWriterKind {
42 Backhand,
43 Mksquashfs,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum BundleFsReaderKind {
48 Backhand,
49 Unsquashfs,
50}
51
52impl BundleFsWriterKind {
53 pub fn from_env_value(value: Option<&str>) -> Result<Self> {
54 match value.map(str::trim).filter(|value| !value.is_empty()) {
55 None => Ok(Self::Backhand),
56 Some("backhand") => Ok(Self::Backhand),
57 Some("mksquashfs") => Ok(Self::Mksquashfs),
58 Some(value) => bail!(
59 "{WRITER_ENV}={value} is not supported. Accepted values: backhand, mksquashfs"
60 ),
61 }
62 }
63}
64
65impl BundleFsReaderKind {
66 pub fn from_env_value(value: Option<&str>) -> Result<Self> {
67 match value.map(str::trim).filter(|value| !value.is_empty()) {
68 None => Ok(Self::Backhand),
69 Some("backhand") => Ok(Self::Backhand),
70 Some("unsquashfs") => Ok(Self::Unsquashfs),
71 Some(value) => bail!(
72 "{READER_ENV}={value} is not supported. Accepted values: backhand, unsquashfs"
73 ),
74 }
75 }
76}
77
78pub fn selected_writer_kind() -> Result<BundleFsWriterKind> {
79 BundleFsWriterKind::from_env_value(std::env::var(WRITER_ENV).ok().as_deref())
80}
81
82pub fn selected_reader_kind() -> Result<BundleFsReaderKind> {
83 BundleFsReaderKind::from_env_value(std::env::var(READER_ENV).ok().as_deref())
84}
85
86pub fn write_bundle(input_dir: &Path, output_file: &Path) -> Result<()> {
87 match selected_writer_kind()? {
88 BundleFsWriterKind::Backhand => BackhandBundleFsWriter.write_bundle(input_dir, output_file),
89 BundleFsWriterKind::Mksquashfs => {
90 MksquashfsBundleFsWriter.write_bundle(input_dir, output_file)
91 }
92 }
93}
94
95pub(crate) fn assert_no_dev_secret_paths(input_dir: &Path) -> Result<()> {
101 for entry in WalkDir::new(input_dir).min_depth(1).follow_links(false) {
102 let entry = entry.with_context(|| {
103 format!(
104 "walk staged bundle for dev-secret denylist: {}",
105 input_dir.display()
106 )
107 })?;
108 let relative = entry.path().strip_prefix(input_dir).unwrap_or(entry.path());
109 if let Some(reason) = dev_secret_match(relative) {
110 bail!(
111 "refusing to archive dev-secret path {} ({reason}); fix the bundle pipeline rather than shipping it",
112 relative.display()
113 );
114 }
115 if entry.file_type().is_symlink() {
125 let target = std::fs::read_link(entry.path()).with_context(|| {
126 format!(
127 "read symlink target for dev-secret denylist: {}",
128 relative.display()
129 )
130 })?;
131 if let Some(reason) = dev_secret_match(&target) {
132 bail!(
133 "refusing to archive symlink {} whose target {} matches dev-secret pattern ({reason})",
134 relative.display(),
135 target.display()
136 );
137 }
138 }
139 }
140 Ok(())
141}
142
143pub(crate) fn dev_secret_match(relative: &Path) -> Option<&'static str> {
144 let parts: Vec<&str> = relative
145 .components()
146 .filter_map(|component| match component {
147 Component::Normal(part) => part.to_str(),
148 _ => None,
149 })
150 .collect();
151 for window in parts.windows(2) {
152 if window[0] == ".greentic" && window[1] == "dev" {
153 return Some(".greentic/dev/ tree");
154 }
155 }
156 for window in parts.windows(3) {
157 if window[0] == ".greentic" && window[1] == "state" && window[2] == "dev" {
158 return Some(".greentic/state/dev/ tree");
159 }
160 }
161 if parts.last().copied() == Some(".dev.secrets.env") {
162 return Some(".dev.secrets.env file");
163 }
164 None
165}
166
167pub fn list_bundle(bundle_file: &Path) -> Result<Vec<BundleEntry>> {
168 match selected_reader_kind()? {
169 BundleFsReaderKind::Backhand => BackhandBundleFsReader.list_bundle(bundle_file),
170 BundleFsReaderKind::Unsquashfs => UnsquashfsBundleFsReader.list_bundle(bundle_file),
171 }
172}
173
174pub fn extract_bundle(bundle_file: &Path, output_dir: &Path) -> Result<()> {
175 match selected_reader_kind()? {
176 BundleFsReaderKind::Backhand => {
177 BackhandBundleFsReader.extract_bundle(bundle_file, output_dir)
178 }
179 BundleFsReaderKind::Unsquashfs => {
180 UnsquashfsBundleFsReader.extract_bundle(bundle_file, output_dir)
181 }
182 }
183}
184
185pub fn read_bundle_file(bundle_file: &Path, inner_path: &str) -> Result<Vec<u8>> {
186 match selected_reader_kind()? {
187 BundleFsReaderKind::Backhand => {
188 backhand_writer::read_bundle_file_with_backhand(bundle_file, inner_path)
189 }
190 BundleFsReaderKind::Unsquashfs => {
191 native_unsquashfs_reader::read_bundle_file_with_unsquashfs(bundle_file, inner_path)
192 }
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use std::path::Path;
199
200 use tempfile::TempDir;
201
202 use super::{
203 BundleFsReaderKind, BundleFsWriterKind, READER_ENV, WRITER_ENV, assert_no_dev_secret_paths,
204 dev_secret_match,
205 };
206
207 #[test]
208 fn dev_secret_match_detects_dev_directory() {
209 assert_eq!(
210 dev_secret_match(Path::new(".greentic/dev/whatever.bin")),
211 Some(".greentic/dev/ tree")
212 );
213 }
214
215 #[test]
216 fn dev_secret_match_detects_state_dev_directory() {
217 assert_eq!(
218 dev_secret_match(Path::new(".greentic/state/dev/anything")),
219 Some(".greentic/state/dev/ tree")
220 );
221 }
222
223 #[test]
224 fn dev_secret_match_detects_dev_secrets_env_file() {
225 assert_eq!(
226 dev_secret_match(Path::new("nested/path/.dev.secrets.env")),
227 Some(".dev.secrets.env file")
228 );
229 }
230
231 #[test]
232 fn dev_secret_match_passes_through_safe_paths() {
233 assert_eq!(dev_secret_match(Path::new("packs/pack-a.gtpack")), None);
234 assert_eq!(
235 dev_secret_match(Path::new("state/setup/provider-a.json")),
236 None
237 );
238 }
239
240 #[test]
241 fn assert_denylist_bails_on_dev_store_file() {
242 let temp = TempDir::new().expect("tempdir");
243 let root = temp.path();
244 let dev_dir = root.join(".greentic/dev");
245 std::fs::create_dir_all(&dev_dir).expect("dev dir");
246 std::fs::write(dev_dir.join(".dev.secrets.env"), "GTC_SECRET=leaked").expect("seed");
247 let err = assert_no_dev_secret_paths(root).expect_err("must bail");
248 let msg = format!("{err:#}");
249 assert!(msg.contains("refusing to archive"));
250 assert!(msg.contains(".greentic/dev"));
251 }
252
253 #[test]
254 fn assert_denylist_bails_on_stray_dev_secrets_env() {
255 let temp = TempDir::new().expect("tempdir");
256 let root = temp.path();
257 std::fs::create_dir_all(root.join("packs")).expect("dir");
258 std::fs::write(root.join("packs/.dev.secrets.env"), "TOKEN=leaked").expect("seed");
259 let err = assert_no_dev_secret_paths(root).expect_err("must bail");
260 let msg = format!("{err:#}");
261 assert!(msg.contains(".dev.secrets.env"));
262 }
263
264 #[test]
265 fn assert_denylist_passes_on_clean_tree() {
266 let temp = TempDir::new().expect("tempdir");
267 let root = temp.path();
268 std::fs::create_dir_all(root.join("state/setup")).expect("dir");
269 std::fs::write(root.join("state/setup/provider-a.json"), "{}").expect("seed");
270 assert_no_dev_secret_paths(root).expect("clean tree passes");
271 }
272
273 #[test]
274 fn writer_selection_defaults_to_backhand() {
275 assert_eq!(
276 BundleFsWriterKind::from_env_value(None).expect("writer kind"),
277 BundleFsWriterKind::Backhand
278 );
279 }
280
281 #[test]
282 fn writer_selection_accepts_backhand() {
283 assert_eq!(
284 BundleFsWriterKind::from_env_value(Some("backhand")).expect("writer kind"),
285 BundleFsWriterKind::Backhand
286 );
287 }
288
289 #[test]
290 fn writer_selection_accepts_mksquashfs() {
291 assert_eq!(
292 BundleFsWriterKind::from_env_value(Some("mksquashfs")).expect("writer kind"),
293 BundleFsWriterKind::Mksquashfs
294 );
295 }
296
297 #[test]
298 fn writer_selection_rejects_unknown_values() {
299 let error = BundleFsWriterKind::from_env_value(Some("external")).expect_err("error");
300 let message = error.to_string();
301 assert!(message.contains(WRITER_ENV));
302 assert!(message.contains("backhand, mksquashfs"));
303 }
304
305 #[test]
306 fn reader_selection_defaults_to_backhand() {
307 assert_eq!(
308 BundleFsReaderKind::from_env_value(None).expect("reader kind"),
309 BundleFsReaderKind::Backhand
310 );
311 }
312
313 #[test]
314 fn reader_selection_accepts_backhand() {
315 assert_eq!(
316 BundleFsReaderKind::from_env_value(Some("backhand")).expect("reader kind"),
317 BundleFsReaderKind::Backhand
318 );
319 }
320
321 #[test]
322 fn reader_selection_accepts_unsquashfs() {
323 assert_eq!(
324 BundleFsReaderKind::from_env_value(Some("unsquashfs")).expect("reader kind"),
325 BundleFsReaderKind::Unsquashfs
326 );
327 }
328
329 #[test]
330 fn reader_selection_rejects_unknown_values() {
331 let error = BundleFsReaderKind::from_env_value(Some("external")).expect_err("error");
332 let message = error.to_string();
333 assert!(message.contains(READER_ENV));
334 assert!(message.contains("backhand, unsquashfs"));
335 }
336
337 #[cfg(unix)]
346 #[test]
347 fn assert_denylist_bails_on_file_symlink_targeting_dev_store() {
348 let temp = TempDir::new().expect("tempdir");
349 let root = temp.path();
350 std::fs::create_dir_all(root.join("packs")).expect("packs dir");
351 let benign_name = root.join("packs/seed.env");
352 std::os::unix::fs::symlink("../.greentic/dev/.dev.secrets.env", &benign_name)
355 .expect("create symlink");
356 let err = assert_no_dev_secret_paths(root).expect_err("must bail on dev-targeted symlink");
357 let msg = format!("{err:#}");
358 assert!(
359 msg.contains("refusing to archive symlink"),
360 "expected symlink refusal; got: {msg}"
361 );
362 assert!(msg.contains(".greentic/dev"));
363 }
364
365 #[cfg(unix)]
366 #[test]
367 fn assert_denylist_bails_on_directory_symlink_targeting_dev_tree() {
368 let temp = TempDir::new().expect("tempdir");
369 let root = temp.path();
370 std::fs::create_dir_all(root.join("packs")).expect("dir");
371 std::os::unix::fs::symlink("/tmp/host/.greentic/state/dev", root.join("packs/seed-dir"))
372 .expect("create dir symlink");
373 let err = assert_no_dev_secret_paths(root).expect_err("must bail");
374 assert!(format!("{err:#}").contains(".greentic/state/dev"));
375 }
376
377 #[cfg(unix)]
378 #[test]
379 fn assert_denylist_bails_on_symlink_targeting_stray_dev_secrets_env() {
380 let temp = TempDir::new().expect("tempdir");
381 let root = temp.path();
382 std::fs::create_dir_all(root.join("packs")).expect("dir");
383 std::os::unix::fs::symlink(
384 "/elsewhere/.dev.secrets.env",
385 root.join("packs/innocent.txt"),
386 )
387 .expect("create symlink");
388 let err = assert_no_dev_secret_paths(root).expect_err("must bail");
389 assert!(format!("{err:#}").contains(".dev.secrets.env"));
390 }
391
392 #[cfg(unix)]
393 #[test]
394 fn assert_denylist_allows_benign_symlink_target() {
395 let temp = TempDir::new().expect("tempdir");
400 let root = temp.path();
401 std::fs::create_dir_all(root.join("packs")).expect("dir");
402 std::os::unix::fs::symlink("../resolved/default.yaml", root.join("packs/link"))
403 .expect("create benign symlink");
404 assert_no_dev_secret_paths(root).expect("benign symlink must pass");
405 }
406}