OXYGENHUB
◆ main oxygen / crates / oxy-core / src / repo / config.rs
16.2 KiB · Rust · last changed oxy git colocate: a .git beside .oxy, kept up to date after every command; .git is never content and never written by checkout (closes #160) 4 days ago source blame history raw
98286406 Andy McCall ↶ 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>,
ff1a64f8 Andy McCall ↶ 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>,
c895bc52 Andy McCall ↶ 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>,
2452b440 Andy McCall ↶ 28
    /// `author.for` (oxygen#82): in a keyless agent's home, the account it
29
    /// acts for; the home then authors as that person's newest identity,
30
    /// which the hub names with each certificate, rather than a pinned
31
    /// `author.key` that goes stale with every rotation.
32
    pub author_for: Option<String>,
98286406 Andy McCall ↶ 33
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
34
    /// push-time seals (`false`, the spec default).
35
    pub sign_changes: bool,
36
    pub bookmark: String,
37
    /// `remote.<name>.url`.
38
    pub remotes: BTreeMap<String, String>,
39
    /// `remote.<name>.token`: bearer token for HTTP pushes.
40
    pub remote_tokens: BTreeMap<String, String>,
2b5ee371 Andy McCall ↶ 41
    /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote.
42
    pub remote_cas: BTreeMap<String, String>,
42ccf504 Andy McCall ↶ 43
    /// `core.remote`: the remote `pull`, `fetch` and `push` use when none is
44
    /// named, for a repository whose remotes include no `origin`.
45
    pub default_remote: Option<String>,
d6e0d5b8 Andy McCall ↶ 46
    /// `git.claim`: a Git import authors the commits whose Git author email
47
    /// is this repository's identity's with its key (oxygen#97).
48
    pub git_claim: bool,
c397a8c3 Andy McCall ↶ 49
    /// `git.colocate`: keep a `.git` beside `.oxy` with the current
50
    /// bookmark exported after every operation, so editors' Git features
51
    /// work on an Oxygen checkout (one way: oxy to Git).
52
    pub git_colocate: bool,
ba160f60 Andy McCall ↶ 53
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
54
    pub op_retention_days: u64,
55
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
56
    pub gc_grace_days: u64,
b689779e Andy McCall ↶ 57
    /// `core.snapshot-retention`: days for which past operations keep their
58
    /// working copies reachable (oxygen#98); each workspace's current one is
59
    /// always kept. A file only ever in a working copy goes with it.
60
    pub snapshot_retention_days: u64,
61
    /// `snapshot.max-new-file-size`: a new, untracked file larger than this
62
    /// is left out of working-copy snapshots and named instead (oxygen#98);
63
    /// 0 takes in files of any size.
64
    pub max_new_file: u64,
252e8cb2 Andy McCall ↶ 65
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
66
    pub checkpoint_interval: u64,
cb8b8082 Andy McCall ↶ 67
    /// `push.batch-bytes`: about how many bytes of changes and their texts
68
    /// one request of a long push carries (spec §16.3).
69
    pub push_batch_bytes: u64,
b3c5a59a Andy McCall ↶ 70
    /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17).
71
    pub max_blob: Option<u64>,
7421309f Andy McCall ↶ 72
    /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything.
73
    pub sparse_paths: Vec<String>,
6d3eefbc Andy McCall ↶ 74
    /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20).
75
    pub semantic_deps: bool,
47ce8b8e Andy McCall ↶ 76
    /// `revset.<name>`: a saved revset, used as `name()`.
77
    pub revsets: BTreeMap<String, String>,
d069701a Andy McCall ↶ 78
    /// `storage.url`: the store the repository's objects and operation
79
    /// head are kept on (`dir://`, `s3://`, …) instead of `.oxy/objects`
80
    /// (oxygen#68).
81
    pub storage_url: Option<String>,
82
    /// `storage.cache`: the most the local cache in front of that store
83
    /// holds, in bytes.
84
    pub storage_cache: u64,
