Skip to main content

git_policy_cli/
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 super::diagnostic_log::debug;
45/// Unguessable staging names.
46use super::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` and publish it whole,
324///  without syncing its bytes.
325/// Why:
326///   The unsynced counterpart of `write_exclusive_synced`, for records whose torn state recovery treats as benign.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// await writeExclusive({ path, bytes, sync: false });
331/// ```
332pub fn write_exclusive_unsynced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
333    return write_then_publish(path, bytes, None, false);
334}
335
336/// What:
337///  Write one file exclusively with mode `0600`,
338///  refusing links,
339///  without syncing it.
340/// Why:
341///   Scratch bytes that are unlinked right after they are opened for reading are never recovered,
342///  so a sync would only make every caller wait for the disk;
343///  records that a crash must find stay on `write_exclusive_synced`.
344///
345/// In TS you'd write (pseudocode):
346/// ```ts
347/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes);
348/// ```
349pub fn write_exclusive_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
350    let mut file: File = exclusive_options().open(path)?;
351    return file.write_all(bytes);
352}
353
354/// What:
355///  Create one directory with mode `0700`;
356///  its parent must exist.
357/// Why:
358///   Lock candidates,
359///  transaction directories and stores are private to the account.
360///       Only the creation mode is applied,
361///  as the incumbent's `mkdir(path, { mode })`.
362///
363/// In TS you'd write (pseudocode):
364/// ```ts
365/// await mkdir(path, { mode: 0o700 });
366/// ```
367pub fn create_private_directory(path: &Path) -> std::io::Result<()> {
368    return private_directory_builder().create(path);
369}
370
371/// What:
372///  Create a directory and every missing parent with mode `0700`;
373///  an existing directory is accepted.
374/// Why:
375///   The per-user state directory may not exist yet,
376///  and the XDG rules ask for `0700` when a program creates one.
377///  Only the directories this call creates get the mode;
378///  existing parents keep theirs.
379///
380/// In TS you'd write (pseudocode):
381/// ```ts
382/// await mkdir(path, { recursive: true, mode: 0o700 });
383/// ```
384pub fn create_private_directory_all(path: &Path) -> std::io::Result<()> {
385    // `mut` allows the builder to be switched to recursive creation.
386    let mut builder: std::fs::DirBuilder = private_directory_builder();
387    builder.recursive(true);
388    return builder.create(path);
389}
390
391/// What:
392///  The directory builder that creates private directories on this platform.
393/// Why:
394///   Only Unix configures a mode;
395///  a separate function per platform keeps each body free of
396///       a `mut` the other platform would leave unused.
397///
398/// In TS you'd write (pseudocode):
399/// ```ts
400/// const builder = { mode: 0o700 };
401/// ```
402#[cfg(unix)]
403fn private_directory_builder() -> std::fs::DirBuilder {
404    // The trait adds `.mode` to directory builders on Unix.
405    /// The Unix directory-builder extensions,
406    ///  for the mode.
407    use std::os::unix::fs::DirBuilderExt;
408    // `mut` allows the builder to be configured before it is returned.
409    let mut builder: std::fs::DirBuilder = std::fs::DirBuilder::new();
410    builder.mode(PRIVATE_DIRECTORY_MODE);
411    return builder;
412}
413
414/// What:
415///  The directory builder that creates private directories on this platform.
416/// Why:
417///   Windows has no mode to configure,
418///  as `private_directory_builder` on Unix explains.
419///
420/// In TS you'd write (pseudocode):
421/// ```ts
422/// const builder = {};
423/// ```
424// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
425#[cfg(not(unix))]
426fn private_directory_builder() -> std::fs::DirBuilder {
427    return std::fs::DirBuilder::new();
428}
429
430/// What:
431///  Whether a failed directory sync means the filesystem has no such operation, not that the flush failed.
432/// Why:
433///   `EINVAL`, `ENOTSUP` and `ENOSYS` are what a filesystem without the operation returns; `EIO`, `ENOSPC` and
434///  `EDQUOT` mean the flush itself failed and stay errors.
435///
436/// In TS you'd write (pseudocode):
437/// ```ts
438/// ['EINVAL', 'ENOTSUP', 'ENOSYS'].includes(error.code)
439/// ```
440#[cfg(unix)]
441fn directory_sync_is_unsupported(error: &std::io::Error) -> bool {
442    return matches!(error.raw_os_error(), Some(libc::EINVAL | libc::ENOTSUP | libc::ENOSYS));
443}
444
445/// What:
446///  The outcome of syncing the directory at `path`: a filesystem without the operation counts as synced, any other
447///  failure stays an error.
448/// Why:
449///   A filesystem with no directory flush (procfs, some network and userspace filesystems) answers with one of the
450///  unsupported codes; the entry cannot be made durable there by any means, and failing would stop every commit. A
451///  function of the sync result lets a test feed the flush failures (`EIO`, `ENOSPC`, `EDQUOT`) that no
452///  unprivileged filesystem produces.
453///
454/// In TS you'd write (pseudocode):
455/// ```ts
456/// if (error !== undefined && !['EINVAL', 'ENOTSUP', 'ENOSYS'].includes(error.code)) throw error;
457/// ```
458#[cfg(unix)]
459fn settle_directory_sync(path: &Path, outcome: std::io::Result<()>) -> std::io::Result<()> {
460    return match outcome {
461        Ok(()) => Ok(()),
462        Err(error) if directory_sync_is_unsupported(&error) => {
463            debug(
464                "syncDirectory",
465                format!("the filesystem of {} cannot sync a directory ({error}); skipped", path.display()).as_str(),
466            );
467            Ok(())
468        }
469        Err(error) => Err(error),
470    };
471}
472
473/// What:
474///  Make a directory's entries durable,
475///  where the platform supports directory handles.
476/// Why:
477///   A new or renamed entry survives a crash only after its directory is synced;
478///  Windows
479///       has no directory handle for this,
480///  as the incumbent's `syncDirectory` notes.
481///
482/// In TS you'd write (pseudocode):
483/// ```ts
484/// async function syncDirectory(path: string): Promise<void>;
485/// ```
486pub fn sync_directory(path: &Path) -> std::io::Result<()> {
487    #[cfg(test)]
488    record_directory_sync(path);
489    #[cfg(unix)]
490    {
491        // Opening a directory read-only and syncing it is the POSIX way to flush its entries.
492        let directory: File = File::open(path)?;
493        settle_directory_sync(path, directory.sync_all())?;
494    }
495    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
496    #[cfg(not(unix))]
497    let _ = path;
498    return Ok(());
499}
500
501#[cfg(test)]
502thread_local! {
503    /// Paths of the files this thread asked `write_then_publish` to sync, in call order.
504    static FILE_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
505}
506
507/// What:
508///  Remember that this thread synced a file it published at `path`.
509/// Why:
510///   Unit tests pin which records keep their file sync, which no file on disk shows.
511///
512/// In TS you'd write (pseudocode):
513/// ```ts
514/// syncedFiles.push(path);
515/// ```
516#[cfg(test)]
517fn record_file_sync(path: &Path) {
518    FILE_SYNCS.with(|syncs| {
519        syncs.borrow_mut().push(path.to_path_buf());
520    });
521}
522
523/// What:
524///  Take and clear the files this thread has synced so far.
525/// Why:
526///   A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
527///
528/// In TS you'd write (pseudocode):
529/// ```ts
530/// const synced = syncedFiles.splice(0);
531/// ```
532#[cfg(test)]
533pub fn take_file_syncs() -> Vec<std::path::PathBuf> {
534    return FILE_SYNCS.with(|syncs| {
535        return std::mem::take(&mut *syncs.borrow_mut());
536    });
537}
538
539#[cfg(test)]
540thread_local! {
541    /// Canonical paths of the directories this thread asked `sync_directory` to flush, in call order.
542    static DIRECTORY_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
543}
544
545/// What:
546///  Remember that this thread asked for a directory sync.
547/// Why:
548///   Unit tests prove that a helper syncs a directory exactly when it changed one, which no file on disk shows;
549///  the list is per thread, so parallel tests never see each other's syncs.
550///
551/// In TS you'd write (pseudocode):
552/// ```ts
553/// syncedDirectories.push(await realpath(path));
554/// ```
555#[cfg(test)]
556fn record_directory_sync(path: &Path) {
557    let canonical: std::path::PathBuf = std::fs::canonicalize(path).unwrap_or_else(|_| return path.to_path_buf());
558    DIRECTORY_SYNCS.with(|syncs| {
559        syncs.borrow_mut().push(canonical);
560    });
561}
562
563/// What:
564///  Take and clear the directories this thread has synced so far.
565/// Why:
566///   A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
567///
568/// In TS you'd write (pseudocode):
569/// ```ts
570/// const synced = syncedDirectories.splice(0);
571/// ```
572#[cfg(test)]
573pub fn take_directory_syncs() -> Vec<std::path::PathBuf> {
574    return DIRECTORY_SYNCS.with(|syncs| {
575        return std::mem::take(&mut *syncs.borrow_mut());
576    });
577}
578
579/// What:
580///  Remove a directory tree,
581///  treating an already absent tree as removed.
582/// Why:
583///   Retired locks and transactions are removed by name after a rename;
584///  another process
585///       may have removed them first,
586///  which is the same end state (`rm -rf` with `force`).
587///
588/// In TS you'd write (pseudocode):
589/// ```ts
590/// await rm(path, { recursive: true, force: true });
591/// ```
592pub fn remove_tree(path: &Path) -> std::io::Result<()> {
593    match std::fs::remove_dir_all(path) {
594        Ok(()) => return Ok(()),
595        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
596        Err(error) => return Err(error),
597    }
598}
599
600/// What:
601///  Open a file for reading without following a final link.
602/// Why:
603///   Records are always files the wrappers created;
604///  a link planted at a record path must
605///       be refused,
606///  never read through.
607///
608/// In TS you'd write (pseudocode):
609/// ```ts
610/// await open(path, O_RDONLY | O_NOFOLLOW);
611/// ```
612fn open_no_follow(path: &Path) -> std::io::Result<File> {
613    // `mut` allows the builder to be configured step by step.
614    let mut options: OpenOptions = OpenOptions::new();
615    options.read(true);
616    #[cfg(unix)]
617    {
618        // The trait adds `.custom_flags` to open options on Unix.
619        /// The Unix open-option extensions,
620        ///  for the no-follow flag.
621        use std::os::unix::fs::OpenOptionsExt;
622        options.custom_flags(libc::O_NOFOLLOW);
623    }
624    return options.open(path);
625}
626
627/// What:
628///  Read a whole regular file without following links,
629///  up to `limit` bytes.
630///       `u64` is an unsigned 64-bit size;
631///  `Result<Vec<u8>, ReadRefusal>` is "the bytes,
632///  or why
633///       not".
634/// Why:
635///   Recovery reads untrusted state:
636///  a link or a directory must be named as unsafe,
637///  and a
638///       runaway file must not be read into memory.
639///
640/// In TS you'd write (pseudocode):
641/// ```ts
642/// async function readPrivateFile(path: string, limit: number): Promise<Uint8Array>; // throws ReadRefusal
643/// ```
644pub fn read_regular_file(path: &Path, limit: u64) -> Result<Vec<u8>, ReadRefusal> {
645    let file: File = match open_no_follow(path) {
646        Ok(opened) => opened,
647        Err(error) => return Err(open_refusal(error)),
648    };
649    // The handle's own metadata, so a swap between the check and the read is impossible.
650    let metadata: std::fs::Metadata = file.metadata().map_err(ReadRefusal::Io)?;
651    if !metadata.is_file() {
652        return Err(ReadRefusal::NotRegular);
653    }
654    if metadata.len() > limit {
655        return Err(ReadRefusal::TooLarge);
656    }
657    // `.take(limit + 1)` caps the read even if the file grows after the size check.
658    let mut bytes: Vec<u8> = Vec::new();
659    file.take(limit + 1)
660        .read_to_end(&mut bytes)
661        .map_err(ReadRefusal::Io)?;
662    if bytes.len() as u64 > limit {
663        return Err(ReadRefusal::TooLarge);
664    }
665    return Ok(bytes);
666}
667
668/// What:
669///  Classify an open failure as missing,
670///  a refused link,
671///  or another error.
672/// Why:
673///   `O_NOFOLLOW` on a link fails with `ELOOP` on Linux and macOS;
674///  that is the unsafe-link
675///       answer,
676///  not an input-output failure.
677///  A path through a non-directory (`ENOTDIR`) names
678///       no file,
679///  which the incumbent treats like a missing one.
680///
681/// In TS you'd write (pseudocode):
682/// ```ts
683/// function openRefusal(error: NodeJS.ErrnoException): ReadRefusal;
684/// ```
685fn open_refusal(error: std::io::Error) -> ReadRefusal {
686    if error.kind() == std::io::ErrorKind::NotFound {
687        return ReadRefusal::Missing;
688    }
689    #[cfg(unix)]
690    {
691        if error.raw_os_error() == Some(libc::ENOTDIR) {
692            return ReadRefusal::Missing;
693        }
694        if error.raw_os_error() == Some(libc::ELOOP) {
695            return ReadRefusal::NotRegular;
696        }
697    }
698    return ReadRefusal::Io(error);
699}
700
701/// Disposable-directory controls stay out of the release executable.
702#[cfg(test)]
703#[path = "private_storage_tests.rs"]
704mod tests;
705
706/// Publication controls stay out of the release executable.
707#[cfg(test)]
708#[path = "private_storage_publish_tests.rs"]
709mod publish_tests;