OXYGENHUB

Coverage / src/cli/identity.rs 19.5%

140 of 719 lines run, 10 of 140 functions · run #252 on main · run missed

1
//! Identity, verification, and configuration commands.
2
3
use super::format::{short_id, signature_line, when};
4
use super::json;
5
use super::open;
6
use super::ui;
7
use anyhow::Result;
8
use oxy_core::hash::ChangeHash;
9
use oxy_core::hash::KeyId;
10
use oxy_core::identity::{Delegate, Identity, Scope};
11
use oxy_core::objects::Hlc;
12
use oxy_core::protocol::SignedPolicy;
13
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
14
use std::path::{Path, PathBuf};
15
16
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
17
180×
fn expiry_from_days(days: u64) -> Option<u64> {
190×
    (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS)
200×
}
21
22
/// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now.
230×
fn expiry_from_duration(s: &str) -> Result<u64> {
240×
    let s = s.trim();
250×
    let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
260×
        Some((i, _)) => (&s[..i], &s[i..]),
270×
        None => (s, "d"),
28
    };
290×
    let n: u64 = n.parse().map_err(|_| {
300×
        anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}")
310×
    })?;
320×
    let ms = match unit {
330×
        "m" => 60_000,
340×
        "h" => 3_600_000,
350×
        "d" => DAY_MS,
360×
        _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"),
37
    };
380×
    if n == 0 {
390×
        anyhow::bail!("--expires needs a length of time; leave it out for no expiry");
400×
    }
410×
    Ok(Hlc::now().wall_ms + n * ms)
420×
}
43
44
/// Print the delegates, one per line: who, their key, what they may do
45
/// and until when, so an agent's name is seen, not only its key.
460×
fn print_delegates(repo: &Repo, delegates: &[Delegate]) {
470×
    if delegates.is_empty() {
480×
        return;
490×
    }
500×
    println!("{}", ui::kv("delegates", delegates.len().to_string()));
510×
    let now = Hlc::now().wall_ms;
520×
    for d in delegates {
530×
        let who = repo
540×
            .identity(&d.key)
550×
            .map(|i| format!("{} <{}>", i.name, i.email))
560×
            .unwrap_or_else(|_| "(unknown identity)".into());
570×
        let until = match d.expires {
580×
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
590×
            Some(e) => ui::dim(format!("until {}", when(e))),
600×
            None => String::new(),
61
        };
620×
        println!(
63
            "    {}  {}  {} {}",
640×
            ui::author(who),
650×
            ui::hash(d.key.short(12)),
660×
            ui::dim(d.scope.to_string()),
67
            until
68
        );
69
    }
700×
}
71
72
/// Resolve a KeyId prefix among the identities the repository knows.
730×
fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> {
740×
    if let Some(k) = KeyId::from_hex(prefix) {
750×
        return Ok(k);
760×
    }
770×
    let mut candidates: Vec<KeyId> = Vec::new();
780×
    if let Some((_, sp)) = repo.current_policy()? {
790×
        candidates.extend(sp.policy.identities.iter().map(|i| i.hash()));
800×
        candidates.extend(sp.policy.revoked.iter().copied());
810×
    }
820×
    if let Some(own) = repo.config.author_key {
830×
        candidates.push(own);
840×
        if let Ok(id) = repo.identity(&own) {
850×
            candidates.extend(id.delegates.iter().map(|d| d.key));
860×
        }
870×
    }
880×
    candidates.sort();
890×
    candidates.dedup();
900×
    let matches: Vec<_> = candidates
910×
        .into_iter()
920×
        .filter(|k| k.starts_with_hex(prefix))
930×
        .collect();
940×
    match matches.len() {
950×
        1 => Ok(matches[0]),
960×
        0 => anyhow::bail!("no known key starts with {prefix:?}"),
970×
        _ => anyhow::bail!("{prefix:?} is ambiguous"),
98
    }
990×
}
100
101
/// Report a policy update: applied, or written out as a proposal that
102
/// other root holders sign with `oxy policy sign`.
1031×
fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> {
1041×
    if u.applied {
1051×
        println!(
106
            "{} policy serial {} {}",
1071×
            ui::done("Applied"),
108
            u.policy.serial,
1091×
            ui::dim("(push to publish it)")
110
        );
1111×
        return Ok(());
1120×
    }
1130×
    let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial)));