98286406 Andy McCall ↶ 85
}
86
87
impl Config {
cbe8499a Andy McCall ↶ 88
    /// Whether the repository's own file sets `key` (as against the user
89
    /// config or a default).
90
    pub fn is_repo_set(&self, key: &str) -> bool {
91
        self.repo_layer.contains_key(key)
92
    }
93
98286406 Andy McCall ↶ 94
    pub(super) fn default_for_user() -> Config {
95
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
96
        Config {
97
            repo_layer: BTreeMap::new(),
98
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
75167672 Andy McCall 99
            author_email: std::env::var("OXY_AUTHOR_EMAIL")
100
                .unwrap_or_else(|_| format!("{user}@localhost")),
98286406 Andy McCall ↶ 101
            author_key: None,
ff1a64f8 Andy McCall ↶ 102
            signer_key: std::env::var("OXY_SIGNER")
103
                .ok()
104
                .and_then(|v| KeyId::from_hex(&v)),
c895bc52 Andy McCall ↶ 105
            signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"),
106
            keyless_hub: None,
2452b440 Andy McCall ↶ 107
            author_for: None,
98286406 Andy McCall ↶ 108
            sign_changes: false,
109
            bookmark: "main".into(),
110
            remotes: BTreeMap::new(),
111
            remote_tokens: BTreeMap::new(),
2b5ee371 Andy McCall ↶ 112
            remote_cas: BTreeMap::new(),
42ccf504 Andy McCall ↶ 113
            default_remote: None,
d6e0d5b8 Andy McCall ↶ 114
            git_claim: false,
c397a8c3 Andy McCall ↶ 115
            git_colocate: false,
ba160f60 Andy McCall ↶ 116
            op_retention_days: 90,
117
            gc_grace_days: 14,
b689779e Andy McCall ↶ 118
            max_new_file: 100 << 20,
119
            snapshot_retention_days: 7,
252e8cb2 Andy McCall ↶ 120
            checkpoint_interval: 256,
d3b27090 Andy McCall ↶ 121
            // Well under the 100 MB a proxy in front of a hub may take
122
            // (oxygen#133).
123
            push_batch_bytes: 64 << 20,
b3c5a59a Andy McCall ↶ 124
            max_blob: None,
7421309f Andy McCall ↶ 125
            sparse_paths: Vec::new(),
6d3eefbc Andy McCall ↶ 126
            semantic_deps: false,
47ce8b8e Andy McCall ↶ 127
            revsets: BTreeMap::new(),
d069701a Andy McCall ↶ 128
            storage_url: None,
129
            storage_cache: DEFAULT_STORAGE_CACHE,
98286406 Andy McCall ↶ 130
        }
131
    }
132
133
    /// Set a key in the repository layer, as `oxy config` does.
134
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
135
        self.apply(key, value)?;
136
        if value.is_empty() {
137
            self.repo_layer.remove(key);
138
        } else {
139
            self.repo_layer.insert(key.to_string(), value.to_string());
140
        }
141
        Ok(())
142
    }
143
144
    /// Set a key in the user file without touching any repository.
145
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
146
        Config::default_for_user().apply(key, value)?;
147
        let mut entries = read_config_file(user_path)?;
148
        if value.is_empty() {
149
            entries.remove(key);
150
        } else {
151
            entries.insert(key.to_string(), value.to_string());
152
        }
153
        if let Some(parent) = user_path.parent() {
154
            fs::create_dir_all(parent)?;
155
        }
156
        write_config_file(user_path, &entries)
157
    }
158
159
    /// Apply a key to the effective values.
