mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
src/accounts_file.rs (6.8K)
1 //! Safe writes to the shared `accounts.json`.
2 //!
3 //! Two processes write that file — cce-system-interface (it owns the account
4 //! list) and cce-mail (it refreshes OAuth tokens and moves plaintext passwords
5 //! into the keyring) — and cce-calendar's sync reads it. Until 2026-10-01 each
6 //! writer saved its whole in-memory list with a plain `fs::write`, which lost
7 //! updates both ways: cce-mail, refreshing a token an hour after it started,
8 //! wrote back the list it had loaded then, deleting an account added in
9 //! Settings since and resurrecting one removed there, refresh token included.
10 //! And a plain write truncates before it writes, so a reader in between saw
11 //! an empty file, fell back to the mock account, and its next save replaced
12 //! the real accounts with it.
13 //!
14 //! So a writer never saves a list it holds. It hands [`update`] the change
15 //! it means to make, and `update` re-reads the file, applies it, and writes
16 //! the result back — holding `accounts.json.lock` throughout, so the two
17 //! writers take turns, and replacing the file by rename, so a reader sees the
18 //! old file or the new one and never half of either. A file that does not
19 //! parse is never overwritten: that is somebody's accounts, and an error is
20 //! cheaper than guessing.
21 //!
22 //! The same helper lives in cce-system-interface as `src/accounts_file.rs`; the
23 //! two must agree on the lock file's name, so change both together.
24
25 use std::io::{self, Write};
26 use std::os::unix::fs::OpenOptionsExt;
27 use std::path::{Path, PathBuf};
28
29 fn lock_path(path: &Path) -> PathBuf {
30 let mut name = path.file_name().unwrap_or_default().to_os_string();
31 name.push(".lock");
32 path.with_file_name(name)
33 }
34
35 /// Apply `change` to the accounts in `path` as they are NOW, write them back
36 /// atomically under the writers' lock, and return what was written. A missing
37 /// file is an empty list.
38 pub fn update<T, F>(path: &Path, change: F) -> io::Result<Vec<T>>
39 where
40 T: serde::Serialize + serde::de::DeserializeOwned,
41 F: FnOnce(&mut Vec<T>),
42 {
43 let lock = std::fs::OpenOptions::new()
44 .create(true)
45 .truncate(false)
46 .write(true)
47 .mode(0o600)
48 .open(lock_path(path))?;
49 // Released when `lock` drops, after the rename.
50 lock.lock()?;
51 let mut accounts: Vec<T> = match std::fs::read_to_string(path) {
52 Ok(text) => serde_json::from_str(&text).map_err(|e| {
53 io::Error::new(
54 io::ErrorKind::InvalidData,
55 format!("{} does not parse ({e}); left as it is", path.display()),
56 )
57 })?,
58 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
59 Err(e) => return Err(e),
60 };
61 change(&mut accounts);
62 let text = serde_json::to_string_pretty(&accounts).map_err(io::Error::other)?;
63 write_atomic(path, text.as_bytes())?;
64 Ok(accounts)
65 }
66
67 /// Write a sibling temp file (0600 from creation: it holds tokens), flush it
68 /// to disk, then rename it over `path`.
69 fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
70 let mut name = std::ffi::OsString::from(".");
71 name.push(path.file_name().unwrap_or_default());
72 name.push(format!(".{}.tmp", std::process::id()));
73 let tmp = path.with_file_name(name);
74 let _ = std::fs::remove_file(&tmp);
75 let written = (|| {
76 let mut f = std::fs::OpenOptions::new()
77 .write(true)
78 .create_new(true)
79 .mode(0o600)
80 .open(&tmp)?;
81 f.write_all(bytes)?;
82 f.sync_all()?;
83 std::fs::rename(&tmp, path)
84 })();
85 if written.is_err() {
86 let _ = std::fs::remove_file(&tmp);
87 }
88 written
89 }
90
91 #[cfg(test)]
92 mod tests {
93 use super::*;
94 use serde_json::{json, Value};
95 use std::os::unix::fs::PermissionsExt;
96
97 fn scratch(name: &str) -> PathBuf {
98 let dir = std::env::temp_dir().join(format!("accounts-file-{name}-{}", std::process::id()));
99 let _ = std::fs::remove_dir_all(&dir);
100 std::fs::create_dir_all(&dir).unwrap();
101 dir.join("accounts.json")
102 }
103
104 fn emails(path: &Path) -> Vec<String> {
105 let v: Vec<Value> = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
106 v.iter().map(|a| a["email"].as_str().unwrap().to_string()).collect()
107 }
108
109 #[test]
110 fn a_missing_file_is_created_private() {
111 let path = scratch("create");
112 update::<Value, _>(&path, |a| a.push(json!({ "email": "a@x" }))).unwrap();
113 assert_eq!(emails(&path), ["a@x"]);
114 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
115 assert_eq!(mode, 0o600);
116 // No temp file left beside it.
117 let left: Vec<_> = std::fs::read_dir(path.parent().unwrap()).unwrap().flatten()
118 .map(|e| e.file_name().into_string().unwrap()).collect();
119 assert!(left.iter().all(|n| n == "accounts.json" || n == "accounts.json.lock"), "{left:?}");
120 }
121
122 #[test]
123 fn a_file_that_does_not_parse_is_never_overwritten() {
124 let path = scratch("corrupt");
125 std::fs::write(&path, "[{\"email\": \"a@x\"").unwrap();
126 let err = update::<Value, _>(&path, |a| a.clear()).unwrap_err();
127 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
128 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[{\"email\": \"a@x\"");
129 }
130
131 #[test]
132 fn a_stale_writer_keeps_what_the_other_one_added() {
133 // The bug: mail loaded [a], Settings then added b, mail refreshed a's
134 // token and wrote its [a] back. A targeted change keeps b.
135 let path = scratch("stale");
136 std::fs::write(&path, r#"[{"email":"a@x","access_token":"old"}]"#).unwrap();
137 update::<Value, _>(&path, |a| a.push(json!({ "email": "b@x" }))).unwrap();
138 update::<Value, _>(&path, |accs| {
139 for a in accs.iter_mut().filter(|a| a["email"] == "a@x") {
140 a["access_token"] = json!("new");
141 }
142 })
143 .unwrap();
144 assert_eq!(emails(&path), ["a@x", "b@x"]);
145 let v: Vec<Value> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
146 assert_eq!(v[0]["access_token"], "new");
147 }
148
149 #[test]
150 fn concurrent_writers_take_turns() {
151 // flock is per open file, so threads opening the lock themselves
152 // exclude each other exactly as two processes do.
153 let path = scratch("concurrent");
154 let threads: Vec<_> = (0..16)
155 .map(|i| {
156 let path = path.clone();
157 std::thread::spawn(move || {
158 update::<Value, _>(&path, |a| a.push(json!({ "email": format!("{i}@x") }))).unwrap();
159 })
160 })
161 .collect();
162 for t in threads {
163 t.join().unwrap();
164 }
165 let mut got = emails(&path);
166 got.sort();
167 let mut want: Vec<String> = (0..16).map(|i| format!("{i}@x")).collect();
168 want.sort();
169 assert_eq!(got, want, "an update was lost");
170 }
171 }