hotl_platform/privatefs/mod.rs
1//! [`PrivateFs`] — filesystem objects only the current user can read.
2
3use std::fs::File;
4use std::io;
5use std::path::Path;
6
7#[cfg(unix)]
8mod unix;
9#[cfg(unix)]
10pub use unix::UnixPrivateFs;
11#[cfg(unix)]
12pub type ActivePrivateFs = UnixPrivateFs;
13
14#[cfg(windows)]
15mod windows;
16#[cfg(windows)]
17pub(crate) use windows::owner_only_attributes;
18#[cfg(windows)]
19pub use windows::WindowsPrivateFs;
20#[cfg(windows)]
21pub type ActivePrivateFs = WindowsPrivateFs;
22
23/// Create filesystem objects only the current user can read.
24///
25/// CONTRACT (all implementors): the restriction is applied **at create**, never
26/// create-then-tighten. The session log is the most sensitive artifact hotl
27/// writes, and a create-then-chmod window is a real read window.
28/// [`create_file_new`](PrivateFs::create_file_new) is `O_EXCL`-shaped: it fails
29/// if the path exists, and it never truncates.
30///
31/// NOT EQUAL ACROSS PLATFORMS, by construction. `0600` excludes root only until
32/// root chooses otherwise; a Windows DACL excludes local Administrators only
33/// until they use `SeTakeOwnershipPrivilege`/`SeBackupPrivilege`. Comparable,
34/// not identical. Two Windows-only caveats have no Unix analogue: a roaming or
35/// redirected `%APPDATA%` on an SMB share defeats the DACL entirely, and there
36/// is no umask, so a *pre-existing* directory keeps whatever it had.
37/// [`effective_access`](PrivateFs::effective_access) exists so callers can check
38/// rather than assume — never certify a mechanism you did not observe working.
39pub trait PrivateFs: crate::sealed::Sealed {
40 /// Create a directory readable only by the current user. Succeeds quietly
41 /// if it already exists **and already excludes everyone else**; otherwise
42 /// it tightens, because an inherited-permissions directory is the exact
43 /// case `harden_existing` exists for.
44 fn create_dir(&self, path: &Path) -> io::Result<()>;
45
46 /// Create `path` and any missing ancestors.
47 ///
48 /// CONTRACT: **only the components this call creates are made private.** A
49 /// component that already exists is left exactly as it is, and that is not
50 /// laziness — the ancestors of a data dir are `$HOME` and `~/.local`, and
51 /// silently tightening those would be a side effect far outside what a
52 /// caller asking for one private directory consented to. A caller that
53 /// wants an existing object narrowed asks
54 /// [`harden_existing`](PrivateFs::harden_existing) by name.
55 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
56 if path.as_os_str().is_empty() {
57 return Ok(());
58 }
59 if std::fs::metadata(path).is_ok_and(|m| m.is_dir()) {
60 return Ok(());
61 }
62 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
63 self.create_dir_all(parent)?;
64 }
65 match self.create_dir(path) {
66 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
67 other => other,
68 }
69 }
70
71 /// Create and open a new private file. Fails if `path` exists.
72 fn create_file_new(&self, path: &Path, writes: Writes) -> io::Result<File>;
73
74 /// Create a private file, truncating one that already exists.
75 ///
76 /// CONTRACT, and it is weaker than [`create_file_new`](PrivateFs::create_file_new)
77 /// on **both** platforms: an at-create restriction only applies to a file
78 /// the call actually creates. A Unix `mode` is ignored for an existing
79 /// path, and a Windows `SECURITY_ATTRIBUTES` is ignored unless the
80 /// disposition creates. So a pre-existing loose file is narrowed *after*
81 /// the open, and that window is real. Use `create_file_new` wherever the
82 /// path is known to be fresh; this exists for content-addressed blobs,
83 /// where a rewrite rewrites identical bytes.
84 fn create_file_truncate(&self, path: &Path) -> io::Result<File>;
85
86 /// Tighten an object that already exists. The one place a create-then-set
87 /// window is unavoidable, so it is a named operation rather than the
88 /// default path.
89 fn harden_existing(&self, path: &Path) -> io::Result<()>;
90
91 /// What the OS *actually* grants, read back from the object.
92 fn effective_access(&self, path: &Path) -> io::Result<EffectiveAccess>;
93}
94
95/// How the returned handle writes.
96///
97/// `Append` is `O_APPEND` / `FILE_APPEND_DATA`: every write lands at the
98/// current end of file. That is not a convenience — it is what keeps an
99/// append-only log append-only when a writer thread and a reader disagree
100/// about the offset, and it is why this is a parameter rather than something
101/// the caller arranges with a seek.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Writes {
104 FromStart,
105 Append,
106}
107
108/// Read back from the object, not inferred from what we asked for.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct EffectiveAccess {
111 pub owner_only: bool,
112 /// Principals other than the owner that can read it, named for a human —
113 /// a mode string on Unix, a resolved account name on Windows.
114 pub other_readers: Vec<String>,
115}
116
117/// One test body per contract clause, run against whichever adapter this build
118/// selected (rule 8). `windows.rs` adds the one assertion that has no Unix
119/// counterpart.
120#[cfg(test)]
121pub(crate) fn assert_private_fs_contract<P: PrivateFs>(fs: &P, scratch: &Path) {
122 let dir = scratch.join("private-dir");
123 fs.create_dir(&dir).unwrap();
124 let access = fs.effective_access(&dir).unwrap();
125 assert!(
126 access.owner_only,
127 "a freshly created private dir must exclude everyone else, got {:?}",
128 access.other_readers
129 );
130
131 let file = dir.join("secret");
132 drop(fs.create_file_new(&file, Writes::FromStart).unwrap());
133 assert!(fs.effective_access(&file).unwrap().owner_only);
134
135 // `O_EXCL`-shaped: an existing path is an error, never a truncation.
136 std::fs::write(&file, b"payload").unwrap();
137 assert_eq!(
138 fs.create_file_new(&file, Writes::FromStart)
139 .unwrap_err()
140 .kind(),
141 io::ErrorKind::AlreadyExists
142 );
143 assert_eq!(std::fs::read(&file).unwrap(), b"payload");
144
145 // `create_file_truncate` narrows a pre-existing loose file rather than
146 // inheriting its permissions.
147 let blob = dir.join("blob");
148 std::fs::write(&blob, b"old").unwrap();
149 loosen(&blob);
150 {
151 use std::io::Write as _;
152 let mut f = fs.create_file_truncate(&blob).unwrap();
153 f.write_all(b"new").unwrap();
154 }
155 assert_eq!(std::fs::read(&blob).unwrap(), b"new");
156 assert!(fs.effective_access(&blob).unwrap().owner_only);
157
158 // `Append` really appends rather than overwriting from offset zero.
159 let logfile = dir.join("log");
160 {
161 use std::io::Write as _;
162 let mut a = fs.create_file_new(&logfile, Writes::Append).unwrap();
163 a.write_all(b"one").unwrap();
164 a.write_all(b"two").unwrap();
165 }
166 assert_eq!(std::fs::read(&logfile).unwrap(), b"onetwo");
167
168 // Loosening and re-hardening is the `harden_existing` path.
169 loosen(&file);
170 fs.harden_existing(&file).unwrap();
171 assert!(fs.effective_access(&file).unwrap().owner_only);
172}
173
174#[cfg(all(test, unix))]
175fn loosen(path: &Path) {
176 use std::os::unix::fs::PermissionsExt;
177 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
178}
179
180#[cfg(all(test, windows))]
181fn loosen(path: &Path) {
182 // Re-enable inheritance, which is how a Windows object picks up readers it
183 // was not created with. `harden_existing` must put `SE_DACL_PROTECTED`
184 // back.
185 windows::allow_inheritance(path).unwrap();
186}
187
188#[cfg(test)]
189mod tests {
190 #[test]
191 fn active_adapter_upholds_the_contract() {
192 let scratch =
193 std::env::temp_dir().join(format!("hotl-privatefs-{}-{}", std::process::id(), line!()));
194 std::fs::create_dir_all(&scratch).unwrap();
195 super::assert_private_fs_contract(&crate::PRIVATE_FS, &scratch);
196 let _ = std::fs::remove_dir_all(&scratch);
197 }
198}