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;