160
    pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> {
161
        match key {
162
            "author.name" => self.author_name = value.into(),
163
            "author.email" => self.author_email = value.into(),
164
            "author.key" => {
165
                self.author_key = if value.is_empty() {
166
                    None
167
                } else {
75167672 Andy McCall 168
                    Some(KeyId::from_hex(value).ok_or_else(|| {
169
                        RepoError::Msg("author.key must be a 64-character hex KeyId".into())
170
                    })?)
98286406 Andy McCall ↶ 171
                }
172
            }
ff1a64f8 Andy McCall ↶ 173
            "author.signer" => {
c895bc52 Andy McCall ↶ 174
                self.signer_keyless = value == "keyless";
175
                self.signer_key = if value.is_empty() || self.signer_keyless {
ff1a64f8 Andy McCall ↶ 176
                    None
177
                } else {
178
                    Some(KeyId::from_hex(value).ok_or_else(|| {
c895bc52 Andy McCall ↶ 179
                        RepoError::Msg(
180
                            "author.signer must be a 64-character hex KeyId, or \"keyless\"".into(),
181
                        )
ff1a64f8 Andy McCall ↶ 182
                    })?)
183
                }
184
            }
98286406 Andy McCall ↶ 185
            "core.sign" => {
186
                self.sign_changes = match value {
187
                    "change" => true,
c9635448 Andy McCall ↶ 188
                    // Empty: unset, the default.
189
                    "seal" | "" => false,
98286406 Andy McCall ↶ 190
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
191
                }
192
            }
193
            "workspace.bookmark" => self.bookmark = value.into(),
c895bc52 Andy McCall ↶ 194
            "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()),
2452b440 Andy McCall ↶ 195
            "author.for" => self.author_for = (!value.is_empty()).then(|| value.to_string()),
ff1f62fe Andy McCall ↶ 196
            // Read by the credential store from the user file.
197
            "auth.helper" => {}
ba160f60 Andy McCall ↶ 198
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
199
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
b689779e Andy McCall ↶ 200
            "snapshot.max-new-file-size" => self.max_new_file = parse_size(value)?,
201
            "core.snapshot-retention" => self.snapshot_retention_days = parse_days(value)?,
75167672 Andy McCall 202
            "filter.max-blob" => {
203
                self.max_blob = if value.is_empty() {
204
                    None
205
                } else {
206
                    Some(parse_size(value)?)
207
                }
208
            }
d069701a Andy McCall ↶ 209
            "storage.url" => {
210
                self.storage_url = (!value.is_empty()).then(|| value.to_string());
211
            }
212
            "storage.cache" => {
213
                self.storage_cache = if value.is_empty() {
214
                    DEFAULT_STORAGE_CACHE
215
                } else {
216
                    parse_size(value)?
217
                }
218
            }
42ccf504 Andy McCall ↶ 219
            "core.remote" => {
220
                self.default_remote = (!value.is_empty()).then(|| value.to_string());
221
            }
6d3eefbc Andy McCall ↶ 222
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
d6e0d5b8 Andy McCall ↶ 223
            "git.claim" => self.git_claim = matches!(value, "true" | "1" | "yes"),
c397a8c3 Andy McCall ↶ 224
            "git.colocate" => self.git_colocate = matches!(value, "true" | "1" | "yes"),
7421309f Andy McCall ↶ 225
            "filter.paths" => {
226
                self.sparse_paths = value
227
                    .split(',')
75167672 Andy McCall 228
                    .map(|p| {
229
                        p.trim()
230
                            .trim_start_matches("./")
231
                            .trim_matches('/')
232
                            .to_string()
233
                    })
7421309f Andy McCall ↶ 234
                    .filter(|p| !p.is_empty())
235
                    .collect();
236
            }
252e8cb2 Andy McCall ↶ 237
            "core.checkpoint-interval" => {
75167672 Andy McCall 238
                self.checkpoint_interval = value
239
                    .parse()
240
                    .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
252e8cb2 Andy McCall ↶ 241
            }
cb8b8082 Andy McCall ↶ 242
            "push.batch-bytes" => {
243
                self.push_batch_bytes = value.parse().ok().filter(|n| *n > 0).ok_or_else(|| {
244
                    RepoError::Msg(format!("expected a number of bytes, got {value:?}"))
245
                })?;
246
            }
2b5ee371 Andy McCall ↶ 247
            k if k.starts_with("remote.") => {
248
                let rest = &k["remote.".len()..];
249
                let Some((name, field)) = rest.rsplit_once('.') else {
250
                    return fail(format!("unknown configuration key {key:?}"));
98286406 Andy McCall ↶ 251
                };
252
                if name.is_empty() || name.contains('/') {
253
                    return fail(format!("invalid remote name in {key:?}"));
254
                }
2b5ee371 Andy McCall ↶ 255
                let map = match field {
256
                    "url" => &mut self.remotes,
257
                    "token" => &mut self.remote_tokens,
258
                    "ca" => &mut self.remote_cas,
259
                    _ => return fail(format!("unknown configuration key {key:?}")),
260
                };
98286406 Andy McCall ↶ 261
                if value.is_empty() {
262
                    map.remove(name);
2b5ee371 Andy McCall ↶ 263
                    if field == "url" {
98286406 Andy McCall ↶ 264
                        self.remote_tokens.remove(name);
2b5ee371 Andy McCall ↶ 265
                        self.remote_cas.remove(name);
98286406 Andy McCall ↶ 266
                    }
267
                } else {
268
                    map.insert(name.to_string(), value.into());
269
                }
270
            }
47ce8b8e Andy McCall ↶ 271
            k if k.starts_with("revset.") => {
272
                let name = &k["revset.".len()..];
273
                if name.is_empty()
274
                    || !name
275
                        .chars()
276
                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
277
                {
278
                    return fail(format!(
279
                        "a revset's name is letters, digits, _ and -: {key:?}"
280
                    ));
281
                }
282
                if value.is_empty() {
283
                    self.revsets.remove(name);
284
                } else {
285
                    crate::revset::parse(value)
286
                        .map_err(|e| RepoError::Msg(format!("{key} = {value:?}: {e}")))?;
287
                    self.revsets.insert(name.to_string(), value.into());
288
                }
289
            }
98286406 Andy McCall ↶ 290
            _ => return fail(format!("unknown configuration key {key:?}")),
291
        }
292
        Ok(())
293
    }
