Skip to main content

pi/core/
trust.rs

1//! Project trust store and trust resolution.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/trust-manager.ts` and
4//! `project-trust.ts`. The store is a sorted-key `trust.json` map under the
5//! agent directory; resolution walks ancestors, skips `null`, and applies the
6//! fixed decision order used by coding-agent.
7
8use std::collections::BTreeMap;
9use std::env;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde_json::{Map, Value};
16use thiserror::Error;
17
18use super::config::{
19    CONFIG_DIR_NAME, PathInputOptions, canonicalize_path, resolve_path, resolve_path_with,
20};
21use super::lockfile::{LockError, LockGuard, LockOptions};
22
23/// Project config entries under `{cwd}/.pi` that require a trust decision.
24const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES: &[&str] = &[
25    "settings.json",
26    "extensions",
27    "skills",
28    "prompts",
29    "themes",
30    "SYSTEM.md",
31    "APPEND_SYSTEM.md",
32];
33
34/// Stored trust decision for a canonical project path.
35///
36/// `None` means “no saved decision” at the lookup API boundary. On disk, a JSON
37/// `null` value is only kept when already present; [`ProjectTrustStore::set`]
38/// with `None` deletes the key.
39pub type ProjectTrustDecision = Option<bool>;
40
41/// Global default used when no override, extension, or store decision applies.
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
43pub enum DefaultProjectTrust {
44    /// Prompt when UI is available; otherwise refuse trust.
45    #[default]
46    Ask,
47    /// Trust without prompting.
48    Always,
49    /// Never trust without an explicit override or store decision.
50    Never,
51}
52
53impl DefaultProjectTrust {
54    /// Parse a settings value, defaulting invalid input to [`Self::Ask`].
55    #[must_use]
56    pub fn parse(value: Option<&str>) -> Self {
57        match value {
58            Some("always") => Self::Always,
59            Some("never") => Self::Never,
60            _ => Self::Ask,
61        }
62    }
63
64    /// Wire string used in settings JSON.
65    #[must_use]
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Ask => "ask",
69            Self::Always => "always",
70            Self::Never => "never",
71        }
72    }
73}
74
75/// Extension `project_trust` decision discriminant.
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub enum ProjectTrustEventDecision {
78    /// Trust the project.
79    Yes,
80    /// Do not trust the project.
81    No,
82    /// Defer to later handlers / built-in resolution.
83    Undecided,
84}
85
86/// Result returned by an extension `project_trust` handler.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ProjectTrustExtensionResult {
89    /// Decision from the extension.
90    pub trusted: ProjectTrustEventDecision,
91    /// When true and the decision is yes/no, persist it in the trust store.
92    pub remember: bool,
93}
94
95/// Nearest saved trust entry for a path (true/false only).
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct ProjectTrustStoreEntry {
98    /// Canonical path that owns the decision.
99    pub path: PathBuf,
100    /// Saved decision.
101    pub decision: bool,
102}
103
104/// One path update applied by a trust prompt option or explicit store write.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct ProjectTrustUpdate {
107    /// Path to update (resolved/canonicalized on write).
108    pub path: PathBuf,
109    /// `Some(true|false)` writes; `None` deletes the key.
110    pub decision: ProjectTrustDecision,
111}
112
113/// One selectable trust prompt option.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct ProjectTrustOption {
116    /// Exact UI label text.
117    pub label: String,
118    /// Whether selecting this option trusts the project for the current run.
119    pub trusted: bool,
120    /// Store updates to apply when the option is chosen.
121    pub updates: Vec<ProjectTrustUpdate>,
122    /// Path that was permanently saved, when any.
123    pub saved_path: Option<PathBuf>,
124}
125
126/// Sync UI surface used by [`resolve_project_trusted`].
127///
128/// Interactive adapters implement selection; non-UI modes report `has_ui() == false`.
129pub trait TrustUi {
130    /// Whether an interactive selector is available.
131    fn has_ui(&self) -> bool;
132
133    /// Present `prompt` with `options` and return the selected label, or `None` on cancel.
134    fn select(&mut self, prompt: &str, options: &[String]) -> Option<String>;
135}
136
137/// Errors from trust store I/O and validation.
138#[derive(Debug, Error)]
139pub enum TrustError {
140    /// Trust file could not be read or parsed as JSON text.
141    #[error("Failed to read trust store {path}: {message}")]
142    Read {
143        /// Trust file path.
144        path: String,
145        /// Underlying error message.
146        message: String,
147    },
148    /// Trust file root was not a JSON object.
149    #[error("Invalid trust store {path}: expected an object")]
150    InvalidObject {
151        /// Trust file path.
152        path: String,
153    },
154    /// A trust entry value was not `true`, `false`, or `null`.
155    #[error("Invalid trust store {path}: value for {key} must be true, false, or null")]
156    InvalidValue {
157        /// Trust file path.
158        path: String,
159        /// JSON-stringified map key (includes quotes).
160        key: String,
161    },
162    /// Exclusive trust lock could not be acquired.
163    #[error("Failed to acquire trust store lock")]
164    Lock,
165    /// Filesystem failure while writing the trust file or creating parents.
166    #[error("Failed to write trust store {path}: {message}")]
167    Write {
168        /// Trust file path.
169        path: String,
170        /// Underlying error message.
171        message: String,
172    },
173}
174
175impl From<LockError> for TrustError {
176    fn from(_value: LockError) -> Self {
177        Self::Lock
178    }
179}
180
181/// Optional extension trust hook used during project trust resolution.
182pub type ProjectTrustExtensionHook<'a> =
183    &'a mut dyn FnMut(&Path) -> Result<Option<ProjectTrustExtensionResult>, String>;
184
185/// Options for [`resolve_project_trusted`].
186pub struct ResolveProjectTrustedOptions<'a> {
187    /// Project working directory under consideration.
188    pub cwd: PathBuf,
189    /// Persistent trust store.
190    pub trust_store: &'a ProjectTrustStore,
191    /// CLI `--approve` / `--no-approve` override.
192    pub trust_override: Option<bool>,
193    /// Global `defaultProjectTrust` setting (defaults to ask).
194    pub default_project_trust: DefaultProjectTrust,
195    /// Optional extension `project_trust` hook.
196    ///
197    /// Invoked with the project cwd. Return `Ok(Some(result))` when an extension
198    /// produced a decision payload; `trusted: Undecided` continues the built-in
199    /// flow. Return `Ok(None)` when no extension handled the event.
200    /// `Err(message)` is reported through [`Self::on_extension_error`] and
201    /// treated as no extension decision.
202    pub extension_hook: Option<ProjectTrustExtensionHook<'a>>,
203    /// Optional interactive UI. When absent or `has_ui() == false`, ask-mode
204    /// falls through to `false`.
205    pub ui: Option<&'a mut dyn TrustUi>,
206    /// Receives extension error strings in the exact coding-agent format.
207    pub on_extension_error: Option<&'a mut dyn FnMut(String)>,
208}
209
210/// Sorted-key project trust store at `{agentDir}/trust.json`.
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct ProjectTrustStore {
213    trust_path: PathBuf,
214}
215
216impl ProjectTrustStore {
217    /// Create a store rooted at `agent_dir` (`{agentDir}/trust.json`).
218    #[must_use]
219    pub fn new(agent_dir: impl AsRef<Path>) -> Self {
220        let agent = path_to_string(agent_dir.as_ref());
221        let resolved = resolve_path(&agent);
222        Self {
223            trust_path: resolved.join("trust.json"),
224        }
225    }
226
227    /// Path of the on-disk trust file.
228    #[must_use]
229    pub fn path(&self) -> &Path {
230        &self.trust_path
231    }
232
233    /// Lookup the effective decision for `cwd`, walking ancestors and skipping null.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`TrustError`] when the lock cannot be acquired or the file is invalid.
238    pub fn get(&self, cwd: impl AsRef<Path>) -> Result<ProjectTrustDecision, TrustError> {
239        Ok(self.get_entry(cwd)?.map(|entry| entry.decision))
240    }
241
242    /// Lookup the nearest ancestor entry with a true/false decision.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`TrustError`] when the lock cannot be acquired or the file is invalid.
247    pub fn get_entry(
248        &self,
249        cwd: impl AsRef<Path>,
250    ) -> Result<Option<ProjectTrustStoreEntry>, TrustError> {
251        self.with_lock(|data| Ok(find_nearest_trust_entry(data, cwd.as_ref())))
252    }
253
254    /// Persist a single path decision (`None` deletes the key).
255    ///
256    /// # Errors
257    ///
258    /// Returns [`TrustError`] on lock, read, validation, or write failure.
259    pub fn set(
260        &self,
261        cwd: impl AsRef<Path>,
262        decision: ProjectTrustDecision,
263    ) -> Result<(), TrustError> {
264        self.set_many([ProjectTrustUpdate {
265            path: cwd.as_ref().to_path_buf(),
266            decision,
267        }])
268    }
269
270    /// Persist multiple path decisions under one lock.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`TrustError`] on lock, read, validation, or write failure.
275    pub fn set_many(
276        &self,
277        decisions: impl IntoIterator<Item = ProjectTrustUpdate>,
278    ) -> Result<(), TrustError> {
279        self.with_lock(|data| {
280            for update in decisions {
281                let key = path_key(&update.path);
282                match update.decision {
283                    None => {
284                        data.remove(&key);
285                    }
286                    Some(value) => {
287                        data.insert(key, Some(value));
288                    }
289                }
290            }
291            write_trust_file(&self.trust_path, data)?;
292            Ok(())
293        })
294    }
295
296    fn with_lock<T>(
297        &self,
298        f: impl FnOnce(&mut TrustFile) -> Result<T, TrustError>,
299    ) -> Result<T, TrustError> {
300        let _guard = acquire_trust_lock(&self.trust_path)?;
301        let mut data = read_trust_file(&self.trust_path)?;
302        f(&mut data)
303    }
304}
305
306/// Parent directory of a trust path when one exists.
307#[must_use]
308pub fn get_project_trust_parent_path(cwd: impl AsRef<Path>) -> Option<PathBuf> {
309    let trust_path = normalize_cwd(cwd.as_ref());
310    let parent = trust_path.parent()?;
311    if parent.as_os_str().is_empty() || parent == trust_path {
312        None
313    } else {
314        // On Unix, `parent()` of `/` is `Some("")` filtered above; of `/a` is `/`.
315        // Match Node `dirname` root fixed-point via normalize equality.
316        let parent_norm = normalize_cwd(parent);
317        if parent_norm == trust_path {
318            None
319        } else {
320            Some(parent_norm)
321        }
322    }
323}
324
325/// Build the exact trust prompt options for `cwd`.
326#[must_use]
327pub fn get_project_trust_options(
328    cwd: impl AsRef<Path>,
329    include_session_only: bool,
330) -> Vec<ProjectTrustOption> {
331    let trust_path = normalize_cwd(cwd.as_ref());
332    let mut options = vec![ProjectTrustOption {
333        label: "Trust".to_owned(),
334        trusted: true,
335        updates: vec![ProjectTrustUpdate {
336            path: trust_path.clone(),
337            decision: Some(true),
338        }],
339        saved_path: Some(trust_path.clone()),
340    }];
341
342    if let Some(parent_path) = get_project_trust_parent_path(&trust_path) {
343        options.push(ProjectTrustOption {
344            label: format!("Trust parent folder ({})", path_to_string(&parent_path)),
345            trusted: true,
346            updates: vec![
347                ProjectTrustUpdate {
348                    path: parent_path.clone(),
349                    decision: Some(true),
350                },
351                ProjectTrustUpdate {
352                    path: trust_path.clone(),
353                    decision: None,
354                },
355            ],
356            saved_path: Some(parent_path),
357        });
358    }
359
360    if include_session_only {
361        options.push(ProjectTrustOption {
362            label: "Trust (this session only)".to_owned(),
363            trusted: true,
364            updates: Vec::new(),
365            saved_path: None,
366        });
367    }
368
369    options.push(ProjectTrustOption {
370        label: "Do not trust".to_owned(),
371        trusted: false,
372        updates: vec![ProjectTrustUpdate {
373            path: trust_path.clone(),
374            decision: Some(false),
375        }],
376        saved_path: Some(trust_path),
377    });
378
379    if include_session_only {
380        options.push(ProjectTrustOption {
381            label: "Do not trust (this session only)".to_owned(),
382            trusted: false,
383            updates: Vec::new(),
384            saved_path: None,
385        });
386    }
387
388    options
389}
390
391/// Exact trust prompt title string for `cwd`.
392#[must_use]
393pub fn format_project_trust_prompt(cwd: impl AsRef<Path>) -> String {
394    let cwd_display = path_to_string(cwd.as_ref());
395    format!(
396        "Trust project folder?\n{cwd_display}\n\nThis allows pi to load {CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions."
397    )
398}
399
400/// Returns true when `cwd` has project-local resources that require trust.
401///
402/// Detects trust-requiring entries under `{cwd}/.pi`, or `.agents/skills` in
403/// `cwd` or an ancestor, excluding the user-global `~/.agents/skills` directory.
404#[must_use]
405pub fn has_trust_requiring_project_resources(cwd: impl AsRef<Path>) -> bool {
406    let home = process_home_path();
407    has_trust_requiring_project_resources_with(cwd.as_ref(), home.as_deref())
408}
409
410/// [`has_trust_requiring_project_resources`] with an explicit home directory.
411#[must_use]
412pub fn has_trust_requiring_project_resources_with(cwd: &Path, home_dir: Option<&Path>) -> bool {
413    let home_canonical = home_dir.map(|home| {
414        let home_str = path_to_string(home);
415        canonicalize_path(resolve_path_with(
416            &home_str,
417            Path::new("/"),
418            PathInputOptions::new()
419                .home_dir(Some(home))
420                .expand_tilde(false),
421        ))
422    });
423    let user_agents_skills = home_canonical
424        .as_ref()
425        .map(|home| home.join(".agents").join("skills"));
426
427    let mut current_dir = normalize_cwd_with(cwd, home_dir);
428
429    let config_dir = current_dir.join(CONFIG_DIR_NAME);
430    if TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES
431        .iter()
432        .any(|entry| config_dir.join(entry).exists())
433    {
434        return true;
435    }
436
437    loop {
438        let agents_skills_dir = current_dir.join(".agents").join("skills");
439        let is_user_global = user_agents_skills
440            .as_ref()
441            .is_some_and(|user| agents_skills_dir == *user);
442        if !is_user_global && agents_skills_dir.exists() {
443            return true;
444        }
445
446        let Some(parent) = current_dir.parent() else {
447            return false;
448        };
449        if parent.as_os_str().is_empty() || parent == current_dir.as_path() {
450            return false;
451        }
452        let parent_norm = normalize_cwd_with(parent, home_dir);
453        if parent_norm == current_dir {
454            return false;
455        }
456        current_dir = parent_norm;
457    }
458}
459
460/// Resolve whether the project at `options.cwd` is trusted.
461///
462/// Decision order:
463/// 1. `trust_override`
464/// 2. no trust-requiring resources → true
465/// 3. extension hook yes/no (optional remember)
466/// 4. store ancestor decision
467/// 5. `default_project_trust` always/never
468/// 6. no UI → false
469/// 7. UI selection (apply updates)
470/// 8. cancel → false
471///
472/// # Errors
473///
474/// Returns [`TrustError`] when the trust store cannot be read or written.
475pub fn resolve_project_trusted(
476    options: ResolveProjectTrustedOptions<'_>,
477) -> Result<bool, TrustError> {
478    if let Some(override_value) = options.trust_override {
479        return Ok(override_value);
480    }
481
482    if !has_trust_requiring_project_resources(&options.cwd) {
483        return Ok(true);
484    }
485
486    if let Some(hook) = options.extension_hook {
487        match hook(&options.cwd) {
488            Ok(Some(result)) => match result.trusted {
489                ProjectTrustEventDecision::Yes => {
490                    if result.remember {
491                        options.trust_store.set(&options.cwd, Some(true))?;
492                    }
493                    return Ok(true);
494                }
495                ProjectTrustEventDecision::No => {
496                    if result.remember {
497                        options.trust_store.set(&options.cwd, Some(false))?;
498                    }
499                    return Ok(false);
500                }
501                ProjectTrustEventDecision::Undecided => {}
502            },
503            Ok(None) => {}
504            Err(message) => {
505                if let Some(on_error) = options.on_extension_error {
506                    on_error(message);
507                }
508            }
509        }
510    }
511
512    if let Some(decision) = options.trust_store.get(&options.cwd)? {
513        return Ok(decision);
514    }
515
516    match options.default_project_trust {
517        DefaultProjectTrust::Always => return Ok(true),
518        DefaultProjectTrust::Never => return Ok(false),
519        DefaultProjectTrust::Ask => {}
520    }
521
522    let Some(ui) = options.ui else {
523        return Ok(false);
524    };
525    if !ui.has_ui() {
526        return Ok(false);
527    }
528
529    let prompt_options = get_project_trust_options(&options.cwd, true);
530    let labels: Vec<String> = prompt_options
531        .iter()
532        .map(|option| option.label.clone())
533        .collect();
534    let prompt = format_project_trust_prompt(&options.cwd);
535    let selected_label = ui.select(&prompt, &labels);
536    if let Some(label) = selected_label
537        && let Some(selected) = prompt_options
538            .into_iter()
539            .find(|option| option.label == label)
540    {
541        if !selected.updates.is_empty() {
542            options.trust_store.set_many(selected.updates)?;
543        }
544        return Ok(selected.trusted);
545    }
546
547    Ok(false)
548}
549
550type TrustFile = BTreeMap<String, Option<bool>>;
551
552fn acquire_trust_lock(trust_path: &Path) -> Result<LockGuard, TrustError> {
553    let trust_dir = trust_path
554        .parent()
555        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
556    if let Err(error) = fs::create_dir_all(&trust_dir) {
557        return Err(TrustError::Write {
558            path: path_to_string(trust_path),
559            message: error.to_string(),
560        });
561    }
562    let lockfile_path = trust_lock_path(trust_path);
563    let options = LockOptions::new().lockfile_path(lockfile_path);
564    LockGuard::acquire_with(&trust_dir, &options).map_err(TrustError::from)
565}
566
567fn trust_lock_path(trust_path: &Path) -> PathBuf {
568    let mut lock = trust_path.as_os_str().to_os_string();
569    lock.push(".lock");
570    PathBuf::from(lock)
571}
572
573fn read_trust_file(path: &Path) -> Result<TrustFile, TrustError> {
574    if !path.exists() {
575        return Ok(TrustFile::new());
576    }
577
578    let text = fs::read_to_string(path).map_err(|error| TrustError::Read {
579        path: path_to_string(path),
580        message: error.to_string(),
581    })?;
582
583    let parsed: Value = serde_json::from_str(&text).map_err(|error| TrustError::Read {
584        path: path_to_string(path),
585        message: error.to_string(),
586    })?;
587
588    let Value::Object(object) = parsed else {
589        return Err(TrustError::InvalidObject {
590            path: path_to_string(path),
591        });
592    };
593
594    let mut data = TrustFile::new();
595    for (key, value) in object {
596        let decision = match value {
597            Value::Bool(flag) => Some(flag),
598            Value::Null => None,
599            _ => {
600                return Err(TrustError::InvalidValue {
601                    path: path_to_string(path),
602                    key: json_string_key(&key),
603                });
604            }
605        };
606        // Preserve null entries that were already on disk so a pure rewrite
607        // does not invent deletions; writers that intend delete remove keys.
608        data.insert(key, decision);
609    }
610    Ok(data)
611}
612
613fn write_trust_file(path: &Path, data: &TrustFile) -> Result<(), TrustError> {
614    let mut sorted = Map::new();
615    for (key, value) in data {
616        let json_value = match value {
617            Some(flag) => Value::Bool(*flag),
618            None => Value::Null,
619        };
620        sorted.insert(key.clone(), json_value);
621    }
622
623    if let Some(parent) = path.parent() {
624        fs::create_dir_all(parent).map_err(|error| TrustError::Write {
625            path: path_to_string(path),
626            message: error.to_string(),
627        })?;
628    }
629
630    let body = serde_json::to_string_pretty(&Value::Object(sorted)).map_err(|error| {
631        TrustError::Write {
632            path: path_to_string(path),
633            message: error.to_string(),
634        }
635    })?;
636    let mut bytes = body.into_bytes();
637    bytes.push(b'\n');
638    atomic_write_trust_file(path, &bytes).map_err(|error| TrustError::Write {
639        path: path_to_string(path),
640        message: error.to_string(),
641    })
642}
643
644fn atomic_write_trust_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
645    let parent = path
646        .parent()
647        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "trust path has no parent"))?;
648    let nonce = SystemTime::now()
649        .duration_since(UNIX_EPOCH)
650        .unwrap_or_default()
651        .as_nanos();
652    let file_name = path.file_name().map_or_else(
653        || "trust.json".to_owned(),
654        |name| name.to_string_lossy().into_owned(),
655    );
656    let temporary = parent.join(format!(".{file_name}.tmp.{}.{nonce}", std::process::id()));
657
658    let result = (|| {
659        let mut file = OpenOptions::new()
660            .write(true)
661            .create_new(true)
662            .open(&temporary)?;
663        file.write_all(bytes)?;
664        file.sync_all()?;
665        drop(file);
666        fs::rename(&temporary, path)?;
667        sync_parent_directory(parent)
668    })();
669    if result.is_err() {
670        let _ = fs::remove_file(&temporary);
671    }
672    result
673}
674
675#[cfg(unix)]
676fn sync_parent_directory(parent: &Path) -> io::Result<()> {
677    File::open(parent)?.sync_all()
678}
679
680#[cfg(not(unix))]
681fn sync_parent_directory(_parent: &Path) -> io::Result<()> {
682    Ok(())
683}
684
685fn find_nearest_trust_entry(data: &TrustFile, cwd: &Path) -> Option<ProjectTrustStoreEntry> {
686    let mut current_dir = normalize_cwd(cwd);
687    loop {
688        let key = path_to_string(&current_dir);
689        if let Some(Some(decision)) = data.get(&key) {
690            return Some(ProjectTrustStoreEntry {
691                path: current_dir,
692                decision: *decision,
693            });
694        }
695
696        let parent = current_dir.parent()?;
697        if parent.as_os_str().is_empty() {
698            return None;
699        }
700        let parent_norm = normalize_cwd(parent);
701        if parent_norm == current_dir {
702            return None;
703        }
704        current_dir = parent_norm;
705    }
706}
707
708fn normalize_cwd(cwd: &Path) -> PathBuf {
709    normalize_cwd_with(cwd, process_home_path().as_deref())
710}
711
712fn normalize_cwd_with(cwd: &Path, home_dir: Option<&Path>) -> PathBuf {
713    let cwd_str = path_to_string(cwd);
714    let process_cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
715    let resolved = resolve_path_with(
716        &cwd_str,
717        &process_cwd,
718        PathInputOptions::new().home_dir(home_dir),
719    );
720    canonicalize_path(resolved)
721}
722
723fn path_key(path: &Path) -> String {
724    path_to_string(&normalize_cwd(path))
725}
726
727fn path_to_string(path: &Path) -> String {
728    path.to_string_lossy().into_owned()
729}
730
731fn json_string_key(key: &str) -> String {
732    serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""))
733}
734
735fn process_home_path() -> Option<PathBuf> {
736    env::var_os("HOME")
737        .map(PathBuf::from)
738        .or_else(dirs::home_dir)
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use std::sync::Arc;
745    use std::thread;
746    use std::time::{SystemTime, UNIX_EPOCH};
747
748    type TestResult = Result<(), String>;
749
750    fn unique_temp_dir(label: &str) -> Result<PathBuf, String> {
751        let nanos = SystemTime::now()
752            .duration_since(UNIX_EPOCH)
753            .map_err(|error| error.to_string())?
754            .as_nanos();
755        let dir = env::temp_dir().join(format!("pi-trust-{label}-{nanos}"));
756        fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
757        Ok(dir)
758    }
759
760    fn write_resource(path: &Path) -> Result<(), String> {
761        if let Some(parent) = path.parent() {
762            fs::create_dir_all(parent).map_err(|error| error.to_string())?;
763        }
764        fs::write(path, b"{}").map_err(|error| error.to_string())
765    }
766
767    fn trust_fixture(label: &str) -> Result<(PathBuf, PathBuf, ProjectTrustStore), String> {
768        let root = unique_temp_dir(label)?;
769        let agent_dir = root.join("agent");
770        let project = root.join("project");
771        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
772        fs::create_dir_all(&project).map_err(|error| error.to_string())?;
773        write_resource(&project.join(".pi").join("settings.json"))?;
774        let store = ProjectTrustStore::new(&agent_dir);
775        Ok((root, project, store))
776    }
777
778    fn resolve_without_extensions(
779        cwd: &Path,
780        store: &ProjectTrustStore,
781        trust_override: Option<bool>,
782        default_project_trust: DefaultProjectTrust,
783    ) -> Result<bool, String> {
784        resolve_project_trusted(ResolveProjectTrustedOptions {
785            cwd: cwd.to_path_buf(),
786            trust_store: store,
787            trust_override,
788            default_project_trust,
789            extension_hook: None,
790            ui: None,
791            on_extension_error: None,
792        })
793        .map_err(|error| error.to_string())
794    }
795
796    fn require_error<T, E>(result: Result<T, E>, message: &str) -> Result<E, String> {
797        match result {
798            Ok(_) => Err(message.to_owned()),
799            Err(error) => Ok(error),
800        }
801    }
802
803    struct ScriptedUi {
804        has_ui: bool,
805        choice: Option<String>,
806        last_prompt: Option<String>,
807        last_options: Vec<String>,
808    }
809
810    impl TrustUi for ScriptedUi {
811        fn has_ui(&self) -> bool {
812            self.has_ui
813        }
814
815        fn select(&mut self, prompt: &str, options: &[String]) -> Option<String> {
816            self.last_prompt = Some(prompt.to_owned());
817            self.last_options = options.to_vec();
818            self.choice.clone()
819        }
820    }
821
822    #[test]
823    fn stores_decisions_and_inherits_from_parent_directories() -> TestResult {
824        let root = unique_temp_dir("inherit")?;
825        let agent_dir = root.join("agent");
826        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
827        let parent_dir = root.join("trusted-parent");
828        let child_dir = parent_dir.join("project");
829        fs::create_dir_all(&child_dir).map_err(|error| error.to_string())?;
830
831        let store = ProjectTrustStore::new(&agent_dir);
832        assert_eq!(store.get(&child_dir).map_err(|e| e.to_string())?, None);
833
834        store
835            .set(&parent_dir, Some(true))
836            .map_err(|e| e.to_string())?;
837        assert_eq!(
838            store.get(&child_dir).map_err(|e| e.to_string())?,
839            Some(true)
840        );
841
842        store
843            .set(&child_dir, Some(false))
844            .map_err(|e| e.to_string())?;
845        assert_eq!(
846            store.get(&child_dir).map_err(|e| e.to_string())?,
847            Some(false)
848        );
849
850        store.set(&child_dir, None).map_err(|e| e.to_string())?;
851        assert_eq!(
852            store.get(&child_dir).map_err(|e| e.to_string())?,
853            Some(true)
854        );
855
856        let _ = fs::remove_dir_all(root);
857        Ok(())
858    }
859
860    #[test]
861    fn null_entries_are_skipped_during_ancestor_walk() -> TestResult {
862        let root = unique_temp_dir("null-skip")?;
863        let agent_dir = root.join("agent");
864        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
865        let parent = root.join("parent");
866        let child = parent.join("child");
867        fs::create_dir_all(&child).map_err(|error| error.to_string())?;
868
869        let store = ProjectTrustStore::new(&agent_dir);
870        store.set(&parent, Some(true)).map_err(|e| e.to_string())?;
871
872        // Inject an explicit null for child by writing the file under lock.
873        {
874            let text = fs::read_to_string(store.path()).map_err(|e| e.to_string())?;
875            let mut value: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
876            let object = value
877                .as_object_mut()
878                .ok_or_else(|| "expected object".to_owned())?;
879            object.insert(path_key(&child), Value::Null);
880            let body = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?;
881            fs::write(store.path(), format!("{body}\n")).map_err(|e| e.to_string())?;
882        }
883
884        assert_eq!(store.get(&child).map_err(|e| e.to_string())?, Some(true));
885        let entry = store.get_entry(&child).map_err(|e| e.to_string())?;
886        assert_eq!(entry.map(|e| e.path), Some(normalize_cwd(&parent)));
887
888        let _ = fs::remove_dir_all(root);
889        Ok(())
890    }
891
892    #[test]
893    fn set_null_deletes_key_and_serialization_is_sorted_with_trailing_newline() -> TestResult {
894        let root = unique_temp_dir("serialize")?;
895        let agent_dir = root.join("agent");
896        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
897        let store = ProjectTrustStore::new(&agent_dir);
898
899        let zed = root.join("zed");
900        let alpha = root.join("alpha");
901        fs::create_dir_all(&zed).map_err(|error| error.to_string())?;
902        fs::create_dir_all(&alpha).map_err(|error| error.to_string())?;
903
904        store.set(&zed, Some(false)).map_err(|e| e.to_string())?;
905        store.set(&alpha, Some(true)).map_err(|e| e.to_string())?;
906        store.set(&zed, None).map_err(|e| e.to_string())?;
907
908        let raw = fs::read_to_string(store.path()).map_err(|e| e.to_string())?;
909        assert!(raw.ends_with('\n'), "missing trailing newline: {raw:?}");
910        assert!(!raw.contains(&path_key(&zed)), "null delete must drop key");
911
912        let alpha_key = path_key(&alpha);
913        let expected = format!("{{\n  {key}: true\n}}\n", key = json_string_key(&alpha_key));
914        // Pretty JSON may format bool without space issues; compare parsed + trailing newline.
915        assert!(raw.ends_with('\n'));
916        let parsed: Value = serde_json::from_str(raw.trim_end()).map_err(|e| e.to_string())?;
917        let object = parsed
918            .as_object()
919            .ok_or_else(|| "expected object".to_owned())?;
920        let keys: Vec<&String> = object.keys().collect();
921        assert_eq!(keys, vec![&alpha_key]);
922        assert_eq!(object.get(&alpha_key), Some(&Value::Bool(true)));
923
924        // Ensure lock artifact is sibling trust.json.lock (not agent.lock).
925        let lock_path = trust_lock_path(store.path());
926        assert_eq!(
927            lock_path.file_name().and_then(|n| n.to_str()),
928            Some("trust.json.lock")
929        );
930        assert!(
931            !lock_path.exists(),
932            "lock directory must not remain after write"
933        );
934
935        let entries = fs::read_dir(&agent_dir)
936            .map_err(|error| error.to_string())?
937            .collect::<Result<Vec<_>, _>>()
938            .map_err(|error| error.to_string())?;
939        assert!(
940            entries
941                .iter()
942                .all(|entry| !entry.file_name().to_string_lossy().contains(".tmp.")),
943            "atomic writer left a temporary file"
944        );
945
946        let _ = expected; // keep construction for readability of expected shape
947        let _ = fs::remove_dir_all(root);
948        Ok(())
949    }
950
951    #[test]
952    fn lock_targets_parent_dir_with_trust_json_lock_artifact() -> TestResult {
953        let root = unique_temp_dir("lock-artifact")?;
954        let agent_dir = root.join("agent");
955        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
956        let store = ProjectTrustStore::new(&agent_dir);
957        let trust_path = store.path().to_path_buf();
958        let lock_path = trust_lock_path(&trust_path);
959
960        // Hold the trust lock the same way the store does and assert paths.
961        let guard = acquire_trust_lock(&trust_path).map_err(|e| e.to_string())?;
962        assert_eq!(guard.lock_path(), lock_path.as_path());
963        assert!(lock_path.is_dir());
964        assert_eq!(
965            guard.target(),
966            trust_path.parent().ok_or_else(|| "parent".to_owned())?
967        );
968        drop(guard);
969        assert!(!lock_path.exists());
970
971        let _ = fs::remove_dir_all(root);
972        Ok(())
973    }
974
975    #[test]
976    fn detects_trust_requiring_project_resources() -> TestResult {
977        let root = unique_temp_dir("resources")?;
978        let project = root.join("project");
979        fs::create_dir_all(&project).map_err(|error| error.to_string())?;
980        fs::create_dir_all(root.join(".pi").join("agent")).map_err(|error| error.to_string())?;
981        fs::create_dir_all(root.join(".agents").join("skills"))
982            .map_err(|error| error.to_string())?;
983
984        assert!(
985            !has_trust_requiring_project_resources_with(&root, Some(&root)),
986            "user-global ~/.agents/skills and bare .pi/agent must be ignored"
987        );
988        assert!(!has_trust_requiring_project_resources_with(
989            &project,
990            Some(&root)
991        ));
992
993        write_resource(&root.join(".pi").join("settings.json"))?;
994        assert!(has_trust_requiring_project_resources_with(
995            &root,
996            Some(&root)
997        ));
998        fs::remove_file(root.join(".pi").join("settings.json")).map_err(|e| e.to_string())?;
999
1000        write_resource(&project.join(".pi").join("settings.json"))?;
1001        assert!(has_trust_requiring_project_resources_with(
1002            &project,
1003            Some(&root)
1004        ));
1005
1006        fs::remove_dir_all(project.join(".pi")).map_err(|e| e.to_string())?;
1007        fs::create_dir_all(project.join(".agents").join("skills")).map_err(|e| e.to_string())?;
1008        assert!(has_trust_requiring_project_resources_with(
1009            &project,
1010            Some(&root)
1011        ));
1012
1013        let _ = fs::remove_dir_all(root);
1014        Ok(())
1015    }
1016
1017    #[test]
1018    fn project_trust_options_labels_and_updates() -> TestResult {
1019        let root = unique_temp_dir("options")?;
1020        let cwd = root.join("proj");
1021        fs::create_dir_all(&cwd).map_err(|error| error.to_string())?;
1022        let options = get_project_trust_options(&cwd, true);
1023        let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
1024        let parent = get_project_trust_parent_path(&cwd).ok_or("parent")?;
1025        assert_eq!(
1026            labels,
1027            vec![
1028                "Trust",
1029                format!("Trust parent folder ({})", path_to_string(&parent)).as_str(),
1030                "Trust (this session only)",
1031                "Do not trust",
1032                "Do not trust (this session only)",
1033            ]
1034        );
1035
1036        assert!(options[0].trusted);
1037        assert_eq!(options[0].updates.len(), 1);
1038        assert_eq!(options[0].updates[0].decision, Some(true));
1039
1040        assert!(options[1].trusted);
1041        assert_eq!(options[1].updates.len(), 2);
1042        assert_eq!(options[1].updates[0].decision, Some(true));
1043        assert_eq!(options[1].updates[1].decision, None);
1044
1045        assert!(options[2].updates.is_empty());
1046        assert!(!options[3].trusted);
1047        assert_eq!(options[3].updates[0].decision, Some(false));
1048        assert!(options[4].updates.is_empty());
1049
1050        let prompt = format_project_trust_prompt(&cwd);
1051        assert_eq!(
1052            prompt,
1053            format!(
1054                "Trust project folder?\n{}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.",
1055                path_to_string(&cwd)
1056            )
1057        );
1058
1059        let _ = fs::remove_dir_all(root);
1060        Ok(())
1061    }
1062
1063    #[test]
1064    fn resolve_order_override_resources_defaults_and_non_ui() -> TestResult {
1065        let (root, project, store) = trust_fixture("resolve-basic")?;
1066
1067        assert!(resolve_without_extensions(
1068            &project,
1069            &store,
1070            Some(true),
1071            DefaultProjectTrust::Never,
1072        )?);
1073
1074        let empty = root.join("empty");
1075        fs::create_dir_all(&empty).map_err(|error| error.to_string())?;
1076        assert!(resolve_without_extensions(
1077            &empty,
1078            &store,
1079            None,
1080            DefaultProjectTrust::Never,
1081        )?);
1082
1083        assert!(resolve_without_extensions(
1084            &project,
1085            &store,
1086            None,
1087            DefaultProjectTrust::Always,
1088        )?);
1089        assert!(!resolve_without_extensions(
1090            &project,
1091            &store,
1092            None,
1093            DefaultProjectTrust::Never,
1094        )?);
1095        assert!(!resolve_without_extensions(
1096            &project,
1097            &store,
1098            None,
1099            DefaultProjectTrust::Ask,
1100        )?);
1101
1102        let mut no_ui = ScriptedUi {
1103            has_ui: false,
1104            choice: Some("Trust".to_owned()),
1105            last_prompt: None,
1106            last_options: Vec::new(),
1107        };
1108        let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1109            cwd: project,
1110            trust_store: &store,
1111            trust_override: None,
1112            default_project_trust: DefaultProjectTrust::Ask,
1113            extension_hook: None,
1114            ui: Some(&mut no_ui),
1115            on_extension_error: None,
1116        })
1117        .map_err(|error| error.to_string())?;
1118        assert!(!trusted);
1119
1120        let _ = fs::remove_dir_all(root);
1121        Ok(())
1122    }
1123
1124    #[test]
1125    fn resolve_order_extension_remember_then_store() -> TestResult {
1126        let (root, project, store) = trust_fixture("resolve-extension")?;
1127        let mut hook = |cwd: &Path| {
1128            assert_eq!(cwd, project.as_path());
1129            Ok(Some(ProjectTrustExtensionResult {
1130                trusted: ProjectTrustEventDecision::Yes,
1131                remember: true,
1132            }))
1133        };
1134        let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1135            cwd: project.clone(),
1136            trust_store: &store,
1137            trust_override: None,
1138            default_project_trust: DefaultProjectTrust::Never,
1139            extension_hook: Some(&mut hook),
1140            ui: None,
1141            on_extension_error: None,
1142        })
1143        .map_err(|error| error.to_string())?;
1144        assert!(trusted);
1145        assert_eq!(store.get(&project).map_err(|e| e.to_string())?, Some(true));
1146
1147        store
1148            .set(&project, Some(false))
1149            .map_err(|error| error.to_string())?;
1150        let mut undecided = |_cwd: &Path| {
1151            Ok(Some(ProjectTrustExtensionResult {
1152                trusted: ProjectTrustEventDecision::Undecided,
1153                remember: false,
1154            }))
1155        };
1156        let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1157            cwd: project,
1158            trust_store: &store,
1159            trust_override: None,
1160            default_project_trust: DefaultProjectTrust::Always,
1161            extension_hook: Some(&mut undecided),
1162            ui: None,
1163            on_extension_error: None,
1164        })
1165        .map_err(|error| error.to_string())?;
1166        assert!(!trusted);
1167
1168        let _ = fs::remove_dir_all(root);
1169        Ok(())
1170    }
1171
1172    #[test]
1173    fn resolve_order_selection_updates_and_cancel() -> TestResult {
1174        let (root, project, store) = trust_fixture("resolve-ui")?;
1175        let mut ui = ScriptedUi {
1176            has_ui: true,
1177            choice: Some("Trust".to_owned()),
1178            last_prompt: None,
1179            last_options: Vec::new(),
1180        };
1181        let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1182            cwd: project.clone(),
1183            trust_store: &store,
1184            trust_override: None,
1185            default_project_trust: DefaultProjectTrust::Ask,
1186            extension_hook: None,
1187            ui: Some(&mut ui),
1188            on_extension_error: None,
1189        })
1190        .map_err(|error| error.to_string())?;
1191        assert!(trusted);
1192        assert_eq!(store.get(&project).map_err(|e| e.to_string())?, Some(true));
1193        let expected_prompt = format_project_trust_prompt(&project);
1194        assert_eq!(ui.last_prompt.as_deref(), Some(expected_prompt.as_str()));
1195
1196        store
1197            .set(&project, None)
1198            .map_err(|error| error.to_string())?;
1199        let mut cancel_ui = ScriptedUi {
1200            has_ui: true,
1201            choice: None,
1202            last_prompt: None,
1203            last_options: Vec::new(),
1204        };
1205        let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1206            cwd: project.clone(),
1207            trust_store: &store,
1208            trust_override: None,
1209            default_project_trust: DefaultProjectTrust::Ask,
1210            extension_hook: None,
1211            ui: Some(&mut cancel_ui),
1212            on_extension_error: None,
1213        })
1214        .map_err(|error| error.to_string())?;
1215        assert!(!trusted);
1216        assert_eq!(store.get(&project).map_err(|e| e.to_string())?, None);
1217
1218        let _ = fs::remove_dir_all(root);
1219        Ok(())
1220    }
1221
1222    #[test]
1223    fn extension_errors_are_reported_and_do_not_abort_resolution() -> TestResult {
1224        let root = unique_temp_dir("ext-err")?;
1225        let agent_dir = root.join("agent");
1226        let project = root.join("project");
1227        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1228        fs::create_dir_all(&project).map_err(|error| error.to_string())?;
1229        write_resource(&project.join(".pi").join("SYSTEM.md"))?;
1230        let store = ProjectTrustStore::new(&agent_dir);
1231
1232        let mut errors = Vec::new();
1233        let mut on_error = |message: String| errors.push(message);
1234        let mut hook = |_cwd: &Path| -> Result<Option<ProjectTrustExtensionResult>, String> {
1235            Err("Extension \"/tmp/ext.ts\" project_trust error: boom".to_owned())
1236        };
1237
1238        let trusted = resolve_project_trusted(ResolveProjectTrustedOptions {
1239            cwd: project,
1240            trust_store: &store,
1241            trust_override: None,
1242            default_project_trust: DefaultProjectTrust::Always,
1243            extension_hook: Some(&mut hook),
1244            ui: None,
1245            on_extension_error: Some(&mut on_error),
1246        })
1247        .map_err(|e| e.to_string())?;
1248        assert!(trusted);
1249        assert_eq!(
1250            errors,
1251            vec!["Extension \"/tmp/ext.ts\" project_trust error: boom".to_owned()]
1252        );
1253
1254        let _ = fs::remove_dir_all(root);
1255        Ok(())
1256    }
1257
1258    #[test]
1259    fn invalid_trust_file_errors_are_exact() -> TestResult {
1260        let root = unique_temp_dir("invalid")?;
1261        let agent_dir = root.join("agent");
1262        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1263        let store = ProjectTrustStore::new(&agent_dir);
1264        fs::write(store.path(), b"[1,2,3]\n").map_err(|e| e.to_string())?;
1265        let err = require_error(store.get(root.join("p")), "array root must fail")?;
1266        assert_eq!(
1267            err.to_string(),
1268            format!(
1269                "Invalid trust store {}: expected an object",
1270                path_to_string(store.path())
1271            )
1272        );
1273
1274        fs::write(store.path(), b"{\"a\":1}\n").map_err(|e| e.to_string())?;
1275        let err = require_error(store.get(root.join("p")), "non-bool value must fail")?;
1276        assert_eq!(
1277            err.to_string(),
1278            format!(
1279                "Invalid trust store {}: value for \"a\" must be true, false, or null",
1280                path_to_string(store.path())
1281            )
1282        );
1283
1284        let _ = fs::remove_dir_all(root);
1285        Ok(())
1286    }
1287
1288    #[test]
1289    fn concurrent_unknown_safe_updates_preserve_all_keys() -> TestResult {
1290        let root = unique_temp_dir("concurrent")?;
1291        let agent_dir = root.join("agent");
1292        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1293        let store = Arc::new(ProjectTrustStore::new(&agent_dir));
1294
1295        // Seed an unrelated key that concurrent writers must preserve.
1296        let other = root.join("other");
1297        fs::create_dir_all(&other).map_err(|error| error.to_string())?;
1298        store.set(&other, Some(true)).map_err(|e| e.to_string())?;
1299
1300        let mut handles = Vec::new();
1301        for index in 0..8 {
1302            let store = Arc::clone(&store);
1303            let root = root.clone();
1304            handles.push(thread::spawn(move || -> TestResult {
1305                let path = root.join(format!("p{index}"));
1306                fs::create_dir_all(&path).map_err(|error| error.to_string())?;
1307                store
1308                    .set(&path, Some(index % 2 == 0))
1309                    .map_err(|error| error.to_string())
1310            }));
1311        }
1312        let mut worker_error = None;
1313        for handle in handles {
1314            let result = handle
1315                .join()
1316                .map_err(|_| "thread panicked".to_owned())
1317                .and_then(|result| result);
1318            if worker_error.is_none() {
1319                worker_error = result.err();
1320            }
1321        }
1322        if let Some(error) = worker_error {
1323            return Err(error);
1324        }
1325
1326        assert_eq!(store.get(&other).map_err(|e| e.to_string())?, Some(true));
1327        for index in 0..8 {
1328            let path = root.join(format!("p{index}"));
1329            assert_eq!(
1330                store.get(&path).map_err(|e| e.to_string())?,
1331                Some(index % 2 == 0)
1332            );
1333        }
1334
1335        let raw = fs::read_to_string(store.path()).map_err(|e| e.to_string())?;
1336        assert!(raw.ends_with('\n'));
1337        let parsed: Value = serde_json::from_str(raw.trim_end()).map_err(|e| e.to_string())?;
1338        let object = parsed
1339            .as_object()
1340            .ok_or_else(|| "expected object".to_owned())?;
1341        assert_eq!(object.len(), 9);
1342
1343        let _ = fs::remove_dir_all(root);
1344        Ok(())
1345    }
1346
1347    #[test]
1348    fn default_project_trust_parse() {
1349        assert_eq!(
1350            DefaultProjectTrust::parse(Some("always")),
1351            DefaultProjectTrust::Always
1352        );
1353        assert_eq!(
1354            DefaultProjectTrust::parse(Some("never")),
1355            DefaultProjectTrust::Never
1356        );
1357        assert_eq!(
1358            DefaultProjectTrust::parse(Some("ask")),
1359            DefaultProjectTrust::Ask
1360        );
1361        assert_eq!(
1362            DefaultProjectTrust::parse(Some("sometimes")),
1363            DefaultProjectTrust::Ask
1364        );
1365        assert_eq!(DefaultProjectTrust::parse(None), DefaultProjectTrust::Ask);
1366    }
1367
1368    #[test]
1369    fn read_missing_trust_file_is_empty() -> TestResult {
1370        let root = unique_temp_dir("missing")?;
1371        let agent_dir = root.join("agent");
1372        fs::create_dir_all(&agent_dir).map_err(|error| error.to_string())?;
1373        let store = ProjectTrustStore::new(&agent_dir);
1374        assert_eq!(store.get(&root).map_err(|e| e.to_string())?, None);
1375        let _ = fs::remove_dir_all(root);
1376        Ok(())
1377    }
1378
1379    #[test]
1380    fn io_error_message_shape_for_unreadable_file() -> TestResult {
1381        // Force a read failure by pointing the store at a directory named trust.json.
1382        let root = unique_temp_dir("read-fail")?;
1383        let agent_dir = root.join("agent");
1384        fs::create_dir_all(agent_dir.join("trust.json")).map_err(|error| error.to_string())?;
1385        let store = ProjectTrustStore::new(&agent_dir);
1386        let err = require_error(store.get(&root), "directory read must fail")?;
1387        let message = err.to_string();
1388        assert!(
1389            message.starts_with(&format!(
1390                "Failed to read trust store {}:",
1391                path_to_string(store.path())
1392            )),
1393            "unexpected message: {message}"
1394        );
1395        let _ = fs::remove_dir_all(root);
1396        Ok(())
1397    }
1398
1399    #[test]
1400    fn parent_path_none_at_filesystem_root() {
1401        // Platform root has no parent trust option.
1402        let root = if cfg!(windows) {
1403            PathBuf::from(r"C:\")
1404        } else {
1405            PathBuf::from("/")
1406        };
1407        assert_eq!(get_project_trust_parent_path(&root), None);
1408        let options = get_project_trust_options(&root, false);
1409        assert_eq!(
1410            options.iter().map(|o| o.label.as_str()).collect::<Vec<_>>(),
1411            vec!["Trust", "Do not trust"]
1412        );
1413    }
1414}