git.lucas.co / cce-mail
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 }