Skip to main content

safe_chains/registry/
folder_config.rs

1//! The `[unknown_folder] writes = "…"` setting, read from the user config only
2//! (`~/.config/safe-chains.toml`). A project's `.safe-chains.toml` is never consulted: the agent can
3//! write that file, and a checkout must not be able to loosen how its own writes are judged.
4
5use std::{env, fs};
6
7use serde::Deserialize;
8
9use crate::pathctx::anchor::FolderLevel;
10
11#[derive(Deserialize)]
12struct File {
13    #[serde(default)]
14    unknown_folder: Option<Section>,
15}
16
17#[derive(Deserialize)]
18struct Section {
19    #[serde(default)]
20    writes: Option<String>,
21}
22
23/// What the user config says about writes in an unknown folder.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum Setting {
26    /// No config, or no `writes` in it: the default level applies.
27    Unset,
28    Level(FolderLevel),
29    /// The file or the value could not be read. Treated as `reads`, the strictest level, since a
30    /// typo must not be what loosens it.
31    Unreadable(String),
32}
33
34impl Setting {
35    pub fn level(&self) -> FolderLevel {
36        match self {
37            Setting::Unset => FolderLevel::DEFAULT,
38            Setting::Level(l) => *l,
39            Setting::Unreadable(_) => FolderLevel::Reads,
40        }
41    }
42}
43
44/// The setting in the user config. `SAFE_CHAINS_NO_LOCAL` switches it off with the rest of the
45/// local config.
46pub fn user_setting() -> Setting {
47    if env::var_os("SAFE_CHAINS_NO_LOCAL").is_some() {
48        return Setting::Unset;
49    }
50    let Some(path) = super::custom::find_user_custom() else {
51        return Setting::Unset;
52    };
53    match fs::read_to_string(&path) {
54        Ok(source) => parse(&source),
55        Err(e) => Setting::Unreadable(format!("{}: {e}", path.display())),
56    }
57}
58
59pub(crate) fn parse(source: &str) -> Setting {
60    let file: File = match toml::from_str(source) {
61        Ok(f) => f,
62        Err(e) => return Setting::Unreadable(e.message().to_string()),
63    };
64    match file.unknown_folder.and_then(|s| s.writes) {
65        None => Setting::Unset,
66        Some(name) => FolderLevel::parse(&name).map_or_else(
67            || Setting::Unreadable(format!("unknown_folder.writes = \"{name}\" is not one of {}", FolderLevel::NAMES.join(", "))),
68            Setting::Level,
69        ),
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn each_level_reads_back() {
79        for name in FolderLevel::NAMES {
80            let level = FolderLevel::parse(name).expect("a listed name parses");
81            assert_eq!(parse(&format!("[unknown_folder]\nwrites = \"{name}\"\n")), Setting::Level(level));
82        }
83    }
84
85    #[test]
86    fn absent_is_the_default_and_unreadable_is_reads() {
87        assert_eq!(parse(""), Setting::Unset);
88        assert_eq!(parse("level = \"editor\"\n[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n"), Setting::Unset);
89        assert_eq!(parse("[unknown_folder]\n"), Setting::Unset);
90        assert_eq!(Setting::Unset.level(), FolderLevel::Developer);
91        for bad in
92            ["[unknown_folder]\nwrites = \"devloper\"\n", "[unknown_folder]\nwrites = 3\n", "not toml {{{", "unknown_folder = \"reads\"\n"]
93        {
94            let got = parse(bad);
95            assert!(matches!(got, Setting::Unreadable(_)), "{bad}: {got:?}");
96            assert_eq!(got.level(), FolderLevel::Reads, "{bad}");
97        }
98    }
99}