Skip to main content

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}