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, and publish it at `path`; any failure removes the staging
207///  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, synced file by link makes the record appear whole or not at all.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// const staged = stagingPath(path); try { await writeAndSync(staged, bytes); await publishExclusive(staged, path); } finally { await rm(staged, { force: true }); }
217/// ```
218fn write_then_publish(path: &Path, bytes: &[u8], mode: Option<u32>) -> std::io::Result<()> {
219    let staged: PathBuf = staging_path(path)?;
220    let mut file: File = exclusive_options().open(staged.as_path())?;
221    // `sync_all` flushes data and metadata, the incumbent's `handle.sync()`.
222    let filled: std::io::Result<()> = file.write_all(bytes).and_then(|()| { return file.sync_all(); });
223    drop(file);
224    let published: std::io::Result<()> = filled
225        .and_then(|()| {
226            return match mode {
227                Some(bits) => protect_path(staged.as_path(), bits),
228                None => Ok(()),
229            };
230        })
231        .and_then(|()| { return publish_exclusive(staged.as_path(), path); });
232    match std::fs::remove_file(staged.as_path()) {
233        Ok(()) => {}
234        // The rename fallback already moved it.
235        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
236        Err(error) => {
237            debug(
238                "writeThenPublish",
239                format!("removing the staging file {} failed: {error}", staged.display()).as_str(),
240            );
241        }
242    }
243    return published;
244}
245
246/// What:
247///  Write one private file exclusively,
248///  refusing links,
249///  then make its bytes durable.
250///       `&[u8]` borrows the exact bytes.
251/// Why:
252///   A journal state file is created once and never rewritten,
253///  so a crash leaves either
254///       no file or a complete one after the caller syncs the directory.
255///  The file is written under a staging name and published whole (`write_then_publish`), so that the promise
256///  also holds for a kill between the creation and the write and for a write cut short by a full disk.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// async function writePrivateFile({ path, bytes }): Promise<void>;
261/// ```
262pub fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
263    return write_then_publish(path, bytes, Some(PRIVATE_FILE_MODE));
264}
265
266/// What:
267///  Write one file exclusively with mode `0600`,
268///  refusing links,
269///  and sync its bytes,
270///       without the explicit mode change.
271/// Why:
272///   Owner-lock candidates are written this way by the incumbent's `writeCandidate`:
273///  the
274///       creation mode alone,
275///  then a sync before publication.
276///  The file is published whole, as `write_private_file` does.
277///
278/// In TS you'd write (pseudocode):
279/// ```ts
280/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes); await handle.sync();
281/// ```
282pub fn write_exclusive_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
283    return write_then_publish(path, bytes, None);
284}
285
286/// What:
287///  Write one file exclusively with mode `0600`,
288///  refusing links,
289///  without syncing it.
290/// Why:
291///   Scratch bytes that are unlinked right after they are opened for reading are never recovered,
292///  so a sync would only make every caller wait for the disk;
293///  records that a crash must find stay on `write_exclusive_synced`.
294///
295/// In TS you'd write (pseudocode):
296/// ```ts
297/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes);
298/// ```
299pub fn write_exclusive_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
300    let mut file: File = exclusive_options().open(path)?;
301    return file.write_all(bytes);
302}
303
304/// What:
305///  Create one directory with mode `0700`;
306///  its parent must exist.
307/// Why:
308///   Lock candidates,
309///  transaction directories and stores are private to the account.
310///       Only the creation mode is applied,
311///  as the incumbent's `mkdir(path, { mode })`.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// await mkdir(path, { mode: 0o700 });
316/// ```
317pub fn create_private_directory(path: &Path) -> std::io::Result<()> {
318    return private_directory_builder().create(path);
319}
320
321/// What:
322///  Create a directory and every missing parent with mode `0700`;
323///  an existing directory is accepted.
324/// Why:
325///   The per-user state directory may not exist yet,
326///  and the XDG rules ask for `0700` when a program creates one.
327///  Only the directories this call creates get the mode;
328///  existing parents keep theirs.
329///
330/// In TS you'd write (pseudocode):
331/// ```ts
332/// await mkdir(path, { recursive: true, mode: 0o700 });
333/// ```
334pub fn create_private_directory_all(path: &Path) -> std::io::Result<()> {
335    // `mut` allows the builder to be switched to recursive creation.
336    let mut builder: std::fs::DirBuilder = private_directory_builder();
337    builder.recursive(true);
338    return builder.create(path);
339}
340
341/// What:
342///  The directory builder that creates private directories on this platform.
343/// Why:
344///   Only Unix configures a mode;
345///  a separate function per platform keeps each body free of
346///       a `mut` the other platform would leave unused.
347///
348/// In TS you'd write (pseudocode):
349/// ```ts
350/// const builder = { mode: 0o700 };
351/// ```
352#[cfg(unix)]
353fn private_directory_builder() -> std::fs::DirBuilder {
354    // The trait adds `.mode` to directory builders on Unix.
355    /// The Unix directory-builder extensions,
356    ///  for the mode.
357    use std::os::unix::fs::DirBuilderExt;
358    // `mut` allows the builder to be configured before it is returned.
359    let mut builder: std::fs::DirBuilder = std::fs::DirBuilder::new();
360    builder.mode(PRIVATE_DIRECTORY_MODE);
361    return builder;
362}
363
364/// What:
365///  The directory builder that creates private directories on this platform.
366/// Why:
367///   Windows has no mode to configure,
368///  as `private_directory_builder` on Unix explains.
369///
370/// In TS you'd write (pseudocode):
371/// ```ts
372/// const builder = {};
373/// ```
374// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
375#[cfg(not(unix))]
376fn private_directory_builder() -> std::fs::DirBuilder {
377    return std::fs::DirBuilder::new();
378}
379
380/// What:
381///  Whether a failed directory sync means the filesystem has no such operation, not that the flush failed.
382/// Why:
383///   `EINVAL`, `ENOTSUP` and `ENOSYS` are what a filesystem without the operation returns; `EIO`, `ENOSPC` and
384///  `EDQUOT` mean the flush itself failed and stay errors.
385///
386/// In TS you'd write (pseudocode):
387/// ```ts
388/// ['EINVAL', 'ENOTSUP', 'ENOSYS'].includes(error.code)
389/// ```
390#[cfg(unix)]
391fn directory_sync_is_unsupported(error: &std::io::Error) -> bool {
392    return matches!(error.raw_os_error(), Some(libc::EINVAL | libc::ENOTSUP | libc::ENOSYS));
393}
394
395/// What:
396///  Make a directory's entries durable,
397///  where the platform supports directory handles.
398/// Why:
399///   A new or renamed entry survives a crash only after its directory is synced;
400///  Windows
401///       has no directory handle for this,
402///  as the incumbent's `syncDirectory` notes.
403///
404/// In TS you'd write (pseudocode):
405/// ```ts
406/// async function syncDirectory(path: string): Promise<void>;
407/// ```
408pub fn sync_directory(path: &Path) -> std::io::Result<()> {
409    #[cfg(test)]
410    record_directory_sync(path);
411    #[cfg(unix)]
412    {
413        // Opening a directory read-only and syncing it is the POSIX way to flush its entries.
414        let directory: File = File::open(path)?;
415        match directory.sync_all() {
416            Ok(()) => {}
417            // A filesystem with no directory flush (procfs, some network and userspace filesystems) answers with one
418            // of these; the entry cannot be made durable there by any means, and failing would stop every commit.
419            Err(error) if directory_sync_is_unsupported(&error) => {
420                debug(
421                    "syncDirectory",
422                    format!("the filesystem of {} cannot sync a directory ({error}); skipped", path.display()).as_str(),
423                );
424            }
425            Err(error) => return Err(error),
426        }
427    }
428    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
429    #[cfg(not(unix))]
430    let _ = path;
431    return Ok(());
432}
433
434#[cfg(test)]
435thread_local! {
436    /// Canonical paths of the directories this thread asked `sync_directory` to flush, in call order.
437    static DIRECTORY_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
438}
439
440/// What:
441///  Remember that this thread asked for a directory sync.
442/// Why:
443///   Unit tests prove that a helper syncs a directory exactly when it changed one, which no file on disk shows;
444///  the list is per thread, so parallel tests never see each other's syncs.
445///
446/// In TS you'd write (pseudocode):
447/// ```ts
448/// syncedDirectories.push(await realpath(path));
449/// ```
450#[cfg(test)]
451fn record_directory_sync(path: &Path) {
452    let canonical: std::path::PathBuf = std::fs::canonicalize(path).unwrap_or_else(|_| return path.to_path_buf());
453    DIRECTORY_SYNCS.with(|syncs| {
454        syncs.borrow_mut().push(canonical);
455    });
456}
457
458/// What:
459///  Take and clear the directories this thread has synced so far.
460/// Why:
461///   A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
462///
463/// In TS you'd write (pseudocode):
464/// ```ts
465/// const synced = syncedDirectories.splice(0);
466/// ```
467#[cfg(test)]
468pub fn take_directory_syncs() -> Vec<std::path::PathBuf> {
469    return DIRECTORY_SYNCS.with(|syncs| {
470        return std::mem::take(&mut *syncs.borrow_mut());
471    });
472}
473
474/// What:
475///  Remove a directory tree,
476///  treating an already absent tree as removed.
477/// Why:
478///   Retired locks and transactions are removed by name after a rename;
479///  another process
480///       may have removed them first,
481///  which is the same end state (`rm -rf` with `force`).
482///
483/// In TS you'd write (pseudocode):
484/// ```ts
485/// await rm(path, { recursive: true, force: true });
486/// ```
487pub fn remove_tree(path: &Path) -> std::io::Result<()> {
488    match std::fs::remove_dir_all(path) {
489        Ok(()) => return Ok(()),
490        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
491        Err(error) => return Err(error),
492    }
493}
494
495/// What:
496///  Open a file for reading without following a final link.
497/// Why:
498///   Records are always files the wrappers created;
499///  a link planted at a record path must
500///       be refused,
501///  never read through.
502///
503/// In TS you'd write (pseudocode):
504/// ```ts
505/// await open(path, O_RDONLY | O_NOFOLLOW);
506/// ```
507fn open_no_follow(path: &Path) -> std::io::Result<File> {
508    // `mut` allows the builder to be configured step by step.
509    let mut options: OpenOptions = OpenOptions::new();
510    options.read(true);
511    #[cfg(unix)]
512    {
513        // The trait adds `.custom_flags` to open options on Unix.
514        /// The Unix open-option extensions,
515        ///  for the no-follow flag.
516        use std::os::unix::fs::OpenOptionsExt;
517        options.custom_flags(libc::O_NOFOLLOW);
518    }
519    return options.open(path);
520}
521
522/// What:
523///  Read a whole regular file without following links,
524///  up to `limit` bytes.
525///       `u64` is an unsigned 64-bit size;
526///  `Result<Vec<u8>, ReadRefusal>` is "the bytes,
527///  or why
528///       not".
529/// Why:
530///   Recovery reads untrusted state:
531///  a link or a directory must be named as unsafe,
532///  and a
533///       runaway file must not be read into memory.
534///
535/// In TS you'd write (pseudocode):
536/// ```ts
537/// async function readPrivateFile(path: string, limit: number): Promise<Uint8Array>; // throws ReadRefusal
538/// ```
539pub fn read_regular_file(path: &Path, limit: u64) -> Result<Vec<u8>, ReadRefusal> {
540    let file: File = match open_no_follow(path) {
541        Ok(opened) => opened,
542        Err(error) => return Err(open_refusal(error)),
543    };
544    // The handle's own metadata, so a swap between the check and the read is impossible.
545    let metadata: std::fs::Metadata = file.metadata().map_err(ReadRefusal::Io)?;
546    if !metadata.is_file() {
547        return Err(ReadRefusal::NotRegular);
548    }
549    if metadata.len() > limit {
550        return Err(ReadRefusal::TooLarge);
551    }
552    // `.take(limit + 1)` caps the read even if the file grows after the size check.
553    let mut bytes: Vec<u8> = Vec::new();
554    file.take(limit + 1)
555        .read_to_end(&mut bytes)
556        .map_err(ReadRefusal::Io)?;
557    if bytes.len() as u64 > limit {
558        return Err(ReadRefusal::TooLarge);
559    }
560    return Ok(bytes);
561}
562
563/// What:
564///  Classify an open failure as missing,
565///  a refused link,
566///  or another error.
567/// Why:
568///   `O_NOFOLLOW` on a link fails with `ELOOP` on Linux and macOS;
569///  that is the unsafe-link
570///       answer,
571///  not an input-output failure.
572///  A path through a non-directory (`ENOTDIR`) names
573///       no file,
574///  which the incumbent treats like a missing one.
575///
576/// In TS you'd write (pseudocode):
577/// ```ts
578/// function openRefusal(error: NodeJS.ErrnoException): ReadRefusal;
579/// ```
580fn open_refusal(error: std::io::Error) -> ReadRefusal {
581    if error.kind() == std::io::ErrorKind::NotFound {
582        return ReadRefusal::Missing;
583    }
584    #[cfg(unix)]
585    {
586        if error.raw_os_error() == Some(libc::ENOTDIR) {
587            return ReadRefusal::Missing;
588        }
589        if error.raw_os_error() == Some(libc::ELOOP) {
590            return ReadRefusal::NotRegular;
591        }
592    }
593    return ReadRefusal::Io(error);
594}
595
596/// Disposable-directory controls stay out of the release executable.
597#[cfg(test)]
598#[path = "private_storage_tests.rs"]
599mod tests;