Skip to main content

forge_browser/
profiles.rs

1//! The server's profile store: one directory per org profile.
2//!
3//! **The key is two directory levels** - `profiles/<org>/<name>` - so two
4//! orgs can both have `work` without sharing a byte of cache. The legacy
5//! trees are deleted once on the first boot that finds no marker: the
6//! server's own pre-org `shared/` and per-seat `profiles/<name>/cef/`, and
7//! the client's old caches under its bundle identifier (`browser/cef-profile`
8//! and `browser/profiles/<name>/cef`). **Those caches hold the client's
9//! logins, and they are not carried over**: an org profile starts empty and
10//! each org signs in again, which is what one login store per org means. A
11//! marker beside the root keeps a later boot from touching anything.
12
13use std::path::{Path, PathBuf};
14
15use forge_primitives::browser::ProfileKey;
16
17/// The marker file, inside the server's browser root, that says the legacy
18/// deletion ran.
19const MARKER: &str = "legacy-profiles-deleted";
20
21/// The client's bundle identifier: its old in-process CEF kept its cache
22/// trees under this app-support directory.
23const CLIENT_BUNDLE: &str = "dev.vedhavyas.forge";
24
25/// The server's profile tree, under forge's app-support `browser/` root.
26pub struct Store {
27    root: PathBuf,
28}
29
30/// What one boot's legacy deletion removed: **the paths are named**, so the
31/// one INFO line can say what went rather than only how many.
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct Legacy {
34    pub removed: Vec<PathBuf>,
35    pub ran: bool,
36}
37
38impl Store {
39    pub fn at(root: &Path) -> Self {
40        Self { root: root.to_owned() }
41    }
42
43    /// The tree every org profile hangs off.
44    pub fn profiles(&self) -> PathBuf {
45        self.root.join("profiles")
46    }
47
48    /// One org profile's directory: its `cef` cache and its `uploads`.
49    pub fn org_profile(&self, key: &ProfileKey) -> PathBuf {
50        self.profiles().join(key.org()).join(key.name())
51    }
52
53    /// The client's own browser tree on this machine, when there is one: the
54    /// app-support directory its bundle identifier owned. **The one path the
55    /// server derives outside its own app-support**, and only to delete the
56    /// client's dead caches.
57    pub fn client_root() -> Option<PathBuf> {
58        let support = forge_sdk::app_support_dir().ok()?.parent()?.to_owned();
59        Some(client_root_at(&support))
60    }
61
62    /// Delete the legacy trees once: the server's own pre-org store
63    /// (`shared/` and `profiles/<name>/cef/`) and the client's old CEF
64    /// caches (`browser/cef-profile` and `browser/profiles/<name>/cef`).
65    /// **The client's logins go with them and are entered again per org**;
66    /// a copy would preserve the single-profile tree the org model replaces.
67    /// **A stray file is not a profile**; the new org directories hold their
68    /// `cef/` one level deeper and are never touched. Guarded by the marker.
69    pub fn delete_legacy(&self) -> Result<Legacy, String> {
70        self.delete_legacy_at(Self::client_root().as_deref())
71    }
72
73    /// The deletion with the client tree given, so a test never touches the
74    /// machine's real client data.
75    pub fn delete_legacy_at(&self, client: Option<&Path>) -> Result<Legacy, String> {
76        let marker = self.root.join(MARKER);
77        if marker.exists() {
78            return Ok(Legacy { removed: Vec::new(), ran: false });
79        }
80        let mut removed = Vec::new();
81        let shared = self.root.join("shared");
82        if shared.is_dir() {
83            remove_tree(&shared)?;
84            removed.push(shared);
85        }
86        removed.extend(remove_named_profiles(&self.profiles())?);
87        if let Some(client) = client.filter(|client| client.is_dir()) {
88            // **The client's shared cache is `cef-profile`**, not a
89            // directory named `shared`: the old in-process host kept it
90            // beside `profiles/`, not inside it.
91            let shared = client.join("cef-profile");
92            if shared.is_dir() {
93                remove_tree(&shared)?;
94                removed.push(shared);
95            }
96            removed.extend(remove_named_profiles(&client.join("profiles"))?);
97        }
98        std::fs::create_dir_all(&self.root)
99            .map_err(|why| format!("the browser root cannot be made: {why}"))?;
100        std::fs::write(&marker, b"1")
101            .map_err(|why| format!("the marker cannot be written: {why}"))?;
102        Ok(Legacy { removed, ran: true })
103    }
104}
105
106/// Delete every direct child that holds a `cef/` cache, answering the paths
107/// it removed. A missing directory is nothing to delete; an entry that cannot
108/// be read fails before the marker is written.
109fn remove_named_profiles(profiles: &Path) -> Result<Vec<PathBuf>, String> {
110    let mut removed = Vec::new();
111    match std::fs::read_dir(profiles) {
112        Ok(entries) => {
113            for entry in entries {
114                let entry = entry.map_err(|why| format!("the profiles cannot be read: {why}"))?;
115                let path = entry.path();
116                let cache = path.join("cef");
117                // **A profile literally named `cef` under an org looks like
118                // a cache at this depth.** The new layout is one level
119                // deeper, so an entry whose `cef` child itself holds a
120                // `cef` child is an org directory, never a legacy profile;
121                // deleting it would take a live org's tree.
122                if cache.is_dir() && path.is_dir() && !cache.join("cef").is_dir() {
123                    remove_tree(&path)?;
124                    removed.push(path);
125                }
126            }
127        }
128        Err(why) if why.kind() == std::io::ErrorKind::NotFound => {}
129        Err(why) => return Err(format!("the profiles cannot be read: {why}")),
130    }
131    Ok(removed)
132}
133
134fn remove_tree(path: &Path) -> Result<(), String> {
135    std::fs::remove_dir_all(path)
136        .map_err(|why| format!("{} could not be deleted: {why}", path.display()))
137}
138
139/// The client's browser tree under an app-support root. **`dev.vedhavyas.forge`
140/// is the client's bundle identifier**, and `browser` is the in-process CEF
141/// cache it kept there before the server owned the browser.
142fn client_root_at(support: &Path) -> PathBuf {
143    support.join(CLIENT_BUNDLE).join("browser")
144}
145
146#[cfg(test)]
147mod tests {
148    use super::Store;
149    use forge_primitives::browser::ProfileKey;
150
151    /// **The one path derived outside the server's own app-support** is the
152    /// client's bundle directory plus its `browser` tree.
153    #[test]
154    fn the_client_root_is_the_clients_bundle_browser_tree() {
155        assert_eq!(
156            super::client_root_at(std::path::Path::new("/support")),
157            std::path::Path::new("/support/dev.vedhavyas.forge/browser"),
158        );
159    }
160
161    /// **The client root hangs off app-support's parent**, not the server's
162    /// own directory: a sibling bundle-id directory, which is where the
163    /// client's in-process caches really lived.
164    #[test]
165    fn the_client_root_is_derived_from_app_supports_parent() {
166        let support = forge_sdk::app_support_dir().expect("this machine has an app-support dir");
167        assert_eq!(
168            Store::client_root(),
169            support.parent().map(|parent| parent.join("dev.vedhavyas.forge/browser")),
170        );
171    }
172
173    #[test]
174    fn an_org_profile_is_one_directory_deeper() {
175        let dir = tempfile::tempdir().expect("a temp dir");
176        let store = Store::at(dir.path());
177        let key = ProfileKey::of("Busytools", "work").expect("a key");
178        assert_eq!(store.org_profile(&key), dir.path().join("profiles/Busytools/work"));
179    }
180
181    #[test]
182    fn the_legacy_server_and_client_trees_go_once() {
183        let dir = tempfile::tempdir().expect("a temp dir");
184        let client = dir.path().join("client/browser");
185        std::fs::create_dir_all(dir.path().join("shared/cef")).expect("the shared tree");
186        std::fs::create_dir_all(dir.path().join("profiles/lead/cef")).expect("a named tree");
187        std::fs::write(dir.path().join("profiles/owner.json"), b"{}").expect("a stray file");
188        std::fs::create_dir_all(dir.path().join("profiles/Busytools/work/cef"))
189            .expect("an org tree");
190        // **A profile literally named `cef`** makes its org directory look
191        // like a legacy cache at the direct-child depth; the org must stay.
192        std::fs::create_dir_all(dir.path().join("profiles/Busytools/cef/cef"))
193            .expect("a profile named cef under an org");
194        // **The real client layout**: its shared cache is `cef-profile`,
195        // beside `profiles/`, not a directory named `shared`.
196        std::fs::create_dir_all(client.join("cef-profile")).expect("the client's shared cache");
197        std::fs::create_dir_all(client.join("profiles/lead/cef")).expect("a client profile");
198        std::fs::create_dir_all(client.join("profiles/notes")).expect("a client dir, no cache");
199
200        let store = Store::at(dir.path());
201        let first = store.delete_legacy_at(Some(&client)).expect("the first boot deletes");
202        assert_eq!(first.removed.len(), 4, "the server's two legacy trees and the client's two go");
203        assert!(!dir.path().join("shared").exists());
204        assert!(!dir.path().join("profiles/lead").exists());
205        assert!(dir.path().join("profiles/Busytools/work/cef").exists(), "an org tree stays");
206        assert!(
207            dir.path().join("profiles/Busytools/cef/cef").exists(),
208            "an org whose profile is literally named `cef` stays",
209        );
210        assert!(dir.path().join("profiles/owner.json").exists(), "a stray file is not a profile");
211        assert!(!client.join("cef-profile").exists(), "the client's shared cache goes");
212        assert!(!client.join("profiles/lead").exists(), "its per-profile cache goes");
213        assert!(client.join("profiles/notes").exists(), "a directory with no cef cache stays");
214
215        std::fs::create_dir_all(client.join("cef-profile")).expect("a client cache returns");
216        let second = store.delete_legacy_at(Some(&client)).expect("the second boot is a no-op");
217        assert_eq!(second.removed.len(), 0, "the deletion never runs twice");
218        assert!(client.join("cef-profile").exists());
219    }
220
221    /// A deletion that cannot finish must not leave the marker behind, so
222    /// the next boot retries the whole thing.
223    #[cfg(unix)]
224    #[test]
225    fn a_legacy_tree_that_cannot_be_removed_fails_the_boot_before_the_marker() {
226        use std::os::unix::fs::PermissionsExt;
227
228        let dir = tempfile::tempdir().expect("a temp dir");
229        let profiles = dir.path().join("profiles");
230        std::fs::create_dir_all(profiles.join("lead/cef")).expect("a legacy tree");
231        std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o500))
232            .expect("profiles made read-only");
233
234        // A root user ignores the mode bits, so there is no refusal to see.
235        let probe = profiles.join("permission-probe");
236        if std::fs::write(&probe, b"").is_ok() {
237            std::fs::remove_file(&probe).expect("the probe goes");
238            std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o700))
239                .expect("profiles writable again");
240            println!("skipping: the mode bits are not enforced (running as root?)");
241            return;
242        }
243
244        let store = Store::at(dir.path());
245        assert!(
246            store.delete_legacy_at(None).is_err(),
247            "a legacy tree that cannot be removed fails the boot"
248        );
249        assert!(
250            !dir.path().join("legacy-profiles-deleted").exists(),
251            "the marker is not written when the deletion failed"
252        );
253
254        std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o700))
255            .expect("profiles writable again");
256    }
257
258    /// **A tree that cannot even be LISTED fails the boot before the
259    /// marker.** The deletion reads every entry to decide what is legacy; a
260    /// read that fails must not leave a marker claiming the work is done.
261    #[cfg(unix)]
262    #[test]
263    fn a_legacy_tree_that_cannot_be_listed_fails_before_the_marker() {
264        use std::os::unix::fs::PermissionsExt;
265
266        let dir = tempfile::tempdir().expect("a temp dir");
267        let profiles = dir.path().join("profiles");
268        std::fs::create_dir_all(&profiles).expect("the profiles dir");
269        std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o000))
270            .expect("profiles closed");
271
272        // A root user ignores the mode bits, so there is no refusal to see.
273        let probe = profiles.join("permission-probe");
274        if std::fs::write(&probe, b"").is_ok() {
275            std::fs::remove_file(&probe).expect("the probe goes");
276            std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o700))
277                .expect("profiles readable again");
278            println!("skipping: the mode bits are not enforced (running as root?)");
279            return;
280        }
281
282        let store = Store::at(dir.path());
283        assert!(
284            store.delete_legacy_at(None).is_err(),
285            "a tree that cannot be listed fails the boot"
286        );
287        assert!(
288            !dir.path().join("legacy-profiles-deleted").exists(),
289            "the marker is not written when the listing failed"
290        );
291
292        std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o700))
293            .expect("profiles readable again");
294    }
295}