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/// `Path` is a borrowed filesystem path of raw bytes.
40use std::path::Path;
41
42/// Mode of every private directory the wrapper creates:
43///  owner only.
44pub const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
45
46/// Mode of every private file the wrapper creates:
47///  owner read and write only.
48pub const PRIVATE_FILE_MODE: u32 = 0o600;
49
50/// What:
51///  Why a no-follow read refused a path.
52///  An `enum` is a closed set of alternatives.
53/// Why:
54///   A missing file is an expected answer for optional records;
55///  a link,
56///  a directory or an
57///       over-long file is unsafe or corrupt state that recovery must name,
58///  never follow.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// type ReadRefusal = { kind: 'missing' } | { kind: 'not-regular' } | { kind: 'too-large' } | { kind: 'io'; error: Error };
63/// ```
64#[derive(Debug)]
65pub enum ReadRefusal {
66    /// Nothing exists at the path.
67    Missing,
68    /// The path names a link,
69    ///  directory or other non-regular file.
70    NotRegular,
71    /// The file holds more bytes than the caller allows.
72    TooLarge,
73    /// The operating system refused the read for another reason.
74    Io(
75        /// The operating-system error that refused the read.
76        std::io::Error,
77    ),
78}
79
80/// What:
81///  Open options for an exclusive,
82///  no-follow,
83///  private-mode creation.
84///       `#[cfg(unix)]` compiles the block only on Unix-like systems.
85/// Why:
86///   `O_EXCL` makes a second writer fail instead of replacing the record;
87///  `O_NOFOLLOW`
88///       refuses a planted final link;
89///  mode `0600` keeps the record private from the start.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600)
94/// ```
95fn exclusive_options() -> OpenOptions {
96    // `mut` allows the builder to be configured step by step.
97    let mut options: OpenOptions = OpenOptions::new();
98    options.write(true).create_new(true);
99    #[cfg(unix)]
100    {
101        // The trait adds `.mode` and `.custom_flags` to open options on Unix.
102        /// The Unix open-option extensions,
103        ///  for the mode and no-follow flags.
104        use std::os::unix::fs::OpenOptionsExt;
105        options
106            .mode(PRIVATE_FILE_MODE)
107            .custom_flags(libc::O_NOFOLLOW);
108    }
109    return options;
110}
111
112/// What:
113///  Set a path's permission bits to `mode`,
114///  on Unix only.
115///       `std::io::Result<()>` is "nothing,
116///  or an operating-system error".
117/// Why:
118///   The creation mode is filtered by the process `umask`;
119///  an explicit change makes the
120///       mode exactly private whatever the caller's `umask` is,
121///  as the incumbent's
122///       `protectPath` does.
123///  Windows keeps its inherited access rules (recorded under
124///       "Choices open to veto" in `doc/handover/cli-git-native-transactions.md`).
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// if (process.platform !== 'win32') await chmod(path, mode);
129/// ```
130pub fn protect_path(path: &Path, mode: u32) -> std::io::Result<()> {
131    #[cfg(unix)]
132    {
133        // The trait adds `.from_mode` to permissions on Unix.
134        /// The Unix permission extensions,
135        ///  for the mode.
136        use std::os::unix::fs::PermissionsExt;
137        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
138    }
139    // `let _ = mode;` marks the argument as used where no mode exists.
140    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
141    #[cfg(not(unix))]
142    let _ = (path, mode);
143    return Ok(());
144}
145
146/// What:
147///  Write one private file exclusively,
148///  refusing links,
149///  then make its bytes durable.
150///       `&[u8]` borrows the exact bytes.
151/// Why:
152///   A journal state file is created once and never rewritten,
153///  so a crash leaves either
154///       no file or a complete one after the caller syncs the directory.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// async function writePrivateFile({ path, bytes }): Promise<void>;
159/// ```
160pub fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
161    let mut file: File = exclusive_options().open(path)?;
162    file.write_all(bytes)?;
163    // `sync_all` flushes data and metadata, the incumbent's `handle.sync()`.
164    file.sync_all()?;
165    return protect_path(path, PRIVATE_FILE_MODE);
166}
167
168/// What:
169///  Write one file exclusively with mode `0600`,
170///  refusing links,
171///  and sync its bytes,
172///       without the explicit mode change.
173/// Why:
174///   Owner-lock candidates are written this way by the incumbent's `writeCandidate`:
175///  the
176///       creation mode alone,
177///  then a sync before publication.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes); await handle.sync();
182/// ```
183pub fn write_exclusive_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
184    let mut file: File = exclusive_options().open(path)?;
185    file.write_all(bytes)?;
186    return file.sync_all();
187}
188
189/// What:
190///  Write one file exclusively with mode `0600`,
191///  refusing links,
192///  without syncing it.
193/// Why:
194///   Scratch bytes that are unlinked right after they are opened for reading are never recovered,
195///  so a sync would only make every caller wait for the disk;
196///  records that a crash must find stay on `write_exclusive_synced`.
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// const handle = await open(path, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600); await handle.writeFile(bytes);
201/// ```
202pub fn write_exclusive_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
203    let mut file: File = exclusive_options().open(path)?;
204    return file.write_all(bytes);
205}
206
207/// What:
208///  Create one directory with mode `0700`;
209///  its parent must exist.
210/// Why:
211///   Lock candidates,
212///  transaction directories and stores are private to the account.
213///       Only the creation mode is applied,
214///  as the incumbent's `mkdir(path, { mode })`.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// await mkdir(path, { mode: 0o700 });
219/// ```
220pub fn create_private_directory(path: &Path) -> std::io::Result<()> {
221    return private_directory_builder().create(path);
222}
223
224/// What:
225///  Create a directory and every missing parent with mode `0700`;
226///  an existing directory is accepted.
227/// Why:
228///   The per-user state directory may not exist yet,
229///  and the XDG rules ask for `0700` when a program creates one.
230///  Only the directories this call creates get the mode;
231///  existing parents keep theirs.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// await mkdir(path, { recursive: true, mode: 0o700 });
236/// ```
237pub fn create_private_directory_all(path: &Path) -> std::io::Result<()> {
238    // `mut` allows the builder to be switched to recursive creation.
239    let mut builder: std::fs::DirBuilder = private_directory_builder();
240    builder.recursive(true);
241    return builder.create(path);
242}
243
244/// What:
245///  The directory builder that creates private directories on this platform.
246/// Why:
247///   Only Unix configures a mode;
248///  a separate function per platform keeps each body free of
249///       a `mut` the other platform would leave unused.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// const builder = { mode: 0o700 };
254/// ```
255#[cfg(unix)]
256fn private_directory_builder() -> std::fs::DirBuilder {
257    // The trait adds `.mode` to directory builders on Unix.
258    /// The Unix directory-builder extensions,
259    ///  for the mode.
260    use std::os::unix::fs::DirBuilderExt;
261    // `mut` allows the builder to be configured before it is returned.
262    let mut builder: std::fs::DirBuilder = std::fs::DirBuilder::new();
263    builder.mode(PRIVATE_DIRECTORY_MODE);
264    return builder;
265}
266
267/// What:
268///  The directory builder that creates private directories on this platform.
269/// Why:
270///   Windows has no mode to configure,
271///  as `private_directory_builder` on Unix explains.
272///
273/// In TS you'd write (pseudocode):
274/// ```ts
275/// const builder = {};
276/// ```
277// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
278#[cfg(not(unix))]
279fn private_directory_builder() -> std::fs::DirBuilder {
280    return std::fs::DirBuilder::new();
281}
282
283/// What:
284///  Make a directory's entries durable,
285///  where the platform supports directory handles.
286/// Why:
287///   A new or renamed entry survives a crash only after its directory is synced;
288///  Windows
289///       has no directory handle for this,
290///  as the incumbent's `syncDirectory` notes.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// async function syncDirectory(path: string): Promise<void>;
295/// ```
296pub fn sync_directory(path: &Path) -> std::io::Result<()> {
297    #[cfg(test)]
298    record_directory_sync(path);
299    #[cfg(unix)]
300    {
301        // Opening a directory read-only and syncing it is the POSIX way to flush its entries.
302        let directory: File = File::open(path)?;
303        directory.sync_all()?;
304    }
305    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
306    #[cfg(not(unix))]
307    let _ = path;
308    return Ok(());
309}
310
311#[cfg(test)]
312thread_local! {
313    /// Canonical paths of the directories this thread asked `sync_directory` to flush, in call order.
314    static DIRECTORY_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
315}
316
317/// What:
318///  Remember that this thread asked for a directory sync.
319/// Why:
320///   Unit tests prove that a helper syncs a directory exactly when it changed one, which no file on disk shows;
321///  the list is per thread, so parallel tests never see each other's syncs.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// syncedDirectories.push(await realpath(path));
326/// ```
327#[cfg(test)]
328fn record_directory_sync(path: &Path) {
329    let canonical: std::path::PathBuf = std::fs::canonicalize(path).unwrap_or_else(|_| return path.to_path_buf());
330    DIRECTORY_SYNCS.with(|syncs| {
331        syncs.borrow_mut().push(canonical);
332    });
333}
334
335/// What:
336///  Take and clear the directories this thread has synced so far.
337/// Why:
338///   A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
339///
340/// In TS you'd write (pseudocode):
341/// ```ts
342/// const synced = syncedDirectories.splice(0);
343/// ```
344#[cfg(test)]
345pub fn take_directory_syncs() -> Vec<std::path::PathBuf> {
346    return DIRECTORY_SYNCS.with(|syncs| {
347        return std::mem::take(&mut *syncs.borrow_mut());
348    });
349}
350
351/// What:
352///  Remove a directory tree,
353///  treating an already absent tree as removed.
354/// Why:
355///   Retired locks and transactions are removed by name after a rename;
356///  another process
357///       may have removed them first,
358///  which is the same end state (`rm -rf` with `force`).
359///
360/// In TS you'd write (pseudocode):
361/// ```ts
362/// await rm(path, { recursive: true, force: true });
363/// ```
364pub fn remove_tree(path: &Path) -> std::io::Result<()> {
365    match std::fs::remove_dir_all(path) {
366        Ok(()) => return Ok(()),
367        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
368        Err(error) => return Err(error),
369    }
370}
371
372/// What:
373///  Open a file for reading without following a final link.
374/// Why:
375///   Records are always files the wrappers created;
376///  a link planted at a record path must
377///       be refused,
378///  never read through.
379///
380/// In TS you'd write (pseudocode):
381/// ```ts
382/// await open(path, O_RDONLY | O_NOFOLLOW);
383/// ```
384fn open_no_follow(path: &Path) -> std::io::Result<File> {
385    // `mut` allows the builder to be configured step by step.
386    let mut options: OpenOptions = OpenOptions::new();
387    options.read(true);
388    #[cfg(unix)]
389    {
390        // The trait adds `.custom_flags` to open options on Unix.
391        /// The Unix open-option extensions,
392        ///  for the no-follow flag.
393        use std::os::unix::fs::OpenOptionsExt;
394        options.custom_flags(libc::O_NOFOLLOW);
395    }
396    return options.open(path);
397}
398
399/// What:
400///  Read a whole regular file without following links,
401///  up to `limit` bytes.
402///       `u64` is an unsigned 64-bit size;
403///  `Result<Vec<u8>, ReadRefusal>` is "the bytes,
404///  or why
405///       not".
406/// Why:
407///   Recovery reads untrusted state:
408///  a link or a directory must be named as unsafe,
409///  and a
410///       runaway file must not be read into memory.
411///
412/// In TS you'd write (pseudocode):
413/// ```ts
414/// async function readPrivateFile(path: string, limit: number): Promise<Uint8Array>; // throws ReadRefusal
415/// ```
416pub fn read_regular_file(path: &Path, limit: u64) -> Result<Vec<u8>, ReadRefusal> {
417    let file: File = match open_no_follow(path) {
418        Ok(opened) => opened,
419        Err(error) => return Err(open_refusal(error)),
420    };
421    // The handle's own metadata, so a swap between the check and the read is impossible.
422    let metadata: std::fs::Metadata = file.metadata().map_err(ReadRefusal::Io)?;
423    if !metadata.is_file() {
424        return Err(ReadRefusal::NotRegular);
425    }
426    if metadata.len() > limit {
427        return Err(ReadRefusal::TooLarge);
428    }
429    // `.take(limit + 1)` caps the read even if the file grows after the size check.
430    let mut bytes: Vec<u8> = Vec::new();
431    file.take(limit + 1)
432        .read_to_end(&mut bytes)
433        .map_err(ReadRefusal::Io)?;
434    if bytes.len() as u64 > limit {
435        return Err(ReadRefusal::TooLarge);
436    }
437    return Ok(bytes);
438}
439
440/// What:
441///  Classify an open failure as missing,
442///  a refused link,
443///  or another error.
444/// Why:
445///   `O_NOFOLLOW` on a link fails with `ELOOP` on Linux and macOS;
446///  that is the unsafe-link
447///       answer,
448///  not an input-output failure.
449///  A path through a non-directory (`ENOTDIR`) names
450///       no file,
451///  which the incumbent treats like a missing one.
452///
453/// In TS you'd write (pseudocode):
454/// ```ts
455/// function openRefusal(error: NodeJS.ErrnoException): ReadRefusal;
456/// ```
457fn open_refusal(error: std::io::Error) -> ReadRefusal {
458    if error.kind() == std::io::ErrorKind::NotFound {
459        return ReadRefusal::Missing;
460    }
461    #[cfg(unix)]
462    {
463        if error.raw_os_error() == Some(libc::ENOTDIR) {
464            return ReadRefusal::Missing;
465        }
466        if error.raw_os_error() == Some(libc::ELOOP) {
467            return ReadRefusal::NotRegular;
468        }
469    }
470    return ReadRefusal::Io(error);
471}
472
473/// Disposable-directory controls stay out of the release executable.
474#[cfg(test)]
475#[path = "private_storage_tests.rs"]
476mod tests;