OXYGENHUB

Coverage / crates/oxy-core/src/repo/config.rs 83.3%

195 of 234 lines run, 38 of 64 functions · run #369 on main · run missed

1
//! Layered configuration: defaults, the user file, the repository file.
2
3
use super::*;
4
5
/// Effective settings: defaults, then the user file (`~/.config/oxy/config.toml`,
6
/// spec §19), then the repository file (`.oxy/config`). Both files are
7
/// `key = "value"` lines. `save` writes only the repository layer.
8
#[derive(Clone, Debug, PartialEq, Eq)]
9
pub struct Config {
10
    /// Keys set in the repository file (or via `set`), written by `save`.
11
    repo_layer: BTreeMap<String, String>,
12
    pub author_name: String,
13
    pub author_email: String,
14
    /// The KeyId of the author's identity, once `create_identity` has run.
15
    pub author_key: Option<KeyId>,
16
    /// `author.signer` (or `$OXY_SIGNER`): the key that signs and seals in
17
    /// place of `author.key`, whose identity lists it as a delegate. An
18
    /// agent sets it to its own key, so its changes carry the human as
19
    /// author and the agent's signature (spec §14.2, agent workflows §2).
20
    pub signer_key: Option<KeyId>,
21
    /// `author.signer = "keyless"`: sign with fresh keys the hub certifies
22
    /// under the agent's grant, keeping no key on disk (spec §14.7,
23
    /// oxygenhub#241).
24
    pub signer_keyless: bool,
25
    /// `keyless.hub`: the hub that certifies them (default: the default
26
    /// remote's hub).
27
    pub keyless_hub: Option<String>,
28
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
29
    /// push-time seals (`false`, the spec default).
30
    pub sign_changes: bool,
31
    pub bookmark: String,
32
    /// `remote.<name>.url`.
33
    pub remotes: BTreeMap<String, String>,
34
    /// `remote.<name>.token`: bearer token for HTTP pushes.
35
    pub remote_tokens: BTreeMap<String, String>,
36
    /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote.
37
    pub remote_cas: BTreeMap<String, String>,
38
    /// `core.remote`: the remote `pull`, `fetch` and `push` use when none is
39
    /// named, for a repository whose remotes include no `origin`.
40
    pub default_remote: Option<String>,
41
    /// `git.claim`: a Git import authors the commits whose Git author email
42
    /// is this repository's identity's with its key (oxygen#97).
43
    pub git_claim: bool,
44
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
45
    pub op_retention_days: u64,
46
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
47
    pub gc_grace_days: u64,
48
    /// `core.snapshot-retention`: days for which past operations keep their
49
    /// working copies reachable (oxygen#98); each workspace's current one is
50
    /// always kept. A file only ever in a working copy goes with it.
51
    pub snapshot_retention_days: u64,
52
    /// `snapshot.max-new-file-size`: a new, untracked file larger than this
53
    /// is left out of working-copy snapshots and named instead (oxygen#98);
54
    /// 0 takes in files of any size.
55
    pub max_new_file: u64,
56
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
57
    pub checkpoint_interval: u64,
58
    /// `push.batch-bytes`: about how many bytes of changes and their texts
59
    /// one request of a long push carries (spec §16.3).
60
    pub push_batch_bytes: u64,
61
    /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17).
62
    pub max_blob: Option<u64>,
63
    /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything.
64
    pub sparse_paths: Vec<String>,
65
    /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20).
66
    pub semantic_deps: bool,
67
    /// `revset.<name>`: a saved revset, used as `name()`.
68
    pub revsets: BTreeMap<String, String>,
69
    /// `storage.url`: the store the repository's objects and operation
70
    /// head are kept on (`dir://`, `s3://`, …) instead of `.oxy/objects`
71
    /// (oxygen#68).
72
    pub storage_url: Option<String>,
73
    /// `storage.cache`: the most the local cache in front of that store
74
    /// holds, in bytes.
75
    pub storage_cache: u64,