1140×
    std::fs::write(&path, u.signed().encode())?;
1150×
    println!(
116
        "{} policy serial {} needs {} of {} root signatures; has {}. Wrote {} for another root to run {}",
1170×
        ui::caution(),
118
        u.policy.serial,
119
        u.policy.root_threshold,
1200×
        u.policy.roots.len(),
1210×
        u.sigs.len(),
1220×
        ui::path(path.display()),
1230×
        ui::dim(format!("oxy policy sign {}", path.display()))
124
    );
1250×
    Ok(())
1261×
}
127
1280×
fn report_rotation(repo: &Repo, r: &Rotation, verb: &str) -> Result<()> {
1290×
    println!(
130
        "{} identity {} <{}>",
1310×
        ui::done(verb),
1320×
        ui::author(&r.identity.name),
133
        r.identity.email
134
    );
1350×
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
1360×
    println!("{}", ui::kv("new key", r.new.to_hex()));
1370×
    print_delegates(repo, &r.identity.delegates);
1380×
    if let Some(e) = r.identity.expires {
1390×
        println!("{}", ui::kv("expires", when(e)));
1400×
    }
1410×
    match &r.policy {
1420×
        Some(u) => report_update(u, None)?,
1430×
        None => println!(
144
            "  {} {}",
1450×
            ui::note(),
1460×
            ui::dim("no policy names the old key; export the new identity for policy holders")
147
        ),
148
    }
1490×
    Ok(())
1500×
}
151
1521×
pub fn create(
1531×
    name: Option<String>,
1541×
    email: Option<String>,
1551×
    local: bool,
1561×
    expires: Option<u64>,
1571×
) -> Result<i32> {
1581×
    let mut repo = open()?;
1591×
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
1601×
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
1611×
    let scope = if local {
1620×
        IdentityScope::Repo
163
    } else {
1641×
        IdentityScope::User
165
    };
1661×
    let (key, _) =
1671×
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
1681×
    println!(
169
        "{} identity {} <{email}>",
1701×
        ui::done("Created"),
1711×
        ui::author(&name)
172
    );
1731×
    println!("{}", ui::kv("key", key));
1741×
    println!(
175
        "{}",
1761×
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
177
    );
1781×
    match scope {
1791×
        IdentityScope::User => println!(
180
            "{}",
1811×
            ui::kv(
1821×
                "scope",
1831×
                format!(
184
                    "default author in {}",
1851×
                    ui::path(repo.user_config_path().display())
186
                )
187
            )
188
        ),
189
        IdentityScope::Repo => {
1900×
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
191
        }
192
    }
1931×
    if !repo.config.sign_changes {
1941×
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
1951×
    }
1961×
    Ok(0)
1971×
}
198
1990×
pub fn show() -> Result<i32> {
2000×
    let repo = open()?;
2010×
    let Some(key) = repo.config.author_key else {
2020×
        println!("No identity configured; run `oxy identity create`.");
2030×
        return Ok(0);
204
    };
2050×
    let id = repo.identity(&key)?;
2060×
    println!(
207
        "{} {} <{}>",
2080×
        ui::section("identity"),
2090×
        ui::author(&id.name),
210
        id.email
211
    );
2120×
    println!("{}", ui::kv("key", key));
2130×
    println!(
214
        "{}",
2150×
        ui::kv(
2160×
            "created",
2170×
            format!(
218
                "{} {}",
2190×
                when(id.created),
2200×
                ui::dim(format!("({})", ui::ago(id.created)))
221
            )
222
        )
223
    );
2240×
    if let Some(e) = id.expires {
2250×
        let expired = id.is_expired_at(Hlc::now().wall_ms);
2260×
        println!(
227
            "{}",
2280×
            ui::kv(
2290×
                "expires",
2300×
                if expired {
2310×
                    ui::err(format!("{} EXPIRED", when(e)))
232
                } else {
2330×
                    when(e)
234
                }
235
            )
236
        );
2370×
    }
2380×
    if let Some(p) = id.previous {
2390×
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
2400×
    }
241
    // A repository that pins one key while the user file names another is
242
    // how checkouts drift apart after a rotation (oxygen#69).
2430×
    if let Some(pinned) = repo
2440×
        .config
2450×
        .repo_layer()
2460×
        .get("author.key")
2470×
        .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
248
    {
2490×
        let user_key = repo::read_config_file(&repo::default_user_config())
2500×
            .ok()
2510×
            .and_then(|m| {
2520×
                m.get("author.key")
2530×
                    .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
2540×
            });
2550×
        if let Some(u) = user_key.filter(|u| *u != pinned) {
2560×
            let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| {
2570×
                let mut cur = Some(from);
2580×
                for _ in 0..64 {
2590×
                    match cur {
2600×
                        Some(k) if k == to => return true,
2610×
                        Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous),
2620×
                        None => break,
263
                    }
264
                }
2650×
                false
2660×
            };
2670×
            let how = if related(pinned, u) {
2680×
                "an older key of this identity"
2690×
            } else if related(u, pinned) {
2700×
                "a newer key of this identity"
271
            } else {
2720×
                "another key"
273
            };
2740×
            println!(
275
                "  {} {}",
2760×
                ui::caution(),
2770×
                ui::dim(format!(
278
                    "this repository pins author.key {}; your user config names {how}, {}. Other checkouts follow the user config: `oxy config --unset author.key` here, or `oxy config --user author.key {}`",
2790×
                    pinned.short(12),
2800×
                    u.short(12),
2810×
                    pinned.to_hex()
282
                ))
283
            );
2840×
        }
2850×
    }
