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/// The directory builder that creates private directories on this platform.
226/// Why:
227/// Only Unix configures a mode;
228/// a separate function per platform keeps each body free of
229/// a `mut` the other platform would leave unused.
230///
231/// In TS you'd write (pseudocode):
232/// ```ts
233/// const builder = { mode: 0o700 };
234/// ```
235#[cfg(unix)]
236fn private_directory_builder() -> std::fs::DirBuilder {
237 // The trait adds `.mode` to directory builders on Unix.
238 /// The Unix directory-builder extensions,
239 /// for the mode.
240 use std::os::unix::fs::DirBuilderExt;
241 // `mut` allows the builder to be configured before it is returned.
242 let mut builder: std::fs::DirBuilder = std::fs::DirBuilder::new();
243 builder.mode(PRIVATE_DIRECTORY_MODE);
244 return builder;
245}
246
247/// What:
248/// The directory builder that creates private directories on this platform.
249/// Why:
250/// Windows has no mode to configure,
251/// as `private_directory_builder` on Unix explains.
252///
253/// In TS you'd write (pseudocode):
254/// ```ts
255/// const builder = {};
256/// ```
257// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
258#[cfg(not(unix))]
259fn private_directory_builder() -> std::fs::DirBuilder {
260 return std::fs::DirBuilder::new();
261}
262
263/// What:
264/// Make a directory's entries durable,
265/// where the platform supports directory handles.
266/// Why:
267/// A new or renamed entry survives a crash only after its directory is synced;
268/// Windows
269/// has no directory handle for this,
270/// as the incumbent's `syncDirectory` notes.
271///
272/// In TS you'd write (pseudocode):
273/// ```ts
274/// async function syncDirectory(path: string): Promise<void>;
275/// ```
276pub fn sync_directory(path: &Path) -> std::io::Result<()> {
277 #[cfg(test)]
278 record_directory_sync(path);
279 #[cfg(unix)]
280 {
281 // Opening a directory read-only and syncing it is the POSIX way to flush its entries.
282 let directory: File = File::open(path)?;
283 directory.sync_all()?;
284 }
285 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
286 #[cfg(not(unix))]
287 let _ = path;
288 return Ok(());
289}
290
291#[cfg(test)]
292thread_local! {
293 /// Canonical paths of the directories this thread asked `sync_directory` to flush, in call order.
294 static DIRECTORY_SYNCS: std::cell::RefCell<Vec<std::path::PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
295}
296
297/// What:
298/// Remember that this thread asked for a directory sync.
299/// Why:
300/// Unit tests prove that a helper syncs a directory exactly when it changed one, which no file on disk shows;
301/// the list is per thread, so parallel tests never see each other's syncs.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// syncedDirectories.push(await realpath(path));
306/// ```
307#[cfg(test)]
308fn record_directory_sync(path: &Path) {
309 let canonical: std::path::PathBuf = std::fs::canonicalize(path).unwrap_or_else(|_| return path.to_path_buf());
310 DIRECTORY_SYNCS.with(|syncs| {
311 syncs.borrow_mut().push(canonical);
312 });
313}
314
315/// What:
316/// Take and clear the directories this thread has synced so far.
317/// Why:
318/// A test calls it before the step under test to drop earlier syncs, and after it to read exactly that step's syncs.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// const synced = syncedDirectories.splice(0);
323/// ```
324#[cfg(test)]
325pub fn take_directory_syncs() -> Vec<std::path::PathBuf> {
326 return DIRECTORY_SYNCS.with(|syncs| {
327 return std::mem::take(&mut *syncs.borrow_mut());
328 });
329}
330
331/// What:
332/// Remove a directory tree,
333/// treating an already absent tree as removed.
334/// Why:
335/// Retired locks and transactions are removed by name after a rename;
336/// another process
337/// may have removed them first,
338/// which is the same end state (`rm -rf` with `force`).
339///
340/// In TS you'd write (pseudocode):
341/// ```ts
342/// await rm(path, { recursive: true, force: true });
343/// ```
344pub fn remove_tree(path: &Path) -> std::io::Result<()> {
345 match std::fs::remove_dir_all(path) {
346 Ok(()) => return Ok(()),
347 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
348 Err(error) => return Err(error),
349 }
350}
351
352/// What:
353/// Open a file for reading without following a final link.
354/// Why:
355/// Records are always files the wrappers created;
356/// a link planted at a record path must
357/// be refused,
358/// never read through.
359///
360/// In TS you'd write (pseudocode):
361/// ```ts
362/// await open(path, O_RDONLY | O_NOFOLLOW);
363/// ```
364fn open_no_follow(path: &Path) -> std::io::Result<File> {
365 // `mut` allows the builder to be configured step by step.
366 let mut options: OpenOptions = OpenOptions::new();
367 options.read(true);
368 #[cfg(unix)]
369 {
370 // The trait adds `.custom_flags` to open options on Unix.
371 /// The Unix open-option extensions,
372 /// for the no-follow flag.
373 use std::os::unix::fs::OpenOptionsExt;
374 options.custom_flags(libc::O_NOFOLLOW);
375 }
376 return options.open(path);
377}
378
379/// What:
380/// Read a whole regular file without following links,
381/// up to `limit` bytes.
382/// `u64` is an unsigned 64-bit size;
383/// `Result<Vec<u8>, ReadRefusal>` is "the bytes,
384/// or why
385/// not".
386/// Why:
387/// Recovery reads untrusted state:
388/// a link or a directory must be named as unsafe,
389/// and a
390/// runaway file must not be read into memory.
391///
392/// In TS you'd write (pseudocode):
393/// ```ts
394/// async function readPrivateFile(path: string, limit: number): Promise<Uint8Array>; // throws ReadRefusal
395/// ```
396pub fn read_regular_file(path: &Path, limit: u64) -> Result<Vec<u8>, ReadRefusal> {
397 let file: File = match open_no_follow(path) {
398 Ok(opened) => opened,
399 Err(error) => return Err(open_refusal(error)),
400 };
401 // The handle's own metadata, so a swap between the check and the read is impossible.
402 let metadata: std::fs::Metadata = file.metadata().map_err(ReadRefusal::Io)?;
403 if !metadata.is_file() {
404 return Err(ReadRefusal::NotRegular);
405 }
406 if metadata.len() > limit {
407 return Err(ReadRefusal::TooLarge);
408 }
409 // `.take(limit + 1)` caps the read even if the file grows after the size check.
410 let mut bytes: Vec<u8> = Vec::new();
411 file.take(limit + 1)
412 .read_to_end(&mut bytes)
413 .map_err(ReadRefusal::Io)?;
414 if bytes.len() as u64 > limit {
415 return Err(ReadRefusal::TooLarge);
416 }
417 return Ok(bytes);
418}
419
420/// What:
421/// Classify an open failure as missing,
422/// a refused link,
423/// or another error.
424/// Why:
425/// `O_NOFOLLOW` on a link fails with `ELOOP` on Linux and macOS;
426/// that is the unsafe-link
427/// answer,
428/// not an input-output failure.
429/// A path through a non-directory (`ENOTDIR`) names
430/// no file,
431/// which the incumbent treats like a missing one.
432///
433/// In TS you'd write (pseudocode):
434/// ```ts
435/// function openRefusal(error: NodeJS.ErrnoException): ReadRefusal;
436/// ```
437fn open_refusal(error: std::io::Error) -> ReadRefusal {
438 if error.kind() == std::io::ErrorKind::NotFound {
439 return ReadRefusal::Missing;
440 }
441 #[cfg(unix)]
442 {
443 if error.raw_os_error() == Some(libc::ENOTDIR) {
444 return ReadRefusal::Missing;
445 }
446 if error.raw_os_error() == Some(libc::ELOOP) {
447 return ReadRefusal::NotRegular;
448 }
449 }
450 return ReadRefusal::Io(error);
451}
452
453/// Disposable-directory controls stay out of the release executable.
454#[cfg(test)]
455#[path = "private_storage_tests.rs"]
456mod tests;