76
}
77
78
impl Config {
79
    /// Whether the repository's own file sets `key` (as against the user
80
    /// config or a default).
812×
    pub fn is_repo_set(&self, key: &str) -> bool {
822×
        self.repo_layer.contains_key(key)
832×
    }
84
853226×
    pub(super) fn default_for_user() -> Config {
863226×
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
87
        Config {
883226×
            repo_layer: BTreeMap::new(),
893226×
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
903226×
            author_email: std::env::var("OXY_AUTHOR_EMAIL")
913226×
                .unwrap_or_else(|_| format!("{user}@localhost")),
923226×
            author_key: None,
933226×
            signer_key: std::env::var("OXY_SIGNER")
943226×
                .ok()
953226×
                .and_then(|v| KeyId::from_hex(&v)),
963226×
            signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"),
973226×
            keyless_hub: None,
98
            sign_changes: false,
993226×
            bookmark: "main".into(),
1003226×
            remotes: BTreeMap::new(),
1013226×
            remote_tokens: BTreeMap::new(),
1023226×
            remote_cas: BTreeMap::new(),
1033226×
            default_remote: None,
104
            git_claim: false,
105
            op_retention_days: 90,
106
            gc_grace_days: 14,
1073226×
            max_new_file: 100 << 20,
108
            snapshot_retention_days: 7,
109
            checkpoint_interval: 256,
110
            // Well under the 100 MB a proxy in front of a hub may take
111
            // (oxygen#133).
1123226×
            push_batch_bytes: 64 << 20,
1133226×
            max_blob: None,
1143226×
            sparse_paths: Vec::new(),
115
            semantic_deps: false,
1163226×
            revsets: BTreeMap::new(),
1173226×
            storage_url: None,
118
            storage_cache: DEFAULT_STORAGE_CACHE,
119
        }
1203226×
    }
121
122
    /// Set a key in the repository layer, as `oxy config` does.
123523×
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
124523×
        self.apply(key, value)?;
125523×
        if value.is_empty() {
1264×
            self.repo_layer.remove(key);
127519×
        } else {
128519×
            self.repo_layer.insert(key.to_string(), value.to_string());
129519×
        }
130523×
        Ok(())
131523×
    }
132
133
    /// Set a key in the user file without touching any repository.
134200×
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
135200×
        Config::default_for_user().apply(key, value)?;
136199×
        let mut entries = read_config_file(user_path)?;
137199×
        if value.is_empty() {
1380×
            entries.remove(key);
139199×
        } else {
140199×
            entries.insert(key.to_string(), value.to_string());
141199×
        }
142199×
        if let Some(parent) = user_path.parent() {
143199×
            fs::create_dir_all(parent)?;
1440×
        }
145199×
        write_config_file(user_path, &entries)
146200×
    }
147
148
    /// Apply a key to the effective values.
1491957×
    pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> {
1503×
        match key {
1511957×
            "author.name" => self.author_name = value.into(),
1521613×
            "author.email" => self.author_email = value.into(),
1531269×
            "author.key" => {
154352×
                self.author_key = if value.is_empty() {
1550×
                    None
156
                } else {
157352×
                    Some(KeyId::from_hex(value).ok_or_else(|| {
1580×
                        RepoError::Msg("author.key must be a 64-character hex KeyId".into())
1590×
                    })?)
160
                }
161
            }
162917×
            "author.signer" => {
16323×
                self.signer_keyless = value == "keyless";
16423×
                self.signer_key = if value.is_empty() || self.signer_keyless {
1652×
                    None
166
                } else {
16721×
                    Some(KeyId::from_hex(value).ok_or_else(|| {
1680×
                        RepoError::Msg(
1690×
                            "author.signer must be a 64-character hex KeyId, or \"keyless\"".into(),
1700×
                        )
1710×
                    })?)
172
                }
173
            }
174894×
            "core.sign" => {
17595×
                self.sign_changes = match value {
17695×
                    "change" => true,
177
                    // Empty: unset, the default.
1787×
                    "seal" | "" => false,
1790×
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
180
                }
181
            }
182799×
            "workspace.bookmark" => self.bookmark = value.into(),
183795×
            "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()),
184
            // Read by the credential store from the user file.
185795×
            "auth.helper" => {}
186795×
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
187783×
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
188783×
            "snapshot.max-new-file-size" => self.max_new_file = parse_size(value)?,
189783×
            "core.snapshot-retention" => self.snapshot_retention_days = parse_days(value)?,
190783×
            "filter.max-blob" => {
1915×
                self.max_blob = if value.is_empty() {
1920×
                    None
193
                } else {
1945×
                    Some(parse_size(value)?)
195
                }
196
            }
197778×
            "storage.url" => {
1985×
                self.storage_url = (!value.is_empty()).then(|| value.to_string());
199
            }
200773×
            "storage.cache" => {
2010×
                self.storage_cache = if value.is_empty() {
2020×
                    DEFAULT_STORAGE_CACHE
203
                } else {
2040×
                    parse_size(value)?
205
                }
206
            }
207773×
            "core.remote" => {
2081×
                self.default_remote = (!value.is_empty()).then(|| value.to_string());
209
            }
210772×
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
211771×
            "git.claim" => self.git_claim = matches!(value, "true" | "1" | "yes"),
212769×
            "filter.paths" => {
2133×
                self.sparse_paths = value
2143×
                    .split(',')
2154×
                    .map(|p| {
2164×
                        p.trim()
2174×
                            .trim_start_matches("./")
2184×
                            .trim_matches('/')
2194×
                            .to_string()
2204×
                    })
2214×
                    .filter(|p| !p.is_empty())
2223×
                    .collect();
223
            }
224766×
            "core.checkpoint-interval" => {
2253×
                self.checkpoint_interval = value
2263×
                    .parse()
2273×
                    .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
228
            }
229763×
            "push.batch-bytes" => {
2302×
                self.push_batch_bytes = value.parse().ok().filter(|n| *n > 0).ok_or_else(|| {
2310×
                    RepoError::Msg(format!("expected a number of bytes, got {value:?}"))
2320×
                })?;
233
            }
234761×
            k if k.starts_with("remote.") => {
235758×
                let rest = &k["remote.".len()..];
236758×
                let Some((name, field)) = rest.rsplit_once('.') else {
2370×
                    return fail(format!("unknown configuration key {key:?}"));
238
                };
239758×
                if name.is_empty() || name.contains('/') {
2400×
                    return fail(format!("invalid remote name in {key:?}"));
241758×
                }
242758×
                let map = match field {
243758×
                    "url" => &mut self.remotes,
24414×
                    "token" => &mut self.remote_tokens,
2454×
                    "ca" => &mut self.remote_cas,
2460×
                    _ => return fail(format!("unknown configuration key {key:?}")),
247
                };
248758×
                if value.is_empty() {
2494×
                    map.remove(name);
2504×
                    if field == "url" {
2513×
                        self.remote_tokens.remove(name);
2523×
                        self.remote_cas.remove(name);
2533×
                    }
254754×
                } else {
255754×
                    map.insert(name.to_string(), value.into());
256754×
                }
257
            }
2583×
            k if k.starts_with("revset.") => {
2593×
                let name = &k["revset.".len()..];
2603×
                if name.is_empty()
2613×
                    || !name
2623×
                        .chars()
26311×
                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
264
                {
2650×
                    return fail(format!(
266
                        "a revset's name is letters, digits, _ and -: {key:?}"
267
                    ));
2683×
                }
2693×
                if value.is_empty() {
2700×
                    self.revsets.remove(name);
2710×
                } else {
2723×
                    crate::revset::parse(value)
2733×
                        .map_err(|e| RepoError::Msg(format!("{key} = {value:?}: {e}")))?;
2742×
                    self.revsets.insert(name.to_string(), value.into());
275
                }
276
            }
2770×
            _ => return fail(format!("unknown configuration key {key:?}")),
278
        }
2791956×
        Ok(())
2801957×
    }
