znippy_common/fsmeta.rs
1//! The tree's **shape**: the directories and the unix modes that the chunk index
2//! structurally cannot hold.
3//!
4//! ## The hole this closes
5//!
6//! `index::base_index_fields()` is a table of *chunks*, and every chunk belongs
7//! to a regular file. Two facts of a real directory tree therefore had no row
8//! shape at all and were dropped on the way in:
9//!
10//! * an **empty directory** owns no file, so it produced no chunk, so it
11//! produced no row. Extract only ever ran `create_dir_all` on a *file's*
12//! parent ([`crate::decompress`]), which recreates exactly the directories
13//! that happen to contain something. MEASURED before this module, on a
14//! synthetic `talosctl image cache-create` tree: `blob/`, `manifests/…` came
15//! back, `empty-dir/` did not.
16//! * a **unix mode**. Nothing on the write path ever called `metadata().mode()`
17//! and nothing on the read path ever called `set_permissions`, so an extracted
18//! file got `0666 & ~umask`. MEASURED on the same tree: a `0755` script came
19//! back `0664`.
20//!
21//! Neither failure reported anything. `verify` was green, every file that *did*
22//! come back was byte-exact, and the tree was still wrong — which is the worst
23//! shape a fidelity bug can take.
24//!
25//! ## The representation
26//!
27//! One reserved section, [`ZNIPPY_FSMETA_MODULE`], holding an Arrow IPC stream of
28//! `(relative_path: Utf8, kind: UInt8, mode: UInt32)`. Reserved means
29//! `is_reserved_module` already makes every data reader skip it, so the section
30//! is additive in both directions: an archive written before it existed reads
31//! unchanged, and one written with it opens in an older znippy — which simply
32//! does not learn the empty dirs and the modes. No [`ZNIPPY_FORMAT_VERSION`]
33//! bump, no new column for the iceberg/append/gunnar writers to supply. The
34//! alternatives were both worse and are recorded on [`ZNIPPY_FSMETA_MODULE`].
35//!
36//! Rows are only what the chunk index does not already carry plus the one thing
37//! it carries without the mode: **every** directory (so an empty one survives)
38//! and **every** file whose mode is not the plain default. Skipping the default
39//! is what keeps the section proportional to the interesting part of a tree —
40//! on a registry mirror, thousands of `0644` blobs contribute nothing and the
41//! section is the directory list.
42//!
43//! ## The restore order is load-bearing
44//!
45//! [`restore_fsmeta`] applies in three phases, and each ordering is forced:
46//!
47//! 1. **directories first, before extraction** — an empty directory has no file
48//! to trigger `create_dir_all`, so nothing else will ever create it.
49//! 2. **file modes after extraction** — a `0444` file must be written before it
50//! is made read-only, or the write fails.
51//! 3. **directory modes last, deepest first** — a `0555` directory cannot have
52//! children created inside it, so its own mode is applied only once
53//! everything below it exists.
54//!
55//! Getting phase 3's order wrong is not a cosmetic bug: it turns a restore of a
56//! tree containing one read-only directory into a hard failure.
57
58use std::collections::HashMap;
59use std::path::Path;
60use std::sync::Arc;
61
62use anyhow::{Result, anyhow};
63use arrow::array::{StringArray, StringBuilder, UInt8Array, UInt8Builder, UInt32Array, UInt32Builder};
64use arrow::datatypes::{DataType, Field, Schema};
65use arrow::record_batch::RecordBatch;
66
67use crate::index::{
68 FORMAT_VERSION_KEY, ZNIPPY_FORMAT_VERSION, ZNIPPY_FSMETA_MODULE, check_format_version,
69 read_reserved_section_bytes,
70};
71
72/// What a row describes. Encoded as a `UInt8` rather than a string so the column
73/// is fixed-width; the variants are open-ended on purpose — an unknown kind read
74/// by an older/newer reader is skipped rather than being an error, which is what
75/// keeps the section additive.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[repr(u8)]
78pub enum FsKind {
79 /// A directory. Written for every directory in the tree, empty or not.
80 Dir = 0,
81 /// A regular file whose mode is NOT the tree's [`FileDefaultMode`](Self::FileDefaultMode).
82 /// The file's *bytes* remain the chunk index's job; this row carries only the mode.
83 File = 1,
84 /// The mode every file **not** carrying an explicit [`File`](Self::File) row has.
85 /// `relative_path` is empty — the row is archive-scoped, not path-scoped.
86 ///
87 /// # Why this row exists instead of a per-file row
88 ///
89 /// The first cut compared each file's mode against a hardcoded `0o644` and
90 /// wrote a row only on a difference. That is wrong, and it was caught by its
91 /// own test: `0o644` is `0o666 & ~umask` for `umask 022`, and the box this
92 /// was measured on runs `umask 002`, so every one of a 69-file tree's plain
93 /// files "deviated" and earned a row. A constant cannot stand in for a
94 /// property of whichever process happens to be extracting.
95 ///
96 /// The alternative — a row per file, unconditionally — is exact but pays a
97 /// second copy of every path in the archive (the lookup sub-index already
98 /// holds them all), so the section would grow with the payload. On a registry
99 /// mirror of ~50 000 blobs whose paths average 80 bytes that is ~4 MB to say
100 /// one thing 50 000 times.
101 ///
102 /// One archive-scoped row saying "the rest are 0o644" is both: exact,
103 /// independent of anyone's umask, and O(distinct modes) rather than O(files).
104 /// A registry mirror seals two file rows total — this one, and the one script.
105 FileDefaultMode = 2,
106}
107
108impl FsKind {
109 fn from_u8(v: u8) -> Option<Self> {
110 match v {
111 0 => Some(Self::Dir),
112 1 => Some(Self::File),
113 2 => Some(Self::FileDefaultMode),
114 _ => None,
115 }
116 }
117}
118
119/// One tree-shape fact: this path is a directory / has this mode.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct FsEntry {
122 /// Relative to the archive root, derived exactly as the chunk index's
123 /// `relative_path` is (`strip_prefix(root)`), so the two name the same tree.
124 pub relative_path: String,
125 pub kind: FsKind,
126 /// Unix permission bits (`mode & 0o7777`). `0` means "not recorded" — what a
127 /// non-unix writer produces — and is skipped on restore rather than applied.
128 pub mode: u32,
129}
130
131/// Arrow schema of the [`ZNIPPY_FSMETA_MODULE`] section. Stamped with
132/// [`FORMAT_VERSION_KEY`] like every other Arrow section so
133/// [`check_format_version`] can refuse a section from a newer znippy instead of
134/// mis-reading its columns.
135pub fn fsmeta_schema() -> Arc<Schema> {
136 let fields = vec![
137 Field::new("relative_path", DataType::Utf8, false),
138 Field::new("kind", DataType::UInt8, false),
139 Field::new("mode", DataType::UInt32, false),
140 ];
141 let mut md = HashMap::new();
142 md.insert(FORMAT_VERSION_KEY.to_string(), ZNIPPY_FORMAT_VERSION.to_string());
143 Arc::new(Schema::new_with_metadata(fields, md))
144}
145
146/// Serialize the shape rows into the one `RecordBatch` the section holds.
147///
148/// Sorted by path so the section is deterministic — the same tree walked on two
149/// machines (readdir order is not stable) seals the same bytes, which is the
150/// same property Law 3 gives the checksums.
151pub fn build_fsmeta_batch(entries: &[FsEntry]) -> Result<RecordBatch> {
152 let mut order: Vec<&FsEntry> = entries.iter().collect();
153 order.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
154
155 let n = order.len();
156 // PERF (Law 2): all three builders sized once from the row count; the string
157 // builder gets a byte estimate too, so a deep mirror tree's long paths do not
158 // walk the Vec through a dozen reallocations.
159 let mut path_b = StringBuilder::with_capacity(n, n * 48);
160 let mut kind_b = UInt8Builder::with_capacity(n);
161 let mut mode_b = UInt32Builder::with_capacity(n);
162 for e in order {
163 path_b.append_value(&e.relative_path);
164 kind_b.append_value(e.kind as u8);
165 mode_b.append_value(e.mode);
166 }
167 RecordBatch::try_new(
168 fsmeta_schema(),
169 vec![Arc::new(path_b.finish()), Arc::new(kind_b.finish()), Arc::new(mode_b.finish())],
170 )
171 .map_err(|e| anyhow!("fsmeta batch: {e}"))
172}
173
174/// Decode the section's Arrow IPC bytes back into rows.
175fn decode_fsmeta_section(bytes: &[u8]) -> Result<Vec<FsEntry>> {
176 use arrow::ipc::reader::StreamReader;
177
178 let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
179 .map_err(|e| anyhow!("fsmeta section: not a readable Arrow stream: {e}"))?;
180 check_format_version(reader.schema().metadata())?;
181
182 let mut out = Vec::new();
183 for batch in reader {
184 let batch = batch.map_err(|e| anyhow!("fsmeta section read: {e}"))?;
185 let paths = batch
186 .column_by_name("relative_path")
187 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
188 .ok_or_else(|| anyhow!("fsmeta section: missing/!Utf8 relative_path"))?;
189 let kinds = batch
190 .column_by_name("kind")
191 .and_then(|c| c.as_any().downcast_ref::<UInt8Array>())
192 .ok_or_else(|| anyhow!("fsmeta section: missing/!UInt8 kind"))?;
193 let modes = batch
194 .column_by_name("mode")
195 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
196 .ok_or_else(|| anyhow!("fsmeta section: missing/!UInt32 mode"))?;
197 out.reserve(batch.num_rows());
198 for i in 0..batch.num_rows() {
199 // An unrecognised kind is SKIPPED, not an error: that is the whole
200 // additivity contract. A future znippy recording symlinks here must
201 // not make its archives unreadable by this one.
202 let Some(kind) = FsKind::from_u8(kinds.value(i)) else { continue };
203 out.push(FsEntry {
204 relative_path: paths.value(i).to_string(),
205 kind,
206 mode: modes.value(i),
207 });
208 }
209 }
210 Ok(out)
211}
212
213/// Read an archive's tree shape. `Ok(None)` — not an empty vec — when the archive
214/// carries no such section, so an archive sealed before this module existed says
215/// "I do not know" rather than "the tree had no directories".
216pub fn read_fsmeta(archive: &Path) -> Result<Option<Vec<FsEntry>>> {
217 match read_reserved_section_bytes(archive, ZNIPPY_FSMETA_MODULE)? {
218 None => Ok(None),
219 Some(bytes) => Ok(Some(decode_fsmeta_section(&bytes)?)),
220 }
221}
222
223/// What [`restore_fsmeta`] actually did — reported rather than assumed, so a
224/// caller can tell "no section" from "a section that restored nothing".
225#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
226pub struct FsRestoreReport {
227 pub dirs_created: u64,
228 pub modes_applied: u64,
229}
230
231/// Phase 1: create every recorded directory, **before** extraction.
232///
233/// This is the phase that makes an empty directory survive at all — nothing else
234/// on the read path will ever create one. Runs before the chunk loop so a
235/// directory is in place ahead of the files that land in it, which also spares
236/// the extract loop a `create_dir_all` per file for paths already covered here.
237///
238/// Every path goes through `safe_output_path`, the same lexical guard the file
239/// rows use: a `..` or absolute component in the section is refused, not
240/// sanitized. The section is as attacker-reachable as the index is.
241pub fn create_recorded_dirs(out_dir: &Path, entries: &[FsEntry]) -> Result<u64> {
242 let mut created = 0u64;
243 for e in entries.iter().filter(|e| e.kind == FsKind::Dir) {
244 let full = crate::decompress::safe_output_path(out_dir, &e.relative_path)?;
245 if !full.is_dir() {
246 std::fs::create_dir_all(&full)?;
247 created += 1;
248 }
249 }
250 Ok(created)
251}
252
253/// Phases 2 and 3: apply the recorded modes, **after** extraction.
254///
255/// `extracted` is the set of file paths the extract actually restored — the
256/// [`FsKind::FileDefaultMode`] row applies to every one of them that has no
257/// explicit row of its own, which is what makes the section O(distinct modes)
258/// rather than O(files).
259///
260/// Files first, then directories deepest-first. Both orderings are forced and
261/// neither is cosmetic — see the module docs. A `0` mode is "not recorded" and is
262/// left alone.
263#[cfg(unix)]
264pub fn apply_recorded_modes<'p, I>(out_dir: &Path, entries: &[FsEntry], extracted: I) -> Result<u64>
265where
266 I: IntoIterator<Item = &'p str>,
267{
268 use std::collections::HashSet;
269 use std::os::unix::fs::PermissionsExt;
270
271 let mut applied = 0u64;
272 let set = |rel: &str, mode: u32| -> Result<bool> {
273 let full = crate::decompress::safe_output_path(out_dir, rel)?;
274 // A filtered extract restores a subset, and a mode row for a path that was
275 // not restored is expected rather than an error. `symlink_metadata` so a
276 // dangling entry is still seen as present.
277 if std::fs::symlink_metadata(&full).is_err() {
278 return Ok(false);
279 }
280 std::fs::set_permissions(&full, std::fs::Permissions::from_mode(mode))?;
281 Ok(true)
282 };
283
284 // Explicit per-file rows first, and remember which paths they covered so the
285 // default does not then overwrite them.
286 let mut explicit: HashSet<&str> = HashSet::new();
287 for e in entries.iter().filter(|e| e.kind == FsKind::File && e.mode != 0) {
288 explicit.insert(e.relative_path.as_str());
289 if set(&e.relative_path, e.mode)? {
290 applied += 1;
291 }
292 }
293
294 // Then the archive-scoped default over everything else that came out.
295 if let Some(def) = entries
296 .iter()
297 .find(|e| e.kind == FsKind::FileDefaultMode && e.mode != 0)
298 .map(|e| e.mode)
299 {
300 for rel in extracted {
301 if explicit.contains(rel) {
302 continue;
303 }
304 if set(rel, def)? {
305 applied += 1;
306 }
307 }
308 }
309
310 // Deepest first: a directory whose recorded mode denies write (0o555, or the
311 // 0o700-minus-w shapes a cache tree uses) must not be sealed while anything
312 // below it still needs creating or chmod-ing. Depth by component count, not
313 // string length — `a/bbbb` is shallower than `a/b/c` however the bytes fall.
314 let mut dirs: Vec<&FsEntry> =
315 entries.iter().filter(|e| e.kind == FsKind::Dir && e.mode != 0).collect();
316 dirs.sort_by_key(|e| std::cmp::Reverse(e.relative_path.matches('/').count()));
317 for e in dirs {
318 if set(&e.relative_path, e.mode)? {
319 applied += 1;
320 }
321 }
322 Ok(applied)
323}
324
325/// Non-unix: mode bits do not translate, so nothing is applied. The directory
326/// phase still runs — an empty directory is not a unix concept.
327#[cfg(not(unix))]
328pub fn apply_recorded_modes<'p, I>(
329 _out_dir: &Path,
330 _entries: &[FsEntry],
331 _extracted: I,
332) -> Result<u64>
333where
334 I: IntoIterator<Item = &'p str>,
335{
336 Ok(0)
337}
338
339/// A file's permission bits, or `None` off unix where they do not translate.
340#[cfg(unix)]
341pub fn file_mode(meta: &std::fs::Metadata) -> Option<u32> {
342 use std::os::unix::fs::MetadataExt;
343 Some(meta.mode() & 0o7777)
344}
345
346#[cfg(not(unix))]
347pub fn file_mode(_meta: &std::fs::Metadata) -> Option<u32> {
348 None
349}
350
351/// Reduce per-file modes to the rows worth sealing: one
352/// [`FsKind::FileDefaultMode`] row carrying the tree's **most common** file mode,
353/// plus one [`FsKind::File`] row for each file that deviates from it.
354///
355/// Taking the modal mode rather than a constant is what makes the elision exact:
356/// whatever `umask` the tree was built under, the bulk case is described once and
357/// the deviations — the executable script, the read-only blob — are named.
358///
359/// `modes` is consumed in walk order; ties on frequency resolve to the lowest
360/// mode, so the section is deterministic for the same reason
361/// [`build_fsmeta_batch`] sorts.
362pub fn summarise_file_modes(modes: &[(String, u32)]) -> Vec<FsEntry> {
363 if modes.is_empty() {
364 return Vec::new();
365 }
366 let mut counts: HashMap<u32, usize> = HashMap::new();
367 for (_, m) in modes {
368 *counts.entry(*m).or_default() += 1;
369 }
370 // Most frequent wins; lowest mode breaks a tie so two machines agree.
371 let default = counts
372 .iter()
373 .max_by_key(|(mode, n)| (**n, std::cmp::Reverse(**mode)))
374 .map(|(mode, _)| *mode)
375 .unwrap_or(0);
376
377 let mut out = Vec::with_capacity(1 + modes.len() - counts[&default]);
378 out.push(FsEntry {
379 relative_path: String::new(),
380 kind: FsKind::FileDefaultMode,
381 mode: default,
382 });
383 for (path, mode) in modes {
384 if *mode != default {
385 out.push(FsEntry {
386 relative_path: path.clone(),
387 kind: FsKind::File,
388 mode: *mode,
389 });
390 }
391 }
392 out
393}
394
395/// The mode to record for a directory. Always `Some` on unix — the row is being
396/// written for the directory's *existence* regardless, so the mode rides along
397/// free.
398#[cfg(unix)]
399pub fn dir_mode(meta: &std::fs::Metadata) -> u32 {
400 use std::os::unix::fs::MetadataExt;
401 meta.mode() & 0o7777
402}
403
404#[cfg(not(unix))]
405pub fn dir_mode(_meta: &std::fs::Metadata) -> u32 {
406 0
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn sample() -> Vec<FsEntry> {
414 vec![
415 FsEntry { relative_path: "blob".into(), kind: FsKind::Dir, mode: 0o755 },
416 FsEntry { relative_path: "empty-dir".into(), kind: FsKind::Dir, mode: 0o755 },
417 FsEntry { relative_path: "blob/run.sh".into(), kind: FsKind::File, mode: 0o755 },
418 FsEntry {
419 // The registry-mirror shapes: ':' in a host:port, '@' in a repo
420 // segment, '+' in a tag, and a non-ASCII tag.
421 relative_path: "manifests/registry.k8s.io:5000/x@v1/ref/v1.30+b1".into(),
422 kind: FsKind::Dir,
423 mode: 0o700,
424 },
425 FsEntry { relative_path: "manifests/ünïcödé".into(), kind: FsKind::Dir, mode: 0o755 },
426 ]
427 }
428
429 /// The section is only useful if it survives the encode; every column and
430 /// every awkward path shape must come back identical.
431 #[test]
432 fn shape_rows_round_trip_through_arrow() {
433 let batch = build_fsmeta_batch(&sample()).unwrap();
434 let mut bytes = Vec::new();
435 {
436 let mut w =
437 arrow::ipc::writer::StreamWriter::try_new(&mut bytes, &fsmeta_schema()).unwrap();
438 w.write(&batch).unwrap();
439 w.finish().unwrap();
440 }
441 let back = decode_fsmeta_section(&bytes).unwrap();
442
443 let mut want = sample();
444 want.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
445 assert_eq!(back, want, "every row, in the deterministic sorted order");
446 }
447
448 /// Determinism: readdir order is not stable across machines, so the section
449 /// must not be either. Two shuffles of one tree seal identical bytes.
450 #[test]
451 fn the_section_is_independent_of_walk_order() {
452 let a = build_fsmeta_batch(&sample()).unwrap();
453 let mut shuffled = sample();
454 shuffled.reverse();
455 let b = build_fsmeta_batch(&shuffled).unwrap();
456 assert_eq!(a, b, "sorted by path, so walk order cannot leak into the archive");
457 }
458
459 /// The additivity contract, driven rather than asserted in prose: a row whose
460 /// `kind` this reader does not know must be skipped, never an error — or the
461 /// first znippy to record symlinks here makes its archives unreadable by
462 /// every znippy shipped before it.
463 #[test]
464 fn an_unknown_kind_is_skipped_not_an_error() {
465 let schema = fsmeta_schema();
466 let batch = RecordBatch::try_new(
467 schema.clone(),
468 vec![
469 Arc::new(StringArray::from(vec!["a", "future/thing"])),
470 Arc::new(UInt8Array::from(vec![FsKind::Dir as u8, 200])),
471 Arc::new(UInt32Array::from(vec![0o755u32, 0o777])),
472 ],
473 )
474 .unwrap();
475 let mut bytes = Vec::new();
476 {
477 let mut w = arrow::ipc::writer::StreamWriter::try_new(&mut bytes, &schema).unwrap();
478 w.write(&batch).unwrap();
479 w.finish().unwrap();
480 }
481 let back = decode_fsmeta_section(&bytes).unwrap();
482 assert_eq!(back.len(), 1, "the known row survives");
483 assert_eq!(back[0].relative_path, "a");
484 }
485
486 /// A path escaping the output root is REFUSED, not sanitized — the section is
487 /// exactly as attacker-reachable as the chunk index, and gets the same guard.
488 #[test]
489 fn a_traversing_dir_row_is_refused() {
490 let out = tempfile::tempdir().unwrap();
491 let rows = vec![FsEntry {
492 relative_path: "../escaped".into(),
493 kind: FsKind::Dir,
494 mode: 0o755,
495 }];
496 assert!(create_recorded_dirs(out.path(), &rows).is_err());
497 assert!(!out.path().parent().unwrap().join("escaped").exists());
498 }
499
500 /// Phase 3's ordering, driven: a read-only directory with content under it
501 /// must restore, which it only does if the child is chmod-ed before the
502 /// parent is sealed.
503 #[cfg(unix)]
504 #[test]
505 fn a_read_only_directory_does_not_block_its_children() {
506 use std::os::unix::fs::PermissionsExt;
507
508 let out = tempfile::tempdir().unwrap();
509 let rows = vec![
510 FsEntry { relative_path: "locked".into(), kind: FsKind::Dir, mode: 0o555 },
511 FsEntry { relative_path: "locked/inner".into(), kind: FsKind::Dir, mode: 0o755 },
512 FsEntry { relative_path: "locked/inner/x".into(), kind: FsKind::File, mode: 0o750 },
513 ];
514 create_recorded_dirs(out.path(), &rows).unwrap();
515 std::fs::write(out.path().join("locked/inner/x"), b"payload").unwrap();
516 apply_recorded_modes(out.path(), &rows, std::iter::empty()).unwrap();
517
518 let m = |p: &str| {
519 std::fs::metadata(out.path().join(p)).unwrap().permissions().mode() & 0o7777
520 };
521 assert_eq!(m("locked/inner/x"), 0o750);
522 assert_eq!(m("locked/inner"), 0o755);
523 assert_eq!(m("locked"), 0o555);
524
525 // tempdir cleanup needs the write bit back.
526 std::fs::set_permissions(out.path().join("locked"), std::fs::Permissions::from_mode(0o755))
527 .unwrap();
528 }
529
530 /// The elision, and the bug that shaped it: the bulk mode is described ONCE
531 /// and only the deviations are named. Driven with a bulk mode of `0o664` —
532 /// deliberately NOT the `0o644` the first cut hardcoded — because a constant
533 /// is exactly what this rule replaces.
534 #[test]
535 fn the_bulk_mode_is_described_once_and_only_deviations_are_named() {
536 let modes: Vec<(String, u32)> = (0..40)
537 .map(|i| (format!("blob/{i:064x}"), 0o664))
538 .chain([("blob/run.sh".to_string(), 0o775), ("blob/ro".to_string(), 0o444)])
539 .collect();
540
541 let rows = summarise_file_modes(&modes);
542 assert_eq!(rows.len(), 3, "one default + two deviations, not 42 rows: {rows:?}");
543 assert_eq!(rows[0].kind, FsKind::FileDefaultMode);
544 assert_eq!(rows[0].mode, 0o664, "the MODAL mode, whatever the umask was");
545 assert!(rows[0].relative_path.is_empty(), "the default row is archive-scoped");
546 let named: Vec<_> = rows[1..].iter().map(|r| (r.relative_path.as_str(), r.mode)).collect();
547 assert_eq!(named, vec![("blob/run.sh", 0o775), ("blob/ro", 0o444)]);
548 }
549
550 /// The default row must be applied to files that have no row of their own,
551 /// and must NOT overwrite one that does. Both halves are the point: without
552 /// the first, eliding loses the bulk mode; without the second, eliding
553 /// destroys the executable bit it was supposed to preserve.
554 #[cfg(unix)]
555 #[test]
556 fn the_default_reaches_unnamed_files_and_never_overrides_a_named_one() {
557 use std::os::unix::fs::PermissionsExt;
558
559 let out = tempfile::tempdir().unwrap();
560 for f in ["plain", "other", "run.sh"] {
561 std::fs::write(out.path().join(f), b"x").unwrap();
562 std::fs::set_permissions(out.path().join(f), std::fs::Permissions::from_mode(0o600))
563 .unwrap();
564 }
565 let rows = summarise_file_modes(&[
566 ("plain".into(), 0o664),
567 ("other".into(), 0o664),
568 ("run.sh".into(), 0o775),
569 ]);
570 let extracted = ["plain", "other", "run.sh"];
571 let n = apply_recorded_modes(out.path(), &rows, extracted).unwrap();
572 assert_eq!(n, 3, "all three files got a mode");
573
574 let m = |p: &str| {
575 std::fs::metadata(out.path().join(p)).unwrap().permissions().mode() & 0o7777
576 };
577 assert_eq!(m("plain"), 0o664, "the default reached it");
578 assert_eq!(m("other"), 0o664);
579 assert_eq!(m("run.sh"), 0o775, "the named row won over the default");
580 }
581}