Skip to main content

git_policy_cli/foundation/
private_storage.rs

1//! What:
2//!  Private,
3//!  crash-safe file primitives shared by every durable record of the wrapper:
4//!       exclusive no-follow creation with private modes,
5//!  file and directory sync,
6//!  and
7//!       no-follow reads that refuse links and non-regular files.
8//! Why:
9//!  Lock records,
10//!  transaction journals and capture records must survive a crash in a
11//!      readable state,
12//!  must never be written through a planted link,
13//!  and must be private to
14//!      the account.
15//!  The incumbent's `src/trust/registry-io.ts` defines these semantics;
16//!  both
17//!      wrappers write and read the same files.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! await writePrivateFile({ path, bytes }); await syncDirectory(dirname(path));
22//! ```
23
24/// What:
25///  `File` is an open file handle;
26///  `OpenOptions` configures how one is opened.
27/// Why:
28///   Exclusive creation and no-follow opening need the configurable form.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// import { open, constants } from 'node:fs/promises';
33/// ```
34use std::fs::{File, OpenOptions};
35/// The trait that gives files `.read_to_end` and `.take`.
36use std::io::Read;
37/// The trait that gives files `.write_all`.
38use std::io::Write;
39/// Owned operating-system text for the staging name.
40use std::ffi::OsString;
41/// `Path` is a borrowed filesystem path of raw bytes; `PathBuf` is the owned one.
42use std::path::{Path, PathBuf};
43/// Diagnostics for a decision no failure reports.
44use crate::diagnostic_log::debug;
45/// Unguessable staging names.
46use crate::random_id::random_uuid;
47
48/// Mode of every private directory the wrapper creates:
49///  owner only.
50pub const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
51
52/// Mode of every private file the wrapper creates:
53///  owner read and write only.
54pub const PRIVATE_FILE_MODE: u32 = 0o600;
55
56/// What:
57///  Why a no-follow read refused a path.
58///  An `enum` is a closed set of alternatives.
59/// Why:
60///   A missing file is an expected answer for optional records;
61///  a link,
62///  a directory or an
63///       over-long file is unsafe or corrupt state that recovery must name,
64///  never follow.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// type ReadRefusal = { kind: 'missing' } | { kind: 'not-regular' } | { kind: 'too-large' } | { kind: 'io'; error: Error };
69/// ```
70#[derive(Debug)]
71pub enum ReadRefusal {
72    /// Nothing exists at the path.
73    Missing,
74    /// The path names a link,
75    ///  directory or other non-regular file.
76    NotRegular,
77    /// The file holds more bytes than the caller allows.
78    TooLarge,
79    /// The operating system refused the read for another reason.
80    Io(
81        /// The operating-system error that refused the read.
82        std::io::Error,
83    ),
84}
85
86/// What:
87///  Open options for an exclusive,
88///  no-follow,
89///  private-mode creation.
90///       `#[cfg(unix)]` compiles the block only on Unix-like systems.
91/// Why:
92///   `O_EXCL` makes a second writer fail instead of replacing the record;
93///  `O_NOFOLLOW`
94///       refuses a planted final link;
95///  mode `0600` keeps the record private from the start.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600)
100/// ```
101fn exclusive_options() -> OpenOptions {
102    // `mut` allows the builder to be configured step by step.
103    let mut options: OpenOptions = OpenOptions::new();
104    options.write(true).create_new(true);
105    #[cfg(unix)]
106    {
107        // The trait adds `.mode` and `.custom_flags` to open options on Unix.
108        /// The Unix open-option extensions,
109        ///  for the mode and no-follow flags.
110        use std::os::unix::fs::OpenOptionsExt;
111        options
112            .mode(PRIVATE_FILE_MODE)
113            .custom_flags(libc::O_NOFOLLOW);
114    }
115    return options;
116}
117
118/// What:
119///  Set a path's permission bits to `mode`,
120///  on Unix only.
121///       `std::io::Result<()>` is "nothing,
122///  or an operating-system error".
123/// Why:
124///   The creation mode is filtered by the process `umask`;
125///  an explicit change makes the
126///       mode exactly private whatever the caller's `umask` is,
127///  as the incumbent's
128///       `protectPath` does.
129///  Windows keeps its inherited access rules (recorded under
130///       "Choices open to veto" in `doc/handover/cli-git-native-transactions.md`).
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// if (process.platform !== 'win32') await chmod(path, mode);
135/// ```
136pub fn protect_path(path: &Path, mode: u32) -> std::io::Result<()> {
137    #[cfg(unix)]
138    {
139        // The trait adds `.from_mode` to permissions on Unix.
140        /// The Unix permission extensions,
141        ///  for the mode.
142        use std::os::unix::fs::PermissionsExt;
143        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
144    }
145    // `let _ = mode;` marks the argument as used where no mode exists.
146    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
147    #[cfg(not(unix))]
148    let _ = (path, mode);
149    return Ok(());
150}
151
152/// What:
153///  The name of the staging file beside `path`: hidden, unique, ending in `.partial`.
154/// Why:
155///   Recovery finds records by their exact names, so a staging file never looks like one.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// join(dirname(path), `.${basename(path)}.${randomUUID()}.partial`)
160/// ```
161fn staging_path(path: &Path) -> std::io::Result<PathBuf> {
162    let Some(name) = path.file_name() else {
163        return Err(std::io::Error::new(
164            std::io::ErrorKind::InvalidInput,
165            format!("{} names no file", path.display()),
166        ));
167    };
168    let unique: String = random_uuid().map_err(|error| { return std::io::Error::other(error.0); })?;
169    let mut staged: OsString = OsString::from(".");
170    staged.push(name);
171    staged.push(".");
172    staged.push(unique);
173    staged.push(".partial");
174    return Ok(path.with_file_name(staged));
175}
176
177/// What:
178///  Make `staged` appear at `path` in one step, failing with `AlreadyExists` when `path` is taken.
179/// Why:
180///   A hard link is atomic and exclusive. A filesystem that refuses hard links (`EPERM`, `ENOTSUP`, `EXDEV`) gets the
181///  rename after a check that nothing is at `path`, which is atomic and, with the writers of one record being
182///  one process, as exclusive as the callers need.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// try { await link(staged, path); } catch (error) { if (error.code === 'EEXIST') throw error; await rename(staged, path); }
187/// ```
188fn publish_exclusive(staged: &Path, path: &Path) -> std::io::Result<()> {
189    match std::fs::hard_link(staged, path) {
190        Ok(()) => return Ok(()),
191        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Err(error),
192        Err(error) => {
193            debug(
194                "publishExclusive",
195                format!("the filesystem refused the link of {} ({error}); publishing by rename", path.display()).as_str(),
196            );
197        }
198    }
199    if std::fs::symlink_metadata(path).is_ok() {
200        return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
201    }
202    return std::fs::rename(staged, path);
203}
204
205/// What:
206///  Write `bytes` to a staging file beside `path`, sync it when `durable`, and publish it at `path`; any failure
207///  removes the staging file, so `path` holds all the bytes or does not exist.
208///  `mode` is applied to the file before it is published, when given.
209/// Why:
210///   A file created at its final name and then written is a record that a kill, or a full disk, between the two
211///  steps leaves empty or cut: recovery then stops every guarded command until a person removes it. Publishing a
212///  complete file by link makes the record appear whole or not at all, whether or not its bytes are synced; a record
213///  that the tearing table shows to be benign when a power loss tears it skips the sync.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// const staged = stagingPath(path); try { await writeAndSync(staged, bytes); await publishExclusive(staged, path); } finally { await rm(staged, { force: true }); }
218/// ```
219fn write_then_publish(path: &Path, bytes: &[u8], mode: Option<u32>, durable: bool) -> std::io::Result<()> {
220    let staged: PathBuf = staging_path(path)?;
221    let mut file: File = exclusive_options().open(staged.as_path())?;
222    let filled: std::io::Result<()> = file.write_all(bytes).and_then(|()| {
223        if !durable {
224            return Ok(());
225        }
226        #[cfg(test)]
227        record_file_sync(path);
228        // `sync_all` flushes data and metadata, the incumbent's `handle.sync()`.
229        return file.sync_all();
230    });
231    drop(file);
232    let published: std::io::Result<()> = filled
233        .and_then(|()| {
234            return match mode {
235                Some(bits) => protect_path(staged.as_path(), bits),
236                None => Ok(()),
237            };
238        })
239        .and_then(|()| { return publish_exclusive(staged.as_path(), path); });
240    if let Some(message) = staging_removal_report(staged.as_path(), std::fs::remove_file(staged.as_path())) {
241        debug("writeThenPublish", message.as_str());
242    }
243    return published;
244}
245
246/// What:
247///  The diagnostic that the removal of a staging file calls for, or `None` when nothing needs saying.
248/// Why:
249///   The rename fallback already moved the staging file, so its absence is the expected end state, while any other
250///  failure leaves a stray `.partial` file that a person may want to know about; a function of the removal result
251///  lets a test see that choice, which no file on disk shows.
252///
253/// In TS you'd write (pseudocode):
254/// ```ts
255/// error === undefined || error.code === 'ENOENT' ? undefined : `removing the staging file ${staged} failed: ${error}`
256/// ```
257fn staging_removal_report(staged: &Path, removal: std::io::Result<()>) -> Option<String> {
258    return match removal {
259        Ok(()) => None,
260        // The rename fallback already moved it.
261        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
262        Err(error) => Some(format!("removing the staging file {} failed: {error}", staged.display())),
263    };
264}
265
266/// What:
267///  Write one private file exclusively,
268///  refusing links,
269///  then make its bytes durable.
270///       `&[u8]` borrows the exact bytes.
271/// Why:
272///   A journal state file is created once and never rewritten,
273///  so a crash leaves either
274///       no file or a complete one after the caller syncs the directory.
275///  The file is written under a staging name and published whole (`write_then_publish`), so that the promise
276///  also holds for a kill between the creation and the write and for a write cut short by a full disk.
277///
278/// In TS you'd write (pseudocode):
279/// ```ts
280/// async function writePrivateFile({ path, bytes }): Promise<void>;
281/// ```
282pub fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
283    return write_then_publish(path, bytes, Some(PRIVATE_FILE_MODE), true);
284}
285
286/// What:
287///  Write one private file exclusively and publish it whole,
288///  without syncing its bytes.
289/// Why:
290///   A record that recovery completes or discards whatever a power loss left of it (the tearing table in
291///  `doc/planning/cli-git-spawn-and-sync-reduction.md`) gains nothing from a flush that makes every commit wait for the
292///  disk; a kill still leaves it whole or absent, because it is published by link as `write_private_file` does.
293///
294/// In TS you'd write (pseudocode):
295/// ```ts
296/// await writePrivateFile({ path, bytes, sync: false });
297/// ```
298pub fn write_private_file_unsynced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
299    return write_then_publish(path, bytes, Some(PRIVATE_FILE_MODE), false);
300}
301
302/// What:
303///  Write one file exclusively with mode `0600`,
304///  refusing links,
305///  and sync its bytes,
306///       without the explicit mode change.
307/// Why:
308///   Owner-lock candidates are written this way by the incumbent's `writeCandidate`:
309///  the
310///       creation mode alone,
311///  then a sync before publication.
312///  The file is published whole, as `write_private_file` does.
313///
314/// In TS you'd write (pseudocode):
315/// ```ts
316/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes); await handle.sync();
317/// ```
318pub fn write_exclusive_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
319    return write_then_publish(path, bytes, None, true);
320}
321
322/// What:
323///  Write one file exclusively with mode `0600`,
324///  refusing links,
325///  without syncing it.
326/// Why:
327///   Scratch bytes that are unlinked right after they are opened for reading are never recovered,
328///  so a sync would only make every caller wait for the disk;
329///  records that a crash must find stay on `write_exclusive_synced`.
330///
331/// In TS you'd write (pseudocode):
332/// ```ts
333/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes);
334/// ```
335pub fn write_exclusive_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
336    let mut file: File = exclusive_options().open(path)?;
337    return file.write_all(bytes);
338}
339
340/// What:
341///  Create one directory with mode `0700`;
342///  its parent must exist.
343/// Why:
344///   Lock candidates,
345///  transaction directories and stores are private to the account.
346///       Only the creation mode is applied,
347///  as the incumbent's `mkdir(path, { mode })`.
348///
349/// In TS you'd write (pseudocode):
350/// ```ts
351/// await mkdir(path, { mode: 0o700 });
352/// ```
353pub fn create_private_directory(path: &Path) -> std::io::Result<()> {
354    return private_directory_builder().create(path);
355}
356
357/// What:
358///  Create a directory and every missing parent with mode `0700`;
359///  an existing directory is accepted.
360/// Why:
361///   The per-user state directory may not exist yet,
362///  and the XDG rules ask for `0700` when a program creates one.
363///  Only the directories this call creates get the mode;
364///  existing parents keep theirs.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// await mkdir(path, { recursive: true, mode: 0o700 });
369/// ```
370pub fn create_private_directory_all(path: &Path) -> std::io::Result<()> {
371    // `mut` allows the builder to be switched to recursive creation.
372    let mut builder: std::fs::DirBuilder = private_directory_builder();
373    builder.recursive(true);
374    return builder.create(path);
375}
376
377/// What:
378///  The directory builder that creates private directories on this platform.
379/// Why:
380///   Only Unix configures a mode;
381///  a separate function per platform keeps each body free of
382///       a `mut` the other platform would leave unused.
383///
384/// In TS you'd write (pseudocode):
385/// ```ts
386/// const builder = { mode: 0o700 };
387/// ```
388#[cfg(unix)]
389fn private_directory_builder() -> std::fs::DirBuilder {
390    // The trait adds `.mode` to directory builders on Unix.
391    /// The Unix directory-builder extensions,
392    ///  for the mode.
393    use std::os::unix::fs::DirBuilderExt;
394    // `mut` allows the builder to be configured before it is returned.
395    let mut builder: std::fs::DirBuilder = std::fs::DirBuilder::new();
396    builder.mode(PRIVATE_DIRECTORY_MODE);
397    return builder;
398}
399
400/// What:
401///  The directory builder that creates private directories on this platform.
402/// Why:
403///   Windows has no mode to configure,
404///  as `private_directory_builder` on Unix explains.
405///
406/// In TS you'd write (pseudocode):
407/// ```ts
408/// const builder = {};
409/// ```
410// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
411#[cfg(not(unix))]
412fn private_directory_builder() -> std::fs::DirBuilder {
413    return std::fs::DirBuilder::new();
414}
415
416/// What:
417///  Whether a failed directory sync means the filesystem has no such operation, not that the flush failed.
418/// Why:
419///   `EINVAL`, `ENOTSUP` and `ENOSYS` are what a filesystem without the operation returns; `EIO`, `ENOSPC` and
420///  `EDQUOT` mean the flush itself failed and stay errors.
421///
422/// In TS you'd write (pseudocode):
423/// ```ts
424/// ['EINVAL', 'ENOTSUP', 'ENOSYS'].includes(error.code)
425/// ```
426#[cfg(unix)]
427fn directory_sync_is_unsupported(error: &std::io::Error) -> bool {
428    return matches!(error.raw_os_error(), Some(libc::EINVAL | libc::ENOTSUP | libc::ENOSYS));
429}
430
431/// What:
432///  The outcome of syncing the directory at `path`: a filesystem without the operation counts as synced, any other
433///  failure stays an error.
434/// Why:
435///   A filesystem with no directory flush (procfs, some network and userspace filesystems) answers with one of the
436///  unsupported codes; the entry cannot be made durable there by any means, and failing would stop every commit. A
437///  function of the sync result lets a test feed the flush failures (`EIO`, `ENOSPC`, `EDQUOT`) that no
438///  unprivileged filesystem produces.
439///
440/// In TS you'd write (pseudocode):
441/// ```ts
442/// if (error !== undefined && !['EINVAL', 'ENOTSUP', 'ENOSYS'].includes(error.code)) throw error;
443/// ```
444#[cfg(unix)]
445fn settle_directory_sync(path: &Path, outcome: std::io::Result<()>) -> std::io::Result<()> {
446    return match outcome {
447        Ok(()) => Ok(()),
448        Err(error) if directory_sync_is_unsupported(&error) => {
449            debug(
450                "syncDirectory",
451                format!("the filesystem of {} cannot sync a directory ({error}); skipped", path.display()).as_str(),
452            );
453            Ok(())
454        }
455        Err(error) => Err(error),
456    };
457}
458
459/// What:
460///  Make a directory's entries durable,
461///  where the platform supports directory handles.
462/// Why:
463///   A new or renamed entry survives a crash only after its directory is synced;
464///  Windows
465///       has no directory handle for this,
466///  as the incumbent's `syncDirectory` notes.
467///
468/// In TS you'd write (pseudocode):
469/// ```ts
470/// async function syncDirectory(path: string): Promise<void>;
471/// ```
472pub fn sync_directory(path: &Path) -> std::io::Result<()> {
473    #[cfg(test)]
474    record_directory_sync(path);
475    #[cfg(unix)]
476    {
477        // Opening a directory read-only and syncing it is the POSIX way to flush its entries.
478        let directory: File = File::open(path)?;
479        settle_directory_sync(path, directory.sync_all())?;
480    }
481    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
482    #[cfg(not(unix))]
483    let _ = path;
484    return Ok(());
485}
486
487#[cfg(test)]
488thread_local! {
489    /// Paths of the files this thread asked `write_then_publish` to sync, in call order.
490    static FILE_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
491}
492
493/// What:
494///  Remember that this thread synced a file it published at `path`.
495/// Why:
496///   Unit tests pin which records keep their file sync, which no file on disk shows.
497///
498/// In TS you'd write (pseudocode):
499/// ```ts
500/// syncedFiles.push(path);
501/// ```
502#[cfg(test)]
503fn record_file_sync(path: &Path) {
504    FILE_SYNCS.with(|syncs| {
505        syncs.borrow_mut().push(path.to_path_buf());
506    });
507}
508
509/// What:
510///  Take and clear the files this thread has synced so far.
511/// Why:
512///   A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
513///
514/// In TS you'd write (pseudocode):
515/// ```ts
516/// const synced = syncedFiles.splice(0);
517/// ```
518#[cfg(test)]
519pub fn take_file_syncs() -> Vec<std::path::PathBuf> {
520    return FILE_SYNCS.with(|syncs| {
521        return std::mem::take(&mut *syncs.borrow_mut());
522    });
523}
524
525#[cfg(test)]
526thread_local! {
527    /// Canonical paths of the directories this thread asked `sync_directory` to flush, in call order.
528    static DIRECTORY_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
529}
530
531/// What:
532///  Remember that this thread asked for a directory sync.
533/// Why:
534///   Unit tests prove that a helper syncs a directory exactly when it changed one, which no file on disk shows;
535///  the list is per thread, so parallel tests never see each other's syncs.
536///
537/// In TS you'd write (pseudocode):
538/// ```ts
539/// syncedDirectories.push(await realpath(path));
540/// ```
541#[cfg(test)]
542fn record_directory_sync(path: &Path) {
543    let canonical: std::path::PathBuf = std::fs::canonicalize(path).unwrap_or_else(|_| return path.to_path_buf());
544    DIRECTORY_SYNCS.with(|syncs| {
545        syncs.borrow_mut().push(canonical);
546    });
547}
548
549/// What:
550///  Take and clear the directories this thread has synced so far.
551/// Why:
552///   A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
553///
554/// In TS you'd write (pseudocode):
555/// ```ts
556/// const synced = syncedDirectories.splice(0);
557/// ```
558#[cfg(test)]
559pub fn take_directory_syncs() -> Vec<std::path::PathBuf> {
560    return DIRECTORY_SYNCS.with(|syncs| {
561        return std::mem::take(&mut *syncs.borrow_mut());
562    });
563}
564
565/// What:
566///  Remove a directory tree,
567///  treating an already absent tree as removed.
568/// Why:
569///   Retired locks and transactions are removed by name after a rename;
570///  another process
571///       may have removed them first,
572///  which is the same end state (`rm -rf` with `force`).
573///
574/// In TS you'd write (pseudocode):
575/// ```ts
576/// await rm(path, { recursive: true, force: true });
577/// ```
578pub fn remove_tree(path: &Path) -> std::io::Result<()> {
579    match std::fs::remove_dir_all(path) {
580        Ok(()) => return Ok(()),
581        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
582        Err(error) => return Err(error),
583    }
584}
585
586/// What:
587///  Open a file for reading without following a final link.
588/// Why:
589///   Records are always files the wrappers created;
590///  a link planted at a record path must
591///       be refused,
592///  never read through.
593///
594/// In TS you'd write (pseudocode):
595/// ```ts
596/// await open(path, O_RDONLY | O_NOFOLLOW);
597/// ```
598fn open_no_follow(path: &Path) -> std::io::Result<File> {
599    // `mut` allows the builder to be configured step by step.
600    let mut options: OpenOptions = OpenOptions::new();
601    options.read(true);
602    #[cfg(unix)]
603    {
604        // The trait adds `.custom_flags` to open options on Unix.
605        /// The Unix open-option extensions,
606        ///  for the no-follow flag.
607        use std::os::unix::fs::OpenOptionsExt;
608        options.custom_flags(libc::O_NOFOLLOW);
609    }
610    return options.open(path);
611}
612
613/// What:
614///  Read a whole regular file without following links,
615///  up to `limit` bytes.
616///       `u64` is an unsigned 64-bit size;
617///  `Result<Vec<u8>, ReadRefusal>` is "the bytes,
618///  or why
619///       not".
620/// Why:
621///   Recovery reads untrusted state:
622///  a link or a directory must be named as unsafe,
623///  and a
624///       runaway file must not be read into memory.
625///
626/// In TS you'd write (pseudocode):
627/// ```ts
628/// async function readPrivateFile(path: string, limit: number): Promise<Uint8Array>; // throws ReadRefusal
629/// ```
630pub fn read_regular_file(path: &Path, limit: u64) -> Result<Vec<u8>, ReadRefusal> {
631    let file: File = match open_no_follow(path) {
632        Ok(opened) => opened,
633        Err(error) => return Err(open_refusal(error)),
634    };
635    // The handle's own metadata, so a swap between the check and the read is impossible.
636    let metadata: std::fs::Metadata = file.metadata().map_err(ReadRefusal::Io)?;
637    if !metadata.is_file() {
638        return Err(ReadRefusal::NotRegular);
639    }
640    if metadata.len() > limit {
641        return Err(ReadRefusal::TooLarge);
642    }
643    // `.take(limit + 1)` caps the read even if the file grows after the size check.
644    let mut bytes: Vec<u8> = Vec::new();
645    file.take(limit + 1)
646        .read_to_end(&mut bytes)
647        .map_err(ReadRefusal::Io)?;
648    if bytes.len() as u64 > limit {
649        return Err(ReadRefusal::TooLarge);
650    }
651    return Ok(bytes);
652}
653
654/// What:
655///  Classify an open failure as missing,
656///  a refused link,
657///  or another error.
658/// Why:
659///   `O_NOFOLLOW` on a link fails with `ELOOP` on Linux and macOS;
660///  that is the unsafe-link
661///       answer,
662///  not an input-output failure.
663///  A path through a non-directory (`ENOTDIR`) names
664///       no file,
665///  which the incumbent treats like a missing one.
666///
667/// In TS you'd write (pseudocode):
668/// ```ts
669/// function openRefusal(error: NodeJS.ErrnoException): ReadRefusal;
670/// ```
671fn open_refusal(error: std::io::Error) -> ReadRefusal {
672    if error.kind() == std::io::ErrorKind::NotFound {
673        return ReadRefusal::Missing;
674    }
675    #[cfg(unix)]
676    {
677        if error.raw_os_error() == Some(libc::ENOTDIR) {
678            return ReadRefusal::Missing;
679        }
680        if error.raw_os_error() == Some(libc::ELOOP) {
681            return ReadRefusal::NotRegular;
682        }
683    }
684    return ReadRefusal::Io(error);
685}
686
687/// Disposable-directory controls stay out of the release executable.
688#[cfg(test)]
689#[path = "private_storage_tests.rs"]
690mod tests;
691
692/// Publication controls stay out of the release executable.
693#[cfg(test)]
694#[path = "private_storage_publish_tests.rs"]
695mod publish_tests;