Skip to main content

silicon_apps_client/
state.rs

1//! Explicit local persistence adapters. The HTTP client remains stateless.
2use crate::install::Installed;
3use anyhow::{Context, Result, ensure};
4use fs2::FileExt;
5use serde::{Deserialize, Serialize};
6use std::{
7    collections::BTreeMap,
8    fs,
9    path::{Path, PathBuf},
10};
11
12/// cmd.exe cannot launch a canonical Windows verbatim path. Keep filesystem
13/// paths canonical, and convert only where a path is embedded in a shell command.
14#[cfg(any(windows, test))]
15pub(crate) fn windows_shell_path(path: &Path) -> String {
16    let text = path.to_string_lossy();
17    if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
18        format!(r"\\{unc}")
19    } else {
20        text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned()
21    }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27    pub server: String,
28    pub accounts_url: String,
29    pub telemetry: bool,
30    pub install_script_timeout_seconds: u64,
31    pub update_interval_seconds: u64,
32}
33impl Default for Config {
34    fn default() -> Self {
35        Self {
36            server: crate::DEFAULT_URL.into(),
37            accounts_url: "https://accounts.teamofsilicons.com".into(),
38            telemetry: true,
39            install_script_timeout_seconds: 120,
40            update_interval_seconds: 60,
41        }
42    }
43}
44#[derive(Debug, Clone)]
45pub struct LocalState {
46    pub home: PathBuf,
47    pub root: PathBuf,
48}
49/// Releases the advisory lock even if a concurrently spawned child temporarily
50/// retains a duplicate descriptor before exec closes it.
51#[derive(Debug)]
52pub struct LocalLock(fs::File);
53
54impl Drop for LocalLock {
55    fn drop(&mut self) {
56        let _ = FileExt::unlock(&self.0);
57    }
58}
59
60impl LocalState {
61    pub fn new(home: impl Into<PathBuf>) -> Result<Self> {
62        let home = home.into();
63        ensure!(home.is_dir(), "{}: not a directory", home.display());
64        let home = home.canonicalize()?;
65        let root = home.join(".apps");
66        if root.exists() {
67            ensure!(
68                !fs::symlink_metadata(&root)?.file_type().is_symlink(),
69                "{}: state directory must not be a symlink",
70                root.display()
71            );
72        }
73        Ok(Self { home, root })
74    }
75    /// Resolve explicit home, SILICON_HOME, saved `config home`, then normal home.
76    pub fn discover(explicit: Option<&Path>) -> Result<Self> {
77        if let Some(home) = explicit {
78            return Self::new(home);
79        }
80        if let Some(home) = std::env::var_os("SILICON_HOME") {
81            return Self::new(PathBuf::from(home));
82        }
83        let default = dirs::home_dir()
84            .context("could not locate home; set SILICON_HOME to an existing directory")?;
85        let pointer = default.join(".apps/home");
86        if pointer.is_file() {
87            return Self::new(PathBuf::from(fs::read_to_string(pointer)?.trim()));
88        }
89        Self::new(default)
90    }
91    pub fn set_home(location: &Path) -> Result<Self> {
92        let state = Self::new(location)?;
93        let default = dirs::home_dir().context("could not locate default home")?;
94        let root = default.join(".apps");
95        fs::create_dir_all(&root)?;
96        atomic_write(&root.join("home"), state.home.to_string_lossy().as_bytes())?;
97        Ok(state)
98    }
99    pub fn initialize(&self) -> Result<()> {
100        fs::create_dir_all(&self.root)?;
101        #[cfg(unix)]
102        {
103            use std::os::unix::fs::PermissionsExt;
104            fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700))?;
105        }
106        for p in ["bin", "installed", "locks"] {
107            fs::create_dir_all(self.root.join(p))?;
108        }
109        Ok(())
110    }
111    pub fn config(&self) -> Result<Config> {
112        read_json(&self.root.join("config.json"))
113    }
114    pub fn save_config(&self, config: &Config) -> Result<()> {
115        self.initialize()?;
116        atomic_json(&self.root.join("config.json"), config)
117    }
118    pub fn installed(&self) -> Result<BTreeMap<String, Installed>> {
119        read_json(&self.root.join("installed.json"))
120    }
121    pub fn save_installed(&self, items: &BTreeMap<String, Installed>) -> Result<()> {
122        self.initialize()?;
123        atomic_json(&self.root.join("installed.json"), items)
124    }
125    pub fn lock(&self, name: &str) -> Result<LocalLock> {
126        ensure!(
127            name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'),
128            "invalid lock name"
129        );
130        self.initialize()?;
131        let lock = fs::OpenOptions::new()
132            .read(true)
133            .write(true)
134            .create(true)
135            .truncate(false)
136            .open(self.root.join("locks").join(name))?;
137        lock.try_lock_exclusive().with_context(|| {
138            format!("another Apps process holds the {name} lock; wait for it to finish")
139        })?;
140        Ok(LocalLock(lock))
141    }
142}
143pub fn read_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> Result<T> {
144    match fs::read(path) {
145        Ok(b) => serde_json::from_slice(&b).with_context(|| {
146            format!(
147                "{} contains invalid JSON; preserve it and repair the reported syntax",
148                path.display()
149            )
150        }),
151        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
152        Err(e) => Err(e.into()),
153    }
154}
155pub fn atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
156    atomic_write(path, &serde_json::to_vec_pretty(value)?)
157}
158pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
159    use std::io::Write;
160    let parent = path.parent().context("state path must have a parent")?;
161    fs::create_dir_all(parent)?;
162    let mut temp = tempfile::NamedTempFile::new_in(parent)?;
163    #[cfg(unix)]
164    {
165        use std::os::unix::fs::PermissionsExt;
166        temp.as_file()
167            .set_permissions(fs::Permissions::from_mode(0o600))?;
168    }
169    temp.write_all(bytes)?;
170    temp.as_file().sync_all()?;
171    temp.persist(path).map_err(|e| e.error)?;
172    Ok(())
173}
174
175#[cfg(all(test, unix))]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn dropping_lock_releases_it_with_a_duplicate_descriptor_alive() {
181        let home = tempfile::tempdir().unwrap();
182        let state = LocalState::new(home.path()).unwrap();
183        let guard = state.lock("install").unwrap();
184        // A fork in another thread can retain the open-file description until
185        // the child executes its new program, even with close-on-exec set.
186        let duplicate = guard.0.try_clone().unwrap();
187        assert!(state.lock("install").is_err());
188        drop(guard);
189        let next = state.lock("install").unwrap();
190        assert!(state.lock("install").is_err());
191        drop(duplicate);
192        assert!(state.lock("install").is_err());
193        drop(next);
194        assert!(state.lock("install").is_ok());
195    }
196}