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}