Skip to main content

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}