2860×
    print_delegates(&repo, &id.delegates);
2870×
    if repo.is_revoked(&key)? {
2880×
        println!(
2890×
            "{}",
2900×
            ui::kv(
2910×
                "status",
2920×
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
2930×
            )
2940×
        );
2950×
    }
2960×
    println!(
297
        "{}",
2980×
        ui::kv(
2990×
            "secret",
3000×
            if repo.secret_key()?.is_some() {
3010×
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
302
            } else {
3030×
                ui::err(format!(
304
                    "{} not found in the key directory",
3050×
                    ui::glyph("✖", "x")
306
                ))
307
            }
308
        )
309
    );
3100×
    println!(
311
        "{}",
3120×
        ui::kv(
3130×
            "signing",
3140×
            if repo.config.sign_changes {
3150×
                "every change (core.sign = change)"
316
            } else {
3170×
                "at push via seals (core.sign = seal)"
318
            }
319
        )
320
    );
3210×
    Ok(0)
3220×
}
323
3241×
pub fn verify(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> {
3251×
    let repo = open()?;
3261×
    let hashes: Vec<ChangeHash> = match (change, revset) {
3270×
        (_, Some(r)) => {
3280×
            let wc = repo.working_copy().ok().map(|(h, _)| h);
3290×
            repo.revset(r)?
330
                .hashes
3310×
                .into_iter()
3320×
                .filter(|h| Some(*h) != wc)
3330×
                .collect()
334
        }
3350×
        (Some(c), None) => vec![repo.resolve_change(c)?],
3361×
        (None, None) => repo
3371×
            .log()?
3381×
            .into_iter()
3393×
            .filter(|e| !e.is_working_copy)
3401×
            .map(|e| e.hash)
3411×
            .collect(),
342
    };
3431×
    let mut bad = 0;
3441×
    if as_json {
3451×
        let mut items = Vec::new();
3462×
        for h in &hashes {
3472×
            let Ok(c) = repo.store().get_change(h) else {
3480×
                continue;
349
            };
3502×
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
3510×
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
352
                SignatureStatus::Valid {
3532×
                    key,
3542×
                    name,
3552×
                    sealed,
3562×
                    revoked,
357
                    ..
358
                } => (
3592×
                    if sealed { "sealed" } else { "signed" },
3602×
                    Some(key.to_hex()),
3612×
                    Some(name),
3622×
                    revoked,
3632×
                    None,
364
                ),
3650×
                SignatureStatus::Invalid(why) => {
3660×
                    bad += 1;
3670×
                    ("invalid", None, None, false, Some(why))
368
                }
369
            };
3702×
            items.push(json::obj([
3712×
                ("hash", json::s(&h.to_hex())),
3722×
                ("change_id", json::s(&c.change_id.to_hex())),
3732×
                ("status", json::s(status)),
3742×
                ("key", json::opt(key.as_deref(), json::s)),
3752×
                ("name", json::opt(name.as_deref(), json::s)),
3762×
                ("revoked", json::b(revoked)),
3772×
                ("reason", json::opt(reason.as_deref(), json::s)),
3782×
            ]));
379
        }
3801×
        println!("{}", json::arr(items));
3811×
        return Ok(if bad > 0 { 1 } else { 0 });
3820×
    }
3830×
    for h in hashes {
3840×
        let Ok(c) = repo.store().get_change(&h) else {
3850×
            println!(
386
                "{}  {}",
3870×
                ui::hash(format!("#{}", h.short(12))),
3880×
                ui::dim("not present (shallow)")
389
            );
3900×
            continue;
391
        };
3920×
        let line = signature_line(&repo, &h)?;
3930×
        let styled = if line.starts_with("INVALID") {
3940×
            bad += 1;
3950×
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
3960×
        } else if line == "unsigned" {
3970×
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
398
        } else {
3990×
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
400
        };
4010×
        println!(
402
            "{} {}  {styled}",
4030×
            ui::id(short_id(&c)),
4040×
            ui::hash(format!("#{}", h.short(12)))
405
        );
406
    }
4070×
    Ok(if bad > 0 { 1 } else { 0 })
4081×
}
409
4101×
pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> {
411
    // An empty value removes the key, which is what --unset says plainly.
4121×
    let value = if unset { "" } else { value };
4131×
    let said = if unset {
4140×
        format!("{} {key}", ui::done("Unset"))
415
    } else {
4161×
        format!("{} {key} = {value}", ui::done("Set"))
417
    };
4181×
    if user {
4191×
        let path = repo::default_user_config();
4201×
        repo::Config::set_user(&path, key, value)?;
4211×
        println!("{said} {}", ui::dim(format!("(in {})", path.display())));
422
    } else {
4230×
        let mut repo = open()?;
4240×
        repo.config.set(key, value)?;
4250×
        repo.save_config()?;
4260×
        println!("{said} {}", ui::dim("(in this repository)"));
427
    }
4281×
    Ok(0)
4291×
}
430
4310×
pub fn export(path: Option<PathBuf>) -> Result<i32> {
4320×
    let repo = open()?;
4330×
    let Some(key) = repo.config.author_key else {
4340×
        anyhow::bail!("no identity configured; run `oxy identity create`");
435
    };
4360×
    let id = repo.identity(&key)?;
4370×
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
4380×
    std::fs::write(&path, id.encode())?;
4390×
    println!(
440
        "{} identity {} <{}> ({}) to {}",
4410×
        ui::done("Wrote"),
4420×
        ui::author(&id.name),
443
        id.email,
4440×
        key.short(12),
4450×
        ui::path(path.display())
446
    );
4470×
    Ok(0)
4480×
}
449
4500×
pub fn rotate() -> Result<i32> {
4510×
    let mut repo = open()?;
4520×
    let r = repo.rotate_identity()?;
4530×
    report_rotation(&repo, &r, "Rotated")?;
4540×
    Ok(0)
4550×
}
456
4570×
pub fn renew(expires: u64) -> Result<i32> {
4580×
    let mut repo = open()?;
4590×
    let r = repo.renew_identity(expiry_from_days(expires))?;
4600×
    report_rotation(&repo, &r, "Renewed")?;
4610×
    Ok(0)
4620×
}
463
4640×
pub fn revoke(key: Option<&str>, reason: &str) -> Result<i32> {
4650×
    let repo = open()?;
4660×
    let key = match key {
4670×
        Some(k) => resolve_key(&repo, k)?,
4680×
        None => repo
4690×
            .config
4700×
            .author_key
4710×
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
472
    };
4730×
    let update = repo.revoke_identity(&key, reason)?;
4740×
    println!(
475
        "{} key {}{}",
4760×
        ui::done("Revoked"),
4770×
        ui::hash(key.short(12)),
4780×
        if reason.is_empty() {
4790×
            String::new()
480
        } else {
4810×
            format!(": {reason}")
482
        }
483
    );
4840×
    match update {
4850×
        Some(u) => report_update(&u, None)?,
4860×
        None => println!(
487
            "  {} {}",
4880×
            ui::note(),
4890×
            ui::dim("recorded locally; no policy named the key")
490
        ),
491
    }
492
    // A revoked delegate comes off the identity too, so the successor no
493
    // longer names it (spec §14.2).
4940×
    let own = repo.config.author_key;
4950×
    let delegated = own.is_some_and(|o| {
4960×
        repo.identity(&o)
4970×
            .is_ok_and(|id| id.delegate(&key).is_some())
4980×
    });
4990×
    if delegated {
5000×
        let mut repo = repo;
5010×
        let r = repo.delegate_remove(&key)?;
5020×
        report_rotation(&repo, &r, "Undelegated")?;
5030×
        return Ok(0);
5040×
    }
5050×
    if repo.config.author_key == Some(key) {
5060×
        println!(
5070×
            "  {} {}",
5080×
            ui::caution(),
5090×
            ui::warn("this was your configured key; run oxy identity create or rotate")
5100×
        );
5110×
    }
5120×
    Ok(0)
5130×
}
514
5150×
pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result<i32> {
5160×
    let mut repo = open()?;
5170×
    let ids = identity_files
5180×
        .iter()
5190×
        .map(|f| {
5200×
            Identity::decode(
5210×
                &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?,
522
            )
5230×
            .map_err(Into::into)
5240×
        })
5250×
        .collect::<Result<Vec<_>>>()?;
5260×
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
5270×
    let expires = expires.map(expiry_from_duration).transpose()?;
5280×
    let r = repo.delegate_add_many(ids, scope, expires)?;
5290×
    report_rotation(&repo, &r, "Delegated")?;
5300×
    println!(
531
        "  {} {}",
5320×
        ui::note(),
5330×
        ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home")
534
    );
5350×
    if !scope.has(Scope::REFS) {
5360×
        println!(
5370×
            "  {} {}",
5380×
            ui::note(),
5390×
            ui::dim("it may not move references: a push or tag it attests is refused")
5400×
        );
5410×
    }
5420×
    Ok(0)
5430×
}
544
5450×
pub fn delegate_remove(key: &str) -> Result<i32> {
5460×
    let mut repo = open()?;
5470×
    let k = resolve_key(&repo, key)?;
5480×
    let r = repo.delegate_remove(&k)?;
5490×
    report_rotation(&repo, &r, "Undelegated")?;
5500×
    Ok(0)
5510×
}
552
5530×
pub fn delegate_list() -> Result<i32> {
5540×
    let repo = open()?;
5550×
    let Some(key) = repo.config.author_key else {
5560×
        println!("No identity configured; run `oxy identity create`.");
5570×
        return Ok(0);
558
    };
5590×
    let id = repo.identity(&key)?;
5600×
    if id.delegates.is_empty() {
5610×
        println!("{}", ui::dim("No delegates."));
5620×
    }
5630×
    let now = Hlc::now().wall_ms;
5640×
    for d in &id.delegates {
5650×
        let who = repo
5660×
            .identity(&d.key)
5670×
            .map(|i| format!("{} <{}>", i.name, i.email))
5680×
            .unwrap_or_else(|_| "(unknown identity)".into());
5690×
        let until = match d.expires {
5700×
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
5710×
            Some(e) => ui::dim(format!("until {}", when(e))),
5720×
            None => String::new(),
573
        };
5740×
        println!(
575
            "  {} {}  {}  {} {}",
5760×
            ui::ok(ui::glyph("🔑", "*")),
5770×
            ui::hash(d.key.short(12)),
5780×
            ui::author(who),
5790×
            ui::dim(d.scope.to_string()),
580
            until
581
        );
582
    }
5830×
    Ok(0)
5840×
}
585
5860×
pub fn policy_root_add(identity_file: &Path, out: Option<PathBuf>) -> Result<i32> {
5870×
    let repo = open()?;
5880×
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
5890×
    let key = id.hash();
5900×
    let u = repo.policy_root_add(id.clone())?;
5910×
    println!(
592
        "{} root {} <{}> ({})",
5930×
        ui::done("Added"),
5940×
        ui::author(&id.name),
595
        id.email,
5960×
        key.short(12)
597
    );
5980×
    report_update(&u, out)?;
5990×
    Ok(0)
6000×
}
601
6020×
pub fn policy_root_remove(key: &str, out: Option<PathBuf>) -> Result<i32> {
6030×
    let repo = open()?;
6040×
    let k = resolve_key(&repo, key)?;
6050×
    let u = repo.policy_root_remove(&k)?;
6060×
    println!("{} root {}", ui::done("Removed"), ui::hash(k.short(12)));
6070×
    report_update(&u, out)?;
6080×
    Ok(0)
6090×
}
610
6110×
pub fn policy_threshold(n: u32, out: Option<PathBuf>) -> Result<i32> {
6120×
    let repo = open()?;
6130×
    let u = repo.policy_set_threshold(n)?;
6140×
    println!("{} root threshold to {n}", ui::done("Set"));
6150×
    report_update(&u, out)?;
6160×
    Ok(0)
6170×
}
618
6190×
pub fn policy_sign(proposal: &Path, out: Option<PathBuf>) -> Result<i32> {
6200×
    let repo = open()?;
6210×
    let sp = SignedPolicy::decode(&std::fs::read(proposal)?)?;
6220×
    let u = repo.policy_sign(sp)?;
6230×
    println!("{} policy serial {}", ui::done("Signed"), u.policy.serial);
6240×
    report_update(&u, Some(out.unwrap_or_else(|| proposal.to_path_buf())))?;
6250×
    Ok(0)
6260×
}
627
6281×
pub fn policy_init() -> Result<i32> {
6291×
    let repo = open()?;
6301×
    let sp = repo.policy_init()?;
6311×
    println!(
632
        "{} policy with root key {}",
6331×
        ui::done("Created"),
6341×
        sp.policy.roots[0].short(12)
635
    );
6361×
    report_update(&sp, None)?;
6371×
    Ok(0)
6381×
}
639
6402×
pub fn policy_show(as_json: bool) -> Result<i32> {
6412×
    let repo = open()?;
6422×
    let Some((r, sp)) = repo.current_policy()? else {
6431×
        if as_json {
6441×
            println!("null");
6451×
        } else {
6460×
            println!(
6470×
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
6480×
            );
6490×
        }
6501×
        return Ok(0);
651
    };
6521×
    let p = &sp.policy;
6531×
    if as_json {
6542×
        let ident = |k: &oxy_core::hash::KeyId| {
6552×
            let (name, email) = p
6562×
                .identity(k)
6572×
                .map(|i| (i.name.clone(), i.email.clone()))
6582×
                .unwrap_or_default();
6592×
            json::obj([
6602×
                ("key", json::s(&k.to_hex())),
6612×
                ("name", json::s(&name)),
6622×
                ("email", json::s(&email)),
6632×
            ])
6642×
        };
6651×
        println!(
666
            "{}",
6671×
            json::obj([
6681×
                ("hash", json::s(&r.to_hex())),
6691×
                ("serial", json::n(p.serial)),
6701×
                ("expires_ms", json::n(p.expires)),
6711×
                (
6721×
                    "expired",
6731×
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
6741×
                ),
6751×
                ("root_threshold", json::n(p.root_threshold)),
6761×
                ("roots", json::arr(p.roots.iter().map(ident))),
677
                (
6781×
                    "rules",
6791×
                    json::arr(p.rules.iter().map(|rule| {
6801×
                        json::obj([
6811×
                            ("pattern", json::s(&rule.pattern)),
6821×
                            ("keys", json::arr(rule.keys.iter().map(ident))),
6831×
                            ("threshold", json::n(rule.threshold)),
6841×
                            ("require", json::s(rule.require.name())),
6851×
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
6861×
                        ])
6871×
                    })),
688
                ),
6891×
                ("revoked", json::arr(p.revoked.iter().map(ident))),
690
            ])
691
        );
6921×
        return Ok(0);
6930×
    }
6940×
    let name = |k: &oxy_core::hash::KeyId| {
6950×
        p.identity(k)
6960×
            .map(|i| format!("{} <{}>", i.name, i.email))
6970×
            .unwrap_or_else(|| "(unknown identity)".into())
6980×
    };
6990×
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
7000×
    println!(
701
        "{} {}{}serial {}{}expires {}{}",
7020×
        ui::section("policy"),
7030×
        ui::hash(format!("#{}", r.short(12))),
7040×
        ui::sep(),
705
        p.serial,
7060×
        ui::sep(),
7070×
        when(p.expires),
7080×
        if expired {
7090×
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
710
        } else {
7110×
            String::new()
712
        }
713
    );
7140×
    println!(
715
        "{}",
7160×
        ui::section(format!(
717
            "roots {}",
7180×
            ui::dim(format!("(threshold {})", p.root_threshold))
719
        ))
720
    );
7210×
    for k in &p.roots {
7220×
        println!(
7230×
            "  {} {}  {}",
7240×
            ui::ok(ui::glyph("🔑", "*")),
7250×
            ui::hash(k.short(12)),
7260×
            ui::author(name(k))
7270×
        );
7280×
    }
7290×
    println!("{}", ui::section("rules"));
7300×
    for rule in &p.rules {
7310×
        println!(
732
            "  {} {:12} {}",
7330×
            ui::ok(ui::glyph("▸", ">")),
7340×
            ui::bookmark(&rule.pattern),
7350×
            ui::dim(format!(
736
                "threshold {}, require {}, conflicts {}",
737
                rule.threshold,
7380×
                rule.require.name(),
7390×
                if rule.allow_conflicts {
7400×
                    "allowed"
741
                } else {
7420×
                    "refused"
743
                }
744
            ))
745
        );
7460×
        for k in &rule.keys {
7470×
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
7480×
        }
749
    }
7500×
    if !p.revoked.is_empty() {
7510×
        println!("{}", ui::section("revoked"));
7520×
        for k in &p.revoked {
7530×
            println!(
7540×
                "  {} {}  {}",
7550×
                ui::err(ui::glyph("✖", "x")),
7560×
                ui::hash(k.short(12)),
7570×
                ui::author(name(k))
7580×
            );
7590×
        }
7600×
    }
7610×
    Ok(0)
7622×
}
763
7640×
pub fn policy_grant(
7650×
    identity_file: &Path,
7660×
    pattern: Option<&str>,
7670×
    out: Option<PathBuf>,
7680×
) -> Result<i32> {
7690×
    let repo = open()?;
7700×
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
7710×
    let key = id.hash();
7720×
    let u = repo.policy_grant(id.clone(), pattern)?;
7730×
    println!(
774
        "{} {} <{}> ({}) on {}",
7750×
        ui::done("Granted"),
7760×
        ui::author(&id.name),
777
        id.email,
7780×
        key.short(12),
7790×
        ui::bookmark(pattern.unwrap_or("*"))
780
    );
7810×
    report_update(&u, out)?;
7820×
    Ok(0)
7830×
}
784
7850×
pub fn policy_revoke(key: &str, out: Option<PathBuf>) -> Result<i32> {
7860×
    let repo = open()?;
7870×
    if repo.current_policy()?.is_none() {
7880×
        anyhow::bail!("this repository has no policy");
7890×
    }
7900×
    let full = resolve_key(&repo, key)?;
7910×
    let u = repo.policy_revoke(&full)?;
7920×
    println!("{} {}", ui::done("Revoked"), ui::hash(full.short(12)));
7930×
    report_update(&u, out)?;
7940×
    Ok(0)
7950×
}
796
797
/// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one.
7980×
pub fn seal(specs: &[String], as_json: bool) -> Result<i32> {
7990×
    let repo = Repo::open(&std::env::current_dir()?)?;
8000×
    let hashes: Vec<_> = if specs.is_empty() {
8010×
        repo.sealable()?
802
    } else {
8030×
        specs
8040×
            .iter()
8050×
            .map(|s| repo.resolve_change(s))
8060×
            .collect::<std::result::Result<_, _>>()?
807
    };
8080×
    if hashes.is_empty() {
8090×
        if as_json {
8100×
            println!("{{\"sealed\":[]}}");
8110×
        } else {
8120×
            println!(
8130×
                "{} nothing to seal: every change you can authenticate already is",
8140×
                ui::done("Sealed")
8150×
            );
8160×
        }
8170×
        return Ok(0);
8180×
    }
8190×
    let mut refs = Vec::new();
8200×
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
8210×
        refs.push(repo.seal(chunk)?);
822
    }