294
295
    /// Defaults, then the user file if it exists, then the repository file.
296
    pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
297
        let mut c = Config::default_for_user();
298
        if let Some(u) = user_path {
299
            for (k, v) in read_config_file(u)? {
300
                let _ = c.apply(&k, &v);
301
            }
302
        }
303
        for (k, v) in read_config_file(repo_path)? {
304
            if c.apply(&k, &v).is_ok() {
305
                c.repo_layer.insert(k, v);
306
            }
307
        }
2452b440 Andy McCall ↶ 308
        // author.for (oxygen#82): the identity the hub last named as the
309
        // person's newest, kept in the home; a repository's own
310
        // author.key pin still wins.
311
        if c.author_for.is_some() && !c.repo_layer.contains_key("author.key") {
312
            if let Some(k) = user_path
313
                .and_then(Path::parent)
314
                .and_then(|home| fs::read_to_string(home.join("keys").join(AUTHOR_FOR_FILE)).ok())
315
                .and_then(|s| KeyId::from_hex(s.trim()))
316
            {
317
                c.author_key = Some(k);
318
            }
319
        }
98286406 Andy McCall ↶ 320
        Ok(c)
321
    }
322
323
    pub(super) fn save(&self, path: &Path) -> Result<()> {
324
        write_config_file(path, &self.repo_layer)
325
    }
326
327
    /// Keys explicitly set for this repository.
328
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
329
        &self.repo_layer
330
    }
331
}
2452b440 Andy McCall ↶ 332
333
/// In a home's `keys/`: the key of the identity `author.for` follows,
334
/// as the hub last named it (oxygen#82).
335
pub const AUTHOR_FOR_FILE: &str = "author.for";
98286406 Andy McCall ↶ 336
b3c5a59a Andy McCall ↶ 337
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
d069701a Andy McCall ↶ 338
/// `storage.cache` when unset: 2 GiB.
339
pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30;
340
b3c5a59a Andy McCall ↶ 341
pub fn parse_size(v: &str) -> Result<u64> {
342
    let v = v.trim();
343
    let (num, mult) = match v.chars().last() {
344
        Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10),
345
        Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20),
346
        Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30),
347
        _ => (v, 1),
348
    };
75167672 Andy McCall 349
    let n: u64 = num
350
        .trim()
351
        .parse()
352
        .map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
b3c5a59a Andy McCall ↶ 353
    Ok(n * mult)
354
}
355
ba160f60 Andy McCall ↶ 356
/// A count of days, optionally suffixed `d`.
357
fn parse_days(v: &str) -> Result<u64> {
75167672 Andy McCall 358
    v.trim_end_matches('d')
359
        .parse()
360
        .map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
ba160f60 Andy McCall ↶ 361
}
362
98286406 Andy McCall ↶ 363
/// Read `key = "value"` lines; a missing file is empty.
364
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
365
    let text = match fs::read_to_string(path) {
366
        Ok(t) => t,
367
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
368
        Err(e) => return Err(e.into()),
369
    };
370
    let mut out = BTreeMap::new();
371
    for line in text.lines() {
372
        let line = line.trim();
373
        if line.is_empty() || line.starts_with('#') {
374
            continue;
375
        }
75167672 Andy McCall 376
        let Some((k, v)) = line.split_once('=') else {
377
            continue;
378
        };
98286406 Andy McCall ↶ 379
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
380
    }
381
    Ok(out)
382
}
383
384
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
75167672 Andy McCall 385
    let text: String = entries
386
        .iter()
387
        .map(|(k, v)| format!("{k} = \"{v}\"\n"))
388
        .collect();
98286406 Andy McCall ↶ 389
    write_atomic(path, text.as_bytes())
390
}