Skip to main content

gglib_core/paths/
private.rs

1//! Directories and files that nobody else on this machine can read.
2//!
3//! `data/` holds the database (chat history, the proxy's API key, the
4//! environment variables given to MCP servers) beside the endpoint identity
5//! and the device keys. A build made in a checkout resolves it inside the
6//! repository, which other accounts on the machine can often reach, so `data/`
7//! is `0700` and the database in it `0600`, each from the moment it exists.
8//!
9//! **Creating is strict; tightening is best effort.** A mode asked of `mkdir`
10//! or `open` costs nothing a plain create did not, so these fail exactly when
11//! one would. Tightening what is already there, which an older build left or
12//! this crate's `build.rs` made in a checkout, is a `chmod` afterwards, and a
13//! filesystem with no Unix modes refuses that for every file. So a tightening
14//! that fails is logged rather than returned: a gglib that would not start
15//! there would protect nothing.
16
17use std::fs;
18use std::io;
19use std::path::Path;
20
21/// Create `dir` and any parents it lacks `0700`, and take group and other's
22/// access away from a `dir` that was already there.
23///
24/// # Errors
25///
26/// Whatever creating the directory returns. A tightening that fails is logged
27/// (see the module docs).
28pub fn create_private_dir(dir: &Path) -> io::Result<()> {
29    create_dir(dir)?;
30    make_private(dir);
31    Ok(())
32}
33
34/// Create `file` empty and `0600`, or take group and other's access away from
35/// one that is already there.
36///
37/// An existing file keeps every byte. The database goes through here on every
38/// start, and a truncate would be the user's history gone.
39///
40/// # Errors
41///
42/// Whatever creating the file returns, other than that it already exists. A
43/// tightening that fails is logged (see the module docs).
44pub fn create_private_file(file: &Path) -> io::Result<()> {
45    match create_file(file) {
46        Ok(_) => Ok(()),
47        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
48            make_private(file);
49            Ok(())
50        }
51        Err(e) => Err(e),
52    }
53}
54
55/// Take group and other's access away from whatever is at `path`; the owner
56/// keeps what it had.
57///
58/// Nothing at `path` is nothing to do. A failure is logged rather than
59/// returned (see the module docs).
60pub fn make_private(path: &Path) {
61    match tighten(path) {
62        Ok(()) => {}
63        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
64        Err(e) => tracing::warn!(
65            path = %path.display(),
66            error = %e,
67            "could not take group and other's access away; other accounts may be able to read this"
68        ),
69    }
70}
71
72/// `mkdir` asked for `0700`. The mode applies to every directory this
73/// creates, parents included, and to none that already exist.
74#[cfg(unix)]
75fn create_dir(dir: &Path) -> io::Result<()> {
76    use std::os::unix::fs::DirBuilderExt;
77    fs::DirBuilder::new()
78        .recursive(true)
79        .mode(0o700)
80        .create(dir)
81}
82
83/// Windows has no mode to ask for; the directory inherits its parent's ACL.
84#[cfg(not(unix))]
85fn create_dir(dir: &Path) -> io::Result<()> {
86    fs::create_dir_all(dir)
87}
88
89/// `open` asked for `0600`, and only if nothing is there: `create_new` is what
90/// makes truncating an existing file impossible here.
91#[cfg(unix)]
92fn create_file(file: &Path) -> io::Result<fs::File> {
93    use std::os::unix::fs::OpenOptionsExt;
94    fs::OpenOptions::new()
95        .write(true)
96        .create_new(true)
97        .mode(0o600)
98        .open(file)
99}
100
101/// Windows has no mode to ask for; the file inherits its directory's ACL.
102#[cfg(not(unix))]
103fn create_file(file: &Path) -> io::Result<fs::File> {
104    fs::OpenOptions::new()
105        .write(true)
106        .create_new(true)
107        .open(file)
108}
109
110/// Clear group's and other's bits, keeping the owner's and the setuid, setgid
111/// and sticky bits, and only when group or other has one, so a file that is
112/// already private costs a `stat` and nothing more.
113#[cfg(unix)]
114#[allow(clippy::verbose_bit_mask)] // `trailing_zeros() >= 6` hides "group and other have nothing"
115fn tighten(path: &Path) -> io::Result<()> {
116    use std::os::unix::fs::PermissionsExt;
117    let mode = fs::metadata(path)?.permissions().mode();
118    if mode & 0o077 == 0 {
119        return Ok(());
120    }
121    fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o7700))
122}
123
124/// Windows has no mode to take away.
125#[cfg(not(unix))]
126#[allow(clippy::unnecessary_wraps, clippy::missing_const_for_fn)] // the Unix twin really can fail
127fn tighten(_path: &Path) -> io::Result<()> {
128    Ok(())
129}
130
131#[cfg(all(test, unix))]
132mod tests {
133    use std::os::unix::fs::PermissionsExt;
134
135    use super::*;
136    use crate::paths::test_utils::{ENV_LOCK, EnvVarGuard};
137    use crate::paths::{database_path, remote_identity_path};
138
139    fn mode(path: &Path) -> u32 {
140        fs::metadata(path).expect("metadata").permissions().mode() & 0o777
141    }
142
143    fn set_mode(path: &Path, mode: u32) {
144        fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("chmod");
145    }
146
147    /// Checked straight after `mkdir`, before `make_private` has had a chance
148    /// to hide a create that asked for no mode.
149    ///
150    /// It bites wherever the bug does. Under the usual `022` umask a plain
151    /// `mkdir` gives `0755`; under `077` it gives `0700` as well, and this
152    /// cannot tell the two apart. The umask is process-wide and these tests
153    /// run in parallel, so it is not set here to force the question.
154    #[test]
155    fn a_new_directory_is_private_before_any_chmod() {
156        let root = tempfile::tempdir().expect("tempdir");
157        let dir = root.path().join("data");
158
159        create_dir(&dir).expect("create");
160
161        assert_eq!(mode(&dir) & 0o077, 0, "{:o}", mode(&dir));
162    }
163
164    /// The same for a file: the database, whose `-wal` and `-shm` take its
165    /// mode when `SQLite` creates them.
166    #[test]
167    fn a_new_file_is_private_before_any_chmod() {
168        let root = tempfile::tempdir().expect("tempdir");
169        let file = root.path().join("gglib.db");
170
171        create_file(&file).expect("create");
172
173        assert_eq!(mode(&file) & 0o077, 0, "{:o}", mode(&file));
174    }
175
176    /// Every start runs this against a database with the user's history in
177    /// it, so an existing file comes out tightened and byte-for-byte whole.
178    #[test]
179    fn an_existing_database_keeps_every_byte_and_loses_what_others_had() {
180        let root = tempfile::tempdir().expect("tempdir");
181        let file = root.path().join("gglib.db");
182        fs::write(&file, b"SQLite format 3\0and the rest").expect("write");
183        set_mode(&file, 0o644);
184
185        create_private_file(&file).expect("an existing file is not an error");
186
187        assert_eq!(
188            fs::read(&file).expect("read"),
189            b"SQLite format 3\0and the rest"
190        );
191        assert_eq!(mode(&file), 0o600);
192    }
193
194    /// Both accessors create `data/`, and in a checkout `build.rs` has usually
195    /// made it first at whatever the umask allowed, so each must leave it
196    /// private whether it made it or found it.
197    #[test]
198    fn database_path_and_remote_identity_path_leave_data_private() {
199        let _lock = ENV_LOCK.lock().unwrap();
200        let root = tempfile::tempdir().expect("tempdir");
201        let _env = EnvVarGuard::set("GGLIB_DATA_DIR", root.path().to_string_lossy().as_ref());
202        let data = root.path().join("data");
203
204        database_path().expect("database_path");
205        assert_eq!(mode(&data) & 0o077, 0, "made: {:o}", mode(&data));
206
207        set_mode(&data, 0o755);
208        remote_identity_path().expect("remote_identity_path");
209        assert_eq!(mode(&data), 0o700, "found");
210    }
211
212    /// Only group's and other's bits go: a setgid directory someone keeps
213    /// the database in stays setgid.
214    #[test]
215    fn tightening_keeps_the_setgid_and_sticky_bits() {
216        let root = tempfile::tempdir().expect("tempdir");
217        let dir = root.path().join("shared");
218        fs::create_dir(&dir).expect("mkdir");
219        set_mode(&dir, 0o3775);
220
221        make_private(&dir);
222
223        let kept = fs::metadata(&dir).expect("metadata").permissions().mode() & 0o7777;
224        assert_eq!(kept, 0o3700, "{kept:o}");
225    }
226}