8230×
    if as_json {
8240×
        let list: Vec<String> = hashes
8250×
            .iter()
8260×
            .map(|h| format!("\"{}\"", h.to_hex()))
8270×
            .collect();
8280×
        let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect();
8290×
        println!(
830
            "{{\"sealed\":[{}],\"seals\":[{}]}}",
8310×
            list.join(","),
8320×
            seals.join(",")
833
        );
834
    } else {
8350×
        println!(
836
            "{} {} change{} in {} seal{}; they travel with your next push",
8370×
            ui::done("Sealed"),
8380×
            hashes.len(),
8390×
            if hashes.len() == 1 { "" } else { "s" },
8400×
            refs.len(),
8410×
            if refs.len() == 1 { "" } else { "s" }
842
        );
8430×
        for h in &hashes {
8440×
            let title = repo
8450×
                .store()
8460×
                .get_change(h)
8470×
                .map(|c| c.message.lines().next().unwrap_or("").to_string())
8480×
                .unwrap_or_default();
8490×
            println!("  {} {}", ui::hash(h.short(12)), title);
850
        }
851
    }
8520×
    Ok(0)
8530×
}
854
855
/// `oxy identity sign-challenge HEX`: the composite signature over a
856
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
8570×
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
8580×
    let repo = open()?;
8590×
    let Some((key, sk)) = repo.secret_key()? else {
8600×
        anyhow::bail!("no secret key: run `oxy identity create` first");
861
    };
8620×
    let challenge = hex_bytes(challenge_hex.trim())
8630×
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
8640×
    let sig = sk
8650×
        .sign_challenge(&key, &challenge)
8660×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
8670×
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
8680×
    if as_json {
8690×
        println!(
8700×
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
8710×
            key.to_hex(),
8720×
            encoded
8730×
        );
8740×
    } else {
8750×
        println!("{encoded}");
8760×
    }
8770×
    Ok(0)
8780×
}
879
8800×
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
8810×
    if !s.len().is_multiple_of(2) {
8820×
        return None;
8830×
    }
8840×
    (0..s.len())
8850×
        .step_by(2)
8860×
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
8870×
        .collect()
8880×
}
889
8900×
fn hex_string(b: &[u8]) -> String {
8910×
    b.iter().map(|x| format!("{x:02x}")).collect()
8920×
}