281
282
    /// Defaults, then the user file if it exists, then the repository file.
2833026×
    pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
2843026×
        let mut c = Config::default_for_user();
2853026×
        if let Some(u) = user_path {
2863026×
            for (k, v) in read_config_file(u)? {
287475×
                let _ = c.apply(&k, &v);
288475×
            }
2890×
        }
2903026×
        for (k, v) in read_config_file(repo_path)? {
291759×
            if c.apply(&k, &v).is_ok() {
292759×
                c.repo_layer.insert(k, v);
293759×
            }
294
        }
2953026×
        Ok(c)
2963026×
    }
297
2981000×
    pub(super) fn save(&self, path: &Path) -> Result<()> {
2991000×
        write_config_file(path, &self.repo_layer)
3001000×
    }
301
302
    /// Keys explicitly set for this repository.
30322×
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
30422×
        &self.repo_layer
30522×
    }
306
}
307
308
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
309
/// `storage.cache` when unset: 2 GiB.
310
pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30;
311
3127×
pub fn parse_size(v: &str) -> Result<u64> {
3137×
    let v = v.trim();
3147×
    let (num, mult) = match v.chars().last() {
3151×
        Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10),
3161×
        Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20),
3170×
        Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30),
3185×
        _ => (v, 1),
319
    };
3207×
    let n: u64 = num
3217×
        .trim()
3227×
        .parse()
3237×
        .map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
3246×
    Ok(n * mult)
3257×
}
326
327
/// A count of days, optionally suffixed `d`.
32812×
fn parse_days(v: &str) -> Result<u64> {
32912×
    v.trim_end_matches('d')
33012×
        .parse()
33112×
        .map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
33212×
}
333
334
/// Read `key = "value"` lines; a missing file is empty.
3356804×
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
3366804×
    let text = match fs::read_to_string(path) {
3372749×
        Ok(t) => t,
3384055×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
3390×
        Err(e) => return Err(e.into()),
340
    };
3412749×
    let mut out = BTreeMap::new();
3422749×
    for line in text.lines() {
3431625×
        let line = line.trim();
3441625×
        if line.is_empty() || line.starts_with('#') {
3450×
            continue;
3461625×
        }
3471625×
        let Some((k, v)) = line.split_once('=') else {
3480×
            continue;
349
        };
3501625×
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
351
    }
3522749×
    Ok(out)
3536804×
}
354
3551199×
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
3561199×
    let text: String = entries
3571199×
        .iter()
3581201×
        .map(|(k, v)| format!("{k} = \"{v}\"\n"))
3591199×
        .collect();
3601199×
    write_atomic(path, text.as_bytes())
3611199×
}