Skip to main content

whycodes_import/
consent.rs

1//! Consent store for settings import (`<data_dir>/import-consent.json`, 0600).
2//!
3//! Approvals are per canonical path. The source file is never modified.
4
5use std::collections::BTreeSet;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::Result;
11use crate::types::SourceState;
12
13const VERSION: u32 = 1;
14
15#[derive(Debug, Default, Serialize, Deserialize)]
16struct ConsentFile {
17    #[serde(default)]
18    version: u32,
19    /// First-run prompt already shown (yes or no).
20    #[serde(default)]
21    first_run_asked: bool,
22    #[serde(default)]
23    approved: BTreeSet<String>,
24    #[serde(default)]
25    denied: BTreeSet<String>,
26}
27
28pub struct ConsentStore {
29    path: PathBuf,
30}
31
32impl ConsentStore {
33    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
34        Self {
35            path: data_dir.into().join("import-consent.json"),
36        }
37    }
38
39    pub fn path(&self) -> &Path {
40        &self.path
41    }
42
43    fn read(&self) -> Result<ConsentFile> {
44        if !self.path.exists() {
45            return Ok(ConsentFile {
46                version: VERSION,
47                ..ConsentFile::default()
48            });
49        }
50        let bytes = std::fs::read(&self.path)?;
51        if bytes.is_empty() {
52            return Ok(ConsentFile {
53                version: VERSION,
54                ..ConsentFile::default()
55            });
56        }
57        let mut file: ConsentFile = serde_json::from_slice(&bytes)?;
58        file.version = VERSION;
59        Ok(file)
60    }
61
62    fn write(&self, file: &ConsentFile) -> Result<()> {
63        let parent = self.path.parent().unwrap_or(Path::new("."));
64        std::fs::create_dir_all(parent)?;
65        let tmp = self.path.with_extension("json.tmp");
66        std::fs::write(&tmp, serde_json::to_string_pretty(file)?)?;
67        set_owner_only(&tmp)?;
68        std::fs::rename(&tmp, &self.path)?;
69        Ok(())
70    }
71
72    pub fn first_run_asked(&self) -> Result<bool> {
73        Ok(self.read()?.first_run_asked)
74    }
75
76    pub fn mark_first_run_asked(&self) -> Result<()> {
77        let mut file = self.read()?;
78        file.first_run_asked = true;
79        self.write(&file)
80    }
81
82    pub fn state_for(&self, path: &Path) -> SourceState {
83        let key = path_key(path);
84        match self.read() {
85            Ok(file) if file.denied.contains(&key) => SourceState::Denied,
86            Ok(file) if file.approved.contains(&key) => SourceState::Approved,
87            _ => SourceState::New,
88        }
89    }
90
91    pub fn approve(&self, path: &Path) -> Result<()> {
92        let key = path_key(path);
93        let mut file = self.read()?;
94        file.denied.remove(&key);
95        file.approved.insert(key);
96        self.write(&file)
97    }
98
99    pub fn deny(&self, path: &Path) -> Result<()> {
100        let key = path_key(path);
101        let mut file = self.read()?;
102        file.approved.remove(&key);
103        file.denied.insert(key);
104        self.write(&file)
105    }
106}
107
108fn path_key(path: &Path) -> String {
109    std::fs::canonicalize(path)
110        .unwrap_or_else(|_| path.to_path_buf())
111        .to_string_lossy()
112        .into_owned()
113}
114
115#[cfg(unix)]
116fn set_owner_only(path: &Path) -> Result<()> {
117    use std::os::unix::fs::PermissionsExt;
118    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
119    Ok(())
120}
121
122#[cfg(not(unix))]
123fn set_owner_only(_path: &Path) -> Result<()> {
124    Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn approve_deny_and_first_run() {
133        let dir = tempfile::tempdir().unwrap();
134        let store = ConsentStore::new(dir.path());
135        let p = dir.path().join("a.json");
136        std::fs::write(&p, "{}").unwrap();
137        assert!(!store.first_run_asked().unwrap());
138        assert_eq!(store.state_for(&p), SourceState::New);
139        store.approve(&p).unwrap();
140        assert_eq!(store.state_for(&p), SourceState::Approved);
141        store.deny(&p).unwrap();
142        assert_eq!(store.state_for(&p), SourceState::Denied);
143        store.approve(&p).unwrap();
144        assert_eq!(store.state_for(&p), SourceState::Approved);
145        store.mark_first_run_asked().unwrap();
146        assert!(store.first_run_asked().unwrap());
147        assert!(store.path().ends_with("import-consent.json"));
148        // empty file is treated as default
149        std::fs::write(store.path(), b"").unwrap();
150        assert!(!store.first_run_asked().unwrap());
151    }
152
153    #[test]
154    fn missing_file_is_new() {
155        let dir = tempfile::tempdir().unwrap();
156        let store = ConsentStore::new(dir.path());
157        assert_eq!(
158            store.state_for(&dir.path().join("nope.json")),
159            SourceState::New
160        );
161    }
162
163    #[test]
164    fn invalid_json_and_missing_canonical_path() {
165        let dir = tempfile::tempdir().unwrap();
166        let store = ConsentStore::new(dir.path());
167        std::fs::write(store.path(), b"{not json").unwrap();
168        assert!(store.first_run_asked().is_err());
169        std::fs::remove_file(store.path()).unwrap();
170        let missing = dir.path().join("no-such.json");
171        assert_eq!(store.state_for(&missing), SourceState::New);
172        store.approve(&missing).unwrap();
173        assert_eq!(store.state_for(&missing), SourceState::Approved);
174    }
175
176    #[cfg(not(unix))]
177    #[test]
178    fn set_owner_only_is_noop_off_unix() {
179        let dir = tempfile::tempdir().unwrap();
180        let p = dir.path().join("x");
181        std::fs::write(&p, b"x").unwrap();
182        set_owner_only(&p).unwrap();
183    }
184}