Skip to main content

osdk_core/
trust.rs

1use std::ffi::OsString;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::PROJECT_CONFIG_NAMES;
8use crate::error::{Error, Result};
9use crate::lock::FileLock;
10
11const TRUST_FILE_NAME: &str = "trusted-configs.toml";
12const TRUST_LOCK_FILE_NAME: &str = "trusted-configs.lock";
13
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15struct TrustStore {
16    #[serde(default = "schema")]
17    schema: u32,
18    #[serde(default)]
19    configs: Vec<TrustRecord>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct TrustRecord {
24    pub path: PathBuf,
25    pub hash: String,
26}
27
28fn schema() -> u32 {
29    1
30}
31
32pub fn project_config(start: &Path) -> Result<Option<PathBuf>> {
33    let start = if start.is_file() {
34        start.parent().unwrap_or(start)
35    } else {
36        start
37    };
38    for directory in start.ancestors() {
39        for name in PROJECT_CONFIG_NAMES {
40            let candidate = directory.join(name);
41            if candidate.is_file() {
42                return Ok(Some(candidate));
43            }
44        }
45    }
46    Ok(None)
47}
48
49pub fn resolve_config(path: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
50    let candidate = path.unwrap_or(cwd);
51    if candidate.is_file() {
52        return canonical_file(candidate);
53    }
54    let Some(config) = project_config(candidate)? else {
55        return Err(Error::config(format!(
56            "no osdk project config found from {}",
57            candidate.display()
58        )));
59    };
60    canonical_file(&config)
61}
62
63pub fn normalized_hash(path: &Path) -> Result<String> {
64    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
65    let value: toml::Value = toml::from_str(&text)?;
66    let normalized = toml::to_string(&value)
67        .map_err(|error| Error::config(format!("normalizing {}: {error}", path.display())))?;
68    Ok(blake3::hash(normalized.as_bytes()).to_hex().to_string())
69}
70
71pub fn requires_trust(path: &Path) -> Result<bool> {
72    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
73    let value: toml::Value = toml::from_str(&text)?;
74    let Some(table) = value.as_table() else {
75        return Ok(false);
76    };
77    let dynamic_tool_activation = table
78        .get("tools")
79        .and_then(toml::Value::as_table)
80        .is_some_and(|tools| {
81            tools.iter().any(|(key, value)| {
82                is_recognized_dynamic_tool(key)
83                    || value.as_str().is_some_and(is_recognized_dynamic_request)
84                    || value
85                        .as_table()
86                        .and_then(|entry| entry.get("version"))
87                        .and_then(toml::Value::as_str)
88                        .is_some_and(is_recognized_dynamic_request)
89            })
90        });
91    Ok(dynamic_tool_activation
92        || table
93            .keys()
94            .any(|key| !matches!(key.as_str(), "tools" | "aliases")))
95}
96
97fn is_recognized_dynamic_tool(value: &str) -> bool {
98    crate::tool::ToolId::parse(value).is_ok_and(|tool| tool.is_dynamic())
99}
100
101fn is_recognized_dynamic_request(value: &str) -> bool {
102    crate::version::ToolRequest::parse(value)
103        .is_ok_and(|request| is_recognized_dynamic_tool(&request.backend))
104}
105
106pub fn is_trusted(
107    config_dir: &Path,
108    path: &Path,
109    trusted_paths: Option<&OsString>,
110) -> Result<bool> {
111    let canonical = canonical_file(path)?;
112    if trusted_paths
113        .into_iter()
114        .flat_map(std::env::split_paths)
115        .filter_map(|entry| canonical_existing(&entry).ok())
116        .any(|entry| canonical == entry || canonical.starts_with(&entry))
117    {
118        return Ok(true);
119    }
120
121    let hash = normalized_hash(&canonical)?;
122    Ok(read_store(config_dir)?
123        .configs
124        .iter()
125        .any(|record| record.path == canonical && record.hash == hash))
126}
127
128pub fn trust(config_dir: &Path, path: &Path) -> Result<TrustRecord> {
129    let path = canonical_file(path)?;
130    let record = TrustRecord {
131        hash: normalized_hash(&path)?,
132        path,
133    };
134    update_store(config_dir, |store| {
135        store
136            .configs
137            .retain(|existing| existing.path != record.path);
138        store.configs.push(record.clone());
139        store
140            .configs
141            .sort_by(|left, right| left.path.cmp(&right.path));
142        (record, true)
143    })
144}
145
146pub fn untrust(config_dir: &Path, path: &Path) -> Result<bool> {
147    let path = canonical_file(path)?;
148    update_store(config_dir, |store| {
149        let original = store.configs.len();
150        store.configs.retain(|record| record.path != path);
151        let removed = store.configs.len() != original;
152        (removed, removed)
153    })
154}
155
156pub fn list(config_dir: &Path) -> Result<Vec<TrustRecord>> {
157    Ok(read_store(config_dir)?.configs)
158}
159
160fn canonical_existing(path: &Path) -> Result<PathBuf> {
161    dunce::canonicalize(path).map_err(|error| Error::io(path, error))
162}
163
164fn canonical_file(path: &Path) -> Result<PathBuf> {
165    let canonical = canonical_existing(path)?;
166    if !canonical.is_file() {
167        return Err(Error::config(format!(
168            "trusted config path is not a file: {}",
169            canonical.display()
170        )));
171    }
172    Ok(canonical)
173}
174
175fn store_path(config_dir: &Path) -> PathBuf {
176    config_dir.join(TRUST_FILE_NAME)
177}
178
179fn store_lock_path(config_dir: &Path) -> PathBuf {
180    config_dir.join(TRUST_LOCK_FILE_NAME)
181}
182
183fn update_store<T>(
184    config_dir: &Path,
185    update: impl FnOnce(&mut TrustStore) -> (T, bool),
186) -> Result<T> {
187    let _lock = FileLock::acquire(store_lock_path(config_dir))?;
188    let mut store = read_store(config_dir)?;
189    let (result, changed) = update(&mut store);
190    if changed {
191        write_store(config_dir, &store)?;
192    }
193    Ok(result)
194}
195
196fn read_store(config_dir: &Path) -> Result<TrustStore> {
197    let path = store_path(config_dir);
198    if !path.is_file() {
199        return Ok(TrustStore {
200            schema: schema(),
201            configs: Vec::new(),
202        });
203    }
204    let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
205    let store: TrustStore = toml::from_str(&text)?;
206    if store.schema != schema() {
207        return Err(Error::config(format!(
208            "unsupported trust store schema {}",
209            store.schema
210        )));
211    }
212    Ok(store)
213}
214
215fn write_store(config_dir: &Path, store: &TrustStore) -> Result<()> {
216    std::fs::create_dir_all(config_dir).map_err(|error| Error::io(config_dir, error))?;
217    let path = store_path(config_dir);
218    let text = toml::to_string_pretty(store)
219        .map_err(|error| Error::config(format!("serializing trust store: {error}")))?;
220    let mut temporary = tempfile::Builder::new()
221        .prefix(".trusted-configs.")
222        .suffix(".tmp")
223        .tempfile_in(config_dir)
224        .map_err(|error| Error::io(config_dir, error))?;
225    let temporary_path = temporary.path().to_path_buf();
226    temporary
227        .write_all(text.as_bytes())
228        .map_err(|error| Error::io(&temporary_path, error))?;
229    temporary
230        .as_file()
231        .sync_all()
232        .map_err(|error| Error::io(&temporary_path, error))?;
233    let (temporary_file, temporary_path) = temporary
234        .keep()
235        .map_err(|error| Error::io(&temporary_path, error.error))?;
236    drop(temporary_file);
237    if let Err(error) = atomic_replace(&temporary_path, &path) {
238        let _ = std::fs::remove_file(&temporary_path);
239        return Err(error);
240    }
241    sync_parent_directory(config_dir)?;
242    Ok(())
243}
244
245#[cfg(not(windows))]
246fn atomic_replace(source: &Path, destination: &Path) -> Result<()> {
247    std::fs::rename(source, destination).map_err(|error| Error::io(destination, error))
248}
249
250#[cfg(windows)]
251fn atomic_replace(source: &Path, destination: &Path) -> Result<()> {
252    use std::os::windows::ffi::OsStrExt;
253    use windows_sys::Win32::Storage::FileSystem::{
254        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
255    };
256
257    let source_wide: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
258    let destination_wide: Vec<u16> = destination
259        .as_os_str()
260        .encode_wide()
261        .chain(Some(0))
262        .collect();
263    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
264    let result = unsafe { MoveFileExW(source_wide.as_ptr(), destination_wide.as_ptr(), flags) };
265    if result == 0 {
266        return Err(Error::io(destination, std::io::Error::last_os_error()));
267    }
268    Ok(())
269}
270
271#[cfg(unix)]
272fn sync_parent_directory(parent: &Path) -> Result<()> {
273    std::fs::File::open(parent)
274        .and_then(|directory| directory.sync_all())
275        .map_err(|error| Error::io(parent, error))
276}
277
278#[cfg(not(unix))]
279fn sync_parent_directory(_parent: &Path) -> Result<()> {
280    Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285    use std::sync::{mpsc, Arc, Barrier};
286    use std::time::Duration;
287
288    use super::*;
289
290    #[test]
291    fn normalized_content_and_canonical_path_define_identity() {
292        let temp = tempfile::tempdir().unwrap();
293        let config_dir = temp.path().join("state");
294        let repo = temp.path().join("repo");
295        std::fs::create_dir_all(&repo).unwrap();
296        let config = repo.join("osdk.toml");
297        std::fs::write(&config, "[sources]\nselection = \"ordered\"\n").unwrap();
298
299        let traversal = repo.join("nested/../osdk.toml");
300        std::fs::create_dir_all(repo.join("nested")).unwrap();
301        let record = trust(&config_dir, &traversal).unwrap();
302        assert!(is_trusted(&config_dir, &config, None).unwrap());
303        assert_eq!(record.path, dunce::canonicalize(&config).unwrap());
304
305        std::fs::write(&config, "[sources]\nselection = \"auto\"\n").unwrap();
306        assert!(!is_trusted(&config_dir, &config, None).unwrap());
307    }
308
309    #[test]
310    fn repeated_updates_replace_store_without_using_fixed_temporary_path() {
311        let temp = tempfile::tempdir().unwrap();
312        let config_dir = temp.path().join("state");
313        std::fs::create_dir_all(&config_dir).unwrap();
314        let config = temp.path().join("osdk.toml");
315        let legacy_temporary = config_dir.join("trusted-configs.toml.tmp");
316        std::fs::write(&legacy_temporary, "do not overwrite").unwrap();
317
318        std::fs::write(&config, "[settings]\nvalue = 1\n").unwrap();
319        let original = trust(&config_dir, &config).unwrap();
320        std::fs::write(&config, "[settings]\nvalue = 2\n").unwrap();
321        let updated = trust(&config_dir, &config).unwrap();
322
323        assert_ne!(original.hash, updated.hash);
324        assert!(is_trusted(&config_dir, &config, None).unwrap());
325        assert_eq!(
326            std::fs::read_to_string(&legacy_temporary).unwrap(),
327            "do not overwrite"
328        );
329        assert!(untrust(&config_dir, &config).unwrap());
330        assert!(list(&config_dir).unwrap().is_empty());
331    }
332
333    #[cfg(windows)]
334    #[test]
335    fn windows_replaces_an_existing_store() {
336        let temp = tempfile::tempdir().unwrap();
337        let config_dir = temp.path().join("state");
338        let config = temp.path().join("osdk.toml");
339
340        std::fs::write(&config, "[settings]\nvalue = 1\n").unwrap();
341        trust(&config_dir, &config).unwrap();
342        std::fs::write(&config, "[settings]\nvalue = 2\n").unwrap();
343        trust(&config_dir, &config).unwrap();
344
345        assert_eq!(list(&config_dir).unwrap().len(), 1);
346        assert!(is_trusted(&config_dir, &config, None).unwrap());
347    }
348
349    #[test]
350    fn concurrent_trust_updates_preserve_every_record() {
351        const WRITERS: usize = 16;
352
353        let temp = tempfile::tempdir().unwrap();
354        let config_dir = temp.path().join("state");
355        let mut configs = Vec::with_capacity(WRITERS);
356        for index in 0..WRITERS {
357            let config = temp.path().join(format!("osdk-{index}.toml"));
358            std::fs::write(&config, format!("[settings]\nvalue = {index}\n")).unwrap();
359            configs.push(config);
360        }
361
362        let barrier = Arc::new(Barrier::new(WRITERS));
363        let handles: Vec<_> = configs
364            .iter()
365            .cloned()
366            .map(|config| {
367                let barrier = Arc::clone(&barrier);
368                let config_dir = config_dir.clone();
369                std::thread::spawn(move || {
370                    barrier.wait();
371                    trust(&config_dir, &config).unwrap();
372                })
373            })
374            .collect();
375        for handle in handles {
376            handle.join().unwrap();
377        }
378
379        let mut expected: Vec<_> = configs
380            .iter()
381            .map(|config| dunce::canonicalize(config).unwrap())
382            .collect();
383        expected.sort();
384        let actual: Vec<_> = list(&config_dir)
385            .unwrap()
386            .into_iter()
387            .map(|record| record.path)
388            .collect();
389        assert_eq!(actual, expected);
390    }
391
392    #[test]
393    fn trust_and_untrust_wait_for_the_store_lock() {
394        let temp = tempfile::tempdir().unwrap();
395        let config_dir = temp.path().join("state");
396        let config = temp.path().join("osdk.toml");
397        std::fs::write(&config, "[settings]\nvalue = 1\n").unwrap();
398
399        let lock = FileLock::acquire(store_lock_path(&config_dir)).unwrap();
400        let (started_tx, started_rx) = mpsc::channel();
401        let (done_tx, done_rx) = mpsc::channel();
402        let trust_config_dir = config_dir.clone();
403        let trust_config = config.clone();
404        let handle = std::thread::spawn(move || {
405            started_tx.send(()).unwrap();
406            done_tx
407                .send(trust(&trust_config_dir, &trust_config).map(|_| ()))
408                .unwrap();
409        });
410        started_rx.recv().unwrap();
411        assert!(matches!(
412            done_rx.recv_timeout(Duration::from_millis(250)),
413            Err(mpsc::RecvTimeoutError::Timeout)
414        ));
415        drop(lock);
416        done_rx
417            .recv_timeout(Duration::from_secs(5))
418            .unwrap()
419            .unwrap();
420        handle.join().unwrap();
421
422        let lock = FileLock::acquire(store_lock_path(&config_dir)).unwrap();
423        let (started_tx, started_rx) = mpsc::channel();
424        let (done_tx, done_rx) = mpsc::channel();
425        let untrust_config_dir = config_dir.clone();
426        let untrust_config = config.clone();
427        let handle = std::thread::spawn(move || {
428            started_tx.send(()).unwrap();
429            done_tx
430                .send(untrust(&untrust_config_dir, &untrust_config))
431                .unwrap();
432        });
433        started_rx.recv().unwrap();
434        assert!(matches!(
435            done_rx.recv_timeout(Duration::from_millis(250)),
436            Err(mpsc::RecvTimeoutError::Timeout)
437        ));
438        drop(lock);
439        assert!(done_rx
440            .recv_timeout(Duration::from_secs(5))
441            .unwrap()
442            .unwrap());
443        handle.join().unwrap();
444    }
445
446    #[cfg(unix)]
447    #[test]
448    fn symlink_resolves_to_target_but_repository_move_invalidates_trust() {
449        use std::os::unix::fs::symlink;
450
451        let temp = tempfile::tempdir().unwrap();
452        let config_dir = temp.path().join("state");
453        let repo = temp.path().join("repo");
454        std::fs::create_dir_all(&repo).unwrap();
455        let config = repo.join("osdk.toml");
456        std::fs::write(&config, "[settings]\nyes = true\n").unwrap();
457        trust(&config_dir, &config).unwrap();
458
459        let link = temp.path().join("linked.toml");
460        symlink(&config, &link).unwrap();
461        assert!(is_trusted(&config_dir, &link, None).unwrap());
462
463        let moved = temp.path().join("moved");
464        std::fs::rename(&repo, &moved).unwrap();
465        assert!(!is_trusted(&config_dir, &moved.join("osdk.toml"), None).unwrap());
466    }
467
468    #[test]
469    fn safe_pins_and_aliases_do_not_require_trust() {
470        let temp = tempfile::tempdir().unwrap();
471        let path = temp.path().join("osdk.toml");
472        std::fs::write(
473            &path,
474            "[tools]\nnode = \"20\"\n[aliases.node]\ndefault = \"20\"\n",
475        )
476        .unwrap();
477        assert!(!requires_trust(&path).unwrap());
478        std::fs::write(&path, "[tools]\nnode = \"20\"\n[settings]\nyes = true\n").unwrap();
479        assert!(requires_trust(&path).unwrap());
480        std::fs::write(
481            &path,
482            "[tools]\nnode = \"20\"\n[registries.npm]\nurls = [\"https://registry.npmjs.org/\"]\n",
483        )
484        .unwrap();
485        assert!(requires_trust(&path).unwrap());
486    }
487
488    #[test]
489    fn npm_project_tool_activation_requires_trust() {
490        let temp = tempfile::tempdir().unwrap();
491        let path = temp.path().join("osdk.toml");
492        std::fs::write(
493            &path,
494            "[tools.\"npm:prettier\"]\nversion = \"3\"\ninstaller = \"aube\"\n",
495        )
496        .unwrap();
497        assert!(requires_trust(&path).unwrap());
498
499        std::fs::write(&path, "[tools]\nformatter = \"npm:prettier@3\"\n").unwrap();
500        assert!(requires_trust(&path).unwrap());
501    }
502
503    #[test]
504    fn http_project_tool_activation_requires_trust() {
505        let temp = tempfile::tempdir().unwrap();
506        let path = temp.path().join("osdk.toml");
507        std::fs::write(
508            &path,
509            "[tools.\"http:https://downloads.example.test/tool-{version}\"]\nversion = \"1.2.3\"\nsha256 = \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n",
510        )
511        .unwrap();
512        assert!(requires_trust(&path).unwrap());
513
514        std::fs::write(
515            &path,
516            "[tools]\nfixture = \"http:https://downloads.example.test/tool-{version}[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2.3\"\n",
517        )
518        .unwrap();
519        assert!(requires_trust(&path).unwrap());
520    }
521
522    #[test]
523    fn every_recognized_dynamic_namespace_requires_trust() {
524        let temp = tempfile::tempdir().unwrap();
525        let path = temp.path().join("osdk.toml");
526        for tool in [
527            "npm:prettier",
528            "github:cli/cli",
529            "http:https://downloads.example.test/tool-{version}",
530        ] {
531            std::fs::write(&path, format!("[tools]\n{tool:?} = \"1.2.3\"\n")).unwrap();
532            assert!(requires_trust(&path).unwrap(), "{tool}");
533        }
534        std::fs::write(&path, "[tools]\nfixture = \"unknown:tool@1.2.3\"\n").unwrap();
535        assert!(!requires_trust(&path).unwrap());
536    }
537}