whycodes_import/
consent.rs1use 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 #[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 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}