anvilsign in

collin/anvil

1//! `anvild secret` — the client half of repository secrets.
2//!
3//! Everything cryptographic happens here, on a machine that holds an ssh
4//! private key. The server stores sealed envelopes it cannot open, so reading a
5//! secret, re-sealing it for a newly added key, and unlocking a repository for
6//! CI are all client operations. See `docs/secrets.md`.
7
8use std::{
9 collections::BTreeMap,
10 io::{
11 IsTerminal,
12 Read,
13 },
14 path::PathBuf,
15};
16
17use anvil_core::secrets::{
18 Envelope,
19 Identity,
20 Recipient,
21 body_aad,
22 kind,
23 seal,
24 user_aad,
25};
26use anyhow::{
27 Context,
28 Result,
29 anyhow,
30 bail,
31};
32use clap::Subcommand;
33use serde::Deserialize;
34
35#[derive(Subcommand)]
36pub enum SecretCommand {
37 /// List a repository's secrets (names and key coverage, never values).
38 List {
39 /// Repository in `owner/name` form.
40 repo: String,
41 },
42 /// Encrypt a value and store it. Reads the value from stdin unless
43 /// `--value` is given.
44 Set {
45 repo: String,
46 /// Variable name, e.g. `DEPLOY_TOKEN`.
47 name: String,
48 /// The value. Prefer stdin or the prompt: an argument is visible in
49 /// `ps` output and lands in your shell history.
50 #[arg(long)]
51 value: Option<String>,
52 },
53 /// Decrypt and print one secret.
54 Get { repo: String, name: String },
55 /// Delete a secret.
56 Rm { repo: String, name: String },
57 /// Decrypt every secret and hand the values to the server, which holds
58 /// them in memory (never on disk) so CI can use them until they expire.
59 Unlock {
60 repo: String,
61 /// How long the unlock lasts, e.g. `8h`, `45m`, `7d`.
62 #[arg(long, default_value = "8h")]
63 ttl: String,
64 },
65 /// Forget the unlocked values on the server immediately.
66 Lock { repo: String },
67 /// Re-seal every secret to the owner's current ssh keys — run this after
68 /// adding a key, which otherwise cannot open anything sealed before it.
69 Rekey { repo: String },
70 /// Secrets scoped to your own account rather than a repository — for
71 /// injecting into your own agent sessions. See `docs/secrets.md`.
72 #[command(subcommand)]
73 User(UserSecretCommand),
74}
75
76#[derive(Subcommand)]
77pub enum UserSecretCommand {
78 /// List your secrets (names, kind, and key coverage — never values).
79 List,
80 /// Encrypt a value and store it as an environment variable secret. Reads
81 /// the value from stdin unless `--value` is given.
82 Set {
83 /// Variable name, e.g. `CLAUDE_CREDS`.
84 name: String,
85 #[arg(long)]
86 value: Option<String>,
87 },
88 /// Encrypt a value and store it as a whole-file secret, written at
89 /// `--path` (relative to `$HOME`) in any session that opts in. Reads the
90 /// value from `--value-file`, or stdin if that is omitted.
91 SetFile {
92 name: String,
93 /// Destination under the session's $HOME, e.g.
94 /// `.claude/.credentials.json`.
95 #[arg(long)]
96 path: String,
97 #[arg(long)]
98 value_file: Option<PathBuf>,
99 },
100 /// Encrypt a value and store it as a json-merge secret: `--field` (a
101 /// jq-style path, e.g. `.oauthAccount.token`) is set inside the JSON file
102 /// at `--path`, leaving the rest of that file alone. Reads the value from
103 /// stdin unless `--value` is given.
104 SetJson {
105 name: String,
106 #[arg(long)]
107 path: String,
108 /// jq-style path within that file, e.g. `.oauthAccount.token`.
109 #[arg(long)]
110 field: String,
111 #[arg(long)]
112 value: Option<String>,
113 },
114 /// Decrypt and print one secret's value.
115 Get { name: String },
116 /// Delete a secret.
117 Rm { name: String },
118 /// Decrypt every secret and hand the values to the server, which holds
119 /// them in memory (never on disk) so your agent sessions can use them
120 /// until they expire.
121 Unlock {
122 /// How long the unlock lasts, e.g. `8h`, `45m`, `7d`.
123 #[arg(long, default_value = "8h")]
124 ttl: String,
125 },
126 /// Forget the unlocked values on the server immediately.
127 Lock,
128 /// Re-seal every secret to your current ssh keys — run this after adding
129 /// a key, which otherwise cannot open anything sealed before it.
130 Rekey,
131}
132
133/// Connection and identity options shared by every `secret` subcommand.
134#[derive(clap::Args)]
135pub struct SecretOpts {
136 /// anvil base URL. Defaults to `$ANVIL_SERVER`, then the config's
137 /// `http.base_url`.
138 #[arg(long, global = true)]
139 pub server: Option<String>,
140 /// Account username. Defaults to `$ANVIL_USER`.
141 #[arg(long = "as", global = true)]
142 pub username: Option<String>,
143 /// SSH private key that opens the envelopes. Defaults to
144 /// `$ANVIL_IDENTITY`, then `~/.ssh/id_ed25519`.
145 #[arg(long, short = 'i', global = true)]
146 pub identity: Option<PathBuf>,
147}
148
149pub async fn run(command: SecretCommand, opts: &SecretOpts, config_base_url: &str) -> Result<()> {
150 let client = Client::new(opts, config_base_url)?;
151 match command {
152 SecretCommand::List { repo } => list(&client, &repo).await,
153 SecretCommand::Set { repo, name, value } => set(&client, opts, &repo, &name, value).await,
154 SecretCommand::Get { repo, name } => get(&client, opts, &repo, &name).await,
155 SecretCommand::Rm { repo, name } => {
156 client.delete(&repo, &name).await?;
157 println!("deleted {name} from {repo}");
158 Ok(())
159 }
160 SecretCommand::Unlock { repo, ttl } => unlock(&client, opts, &repo, &ttl).await,
161 SecretCommand::Lock { repo } => {
162 client.lock(&repo).await?;
163 println!("{repo} sealed — CI runs that declare secrets will fail until unlocked");
164 Ok(())
165 }
166 SecretCommand::Rekey { repo } => rekey(&client, opts, &repo).await,
167 SecretCommand::User(cmd) => run_user(cmd, &client, opts).await,
168 }
169}
170
171async fn run_user(command: UserSecretCommand, client: &Client, opts: &SecretOpts) -> Result<()> {
172 match command {
173 UserSecretCommand::List => user_list(client).await,
174 UserSecretCommand::Set { name, value } => {
175 user_set(client, &name, kind::ENV, "", "", read_value(value)?).await
176 }
177 UserSecretCommand::SetFile {
178 name,
179 path,
180 value_file,
181 } => {
182 let value = match value_file {
183 Some(path) => std::fs::read_to_string(&path)
184 .with_context(|| format!("reading {}", path.display()))?,
185 None => read_value(None)?,
186 };
187 user_set(client, &name, kind::FILE, &path, "", value).await
188 }
189 UserSecretCommand::SetJson {
190 name,
191 path,
192 field,
193 value,
194 } => user_set(client, &name, kind::JSON, &path, &field, read_value(value)?).await,
195 UserSecretCommand::Get { name } => user_get(client, opts, &name).await,
196 UserSecretCommand::Rm { name } => {
197 client.delete_user(&name).await?;
198 println!("deleted {name}");
199 Ok(())
200 }
201 UserSecretCommand::Unlock { ttl } => user_unlock(client, opts, &ttl).await,
202 UserSecretCommand::Lock => {
203 client.lock_user().await?;
204 println!(
205 "sealed — agent sessions that opt into a secret will fail to start until unlocked"
206 );
207 Ok(())
208 }
209 UserSecretCommand::Rekey => user_rekey(client, opts).await,
210 }
211}
212
213/// A value from `--value`, or stdin (a prompt if it's a terminal, else read
214/// to EOF) — the same fallback [`set`] uses.
215fn read_value(value: Option<String>) -> Result<String> {
216 match value {
217 Some(v) => Ok(v),
218 None if std::io::stdin().is_terminal() => Ok(rpassword::prompt_password("value: ")?),
219 None => {
220 let mut buf = String::new();
221 std::io::stdin().read_to_string(&mut buf)?;
222 Ok(buf.trim_end_matches('\n').to_string())
223 }
224 }
225}
226
227// --- commands --------------------------------------------------------------
228
229async fn list(client: &Client, repo: &str) -> Result<()> {
230 let state = client.fetch(repo).await?;
231 if state.secrets.is_empty() {
232 println!("{repo} has no secrets.");
233 }
234 let current: Vec<&str> = state
235 .recipients
236 .iter()
237 .map(|r| r.fingerprint.as_str())
238 .collect();
239 for secret in &state.secrets {
240 let missing = current
241 .iter()
242 .filter(|fp| !secret.recipients.iter().any(|s| s == **fp))
243 .count();
244 let note = if missing > 0 {
245 format!(
246 " — {missing} registered key(s) cannot open it; run `anvild secret rekey {repo}`"
247 )
248 } else {
249 String::new()
250 };
251 println!(
252 "{:<24} sealed to {} key(s){note}",
253 secret.name,
254 secret.recipients.len()
255 );
256 }
257 match state.unlocked_until {
258 0 => println!("\nsealed (CI cannot read these)"),
259 until => println!("\nunlocked for CI until {}", fmt_time(until)),
260 }
261 Ok(())
262}
263
264async fn set(
265 client: &Client,
266 opts: &SecretOpts,
267 repo: &str,
268 name: &str,
269 value: Option<String>,
270) -> Result<()> {
271 if !anvil_core::secrets::valid_name(name) {
272 bail!("secret names are A–Z, 0–9 and _, and cannot start with a digit");
273 }
274 let state = client.fetch(repo).await?;
275 let recipients = state.recipient_keys()?;
276 let value = match value {
277 Some(v) => v,
278 None if std::io::stdin().is_terminal() => {
279 rpassword::prompt_password(format!("value for {name}: "))?
280 }
281 None => {
282 let mut buf = String::new();
283 std::io::stdin().read_to_string(&mut buf)?;
284 // A here-doc or `echo` adds a newline that is never part of a token.
285 buf.trim_end_matches('\n').to_string()
286 }
287 };
288 let (owner, name_only) = split_repo(repo)?;
289 let envelope = seal(
290 value.as_bytes(),
291 &body_aad(owner, name_only, name),
292 &recipients,
293 )?;
294 client.put(repo, name, &envelope).await?;
295 println!(
296 "sealed {name} to {} key(s) in {repo}",
297 envelope.recipients.len()
298 );
299 if state.unlocked_until > 0 {
300 println!(
301 "note: {repo} is unlocked with the *old* set — re-run `anvild secret unlock` for CI to see this value"
302 );
303 }
304 // `opts` participates only through the client; the identity is not needed
305 // to seal, which is the point of a public-key scheme.
306 let _ = opts;
307 Ok(())
308}
309
310async fn get(client: &Client, opts: &SecretOpts, repo: &str, name: &str) -> Result<()> {
311 let state = client.fetch(repo).await?;
312 let identity = load_identity(opts)?;
313 let (owner, repo_name) = split_repo(repo)?;
314 let secret = state
315 .secrets
316 .iter()
317 .find(|s| s.name == name)
318 .ok_or_else(|| anyhow!("{repo} has no secret named {name}"))?;
319 let envelope = secret.parse()?;
320 let plaintext = envelope.open(&body_aad(owner, repo_name, name), &identity)?;
321 print!("{}", String::from_utf8_lossy(&plaintext));
322 Ok(())
323}
324
325async fn unlock(client: &Client, opts: &SecretOpts, repo: &str, ttl: &str) -> Result<()> {
326 let state = client.fetch(repo).await?;
327 if state.secrets.is_empty() {
328 bail!("{repo} has no secrets to unlock");
329 }
330 let identity = load_identity(opts)?;
331 let (owner, repo_name) = split_repo(repo)?;
332 let mut values = BTreeMap::new();
333 for secret in &state.secrets {
334 let envelope = secret.parse()?;
335 let plaintext = envelope
336 .open(&body_aad(owner, repo_name, &secret.name), &identity)
337 .with_context(|| format!("opening {}", secret.name))?;
338 values.insert(
339 secret.name.clone(),
340 String::from_utf8(plaintext)
341 .with_context(|| format!("{} is not valid UTF-8", secret.name))?,
342 );
343 }
344 let response = client.unlock(repo, values, parse_ttl(ttl)?).await?;
345 println!(
346 "unlocked {repo} with {} value(s) until {} — held in memory only, and lost on restart",
347 response.count,
348 fmt_time(response.unlocked_until)
349 );
350 Ok(())
351}
352
353async fn rekey(client: &Client, opts: &SecretOpts, repo: &str) -> Result<()> {
354 let state = client.fetch(repo).await?;
355 let recipients = state.recipient_keys()?;
356 let identity = load_identity(opts)?;
357 let (owner, repo_name) = split_repo(repo)?;
358 let mut rekeyed = 0;
359 for secret in &state.secrets {
360 let current: Vec<String> = recipients.iter().map(|r| r.fingerprint.clone()).collect();
361 if current.len() == secret.recipients.len()
362 && current.iter().all(|fp| secret.recipients.contains(fp))
363 {
364 continue; // already sealed to exactly the current key set
365 }
366 let aad = body_aad(owner, repo_name, &secret.name);
367 let plaintext = secret
368 .parse()?
369 .open(&aad, &identity)
370 .with_context(|| format!("opening {}", secret.name))?;
371 let resealed = seal(&plaintext, &aad, &recipients)?;
372 client.put(repo, &secret.name, &resealed).await?;
373 println!("re-sealed {} to {} key(s)", secret.name, recipients.len());
374 rekeyed += 1;
375 }
376 if rekeyed == 0 {
377 println!("nothing to do — every secret is already sealed to the current keys");
378 }
379 Ok(())
380}
381
382// --- user secret commands ---------------------------------------------------
383//
384// Same shape as the repository commands above, minus the repository: the
385// target is always the authenticated account itself.
386
387async fn user_list(client: &Client) -> Result<()> {
388 let state = client.fetch_user().await?;
389 if state.secrets.is_empty() {
390 println!("no user secrets.");
391 }
392 let current: Vec<&str> = state
393 .recipients
394 .iter()
395 .map(|r| r.fingerprint.as_str())
396 .collect();
397 for secret in &state.secrets {
398 let missing = current
399 .iter()
400 .filter(|fp| !secret.recipients.iter().any(|s| s == **fp))
401 .count();
402 let note = if missing > 0 {
403 format!(" — {missing} registered key(s) cannot open it; run `anvild secret user rekey`")
404 } else {
405 String::new()
406 };
407 let dest = match secret.kind.as_str() {
408 kind::JSON => format!(" {} {}", secret.dest_path, secret.field),
409 kind::FILE => format!(" {}", secret.dest_path),
410 _ => String::new(),
411 };
412 println!(
413 "{:<24} {}{dest} sealed to {} key(s){note}",
414 secret.name,
415 secret.kind,
416 secret.recipients.len()
417 );
418 }
419 match state.unlocked_until {
420 0 => println!("\nsealed (agent sessions cannot read these)"),
421 until => println!("\nunlocked until {}", fmt_time(until)),
422 }
423 Ok(())
424}
425
426async fn user_set(
427 client: &Client,
428 name: &str,
429 set_kind: &str,
430 dest_path: &str,
431 field: &str,
432 value: String,
433) -> Result<()> {
434 if !anvil_core::secrets::valid_name(name) {
435 bail!("secret names are A–Z, 0–9 and _, and cannot start with a digit");
436 }
437 let state = client.fetch_user().await?;
438 let recipients = state.recipient_keys()?;
439 let envelope = seal(
440 value.as_bytes(),
441 &user_aad(&client.username, name),
442 &recipients,
443 )?;
444 client
445 .put_user(name, set_kind, dest_path, field, &envelope)
446 .await?;
447 println!("sealed {name} to {} key(s)", envelope.recipients.len());
448 if state.unlocked_until > 0 {
449 println!(
450 "note: your secrets are unlocked with the *old* set — re-run `anvild secret user unlock` for sessions to see this value"
451 );
452 }
453 Ok(())
454}
455
456async fn user_get(client: &Client, opts: &SecretOpts, name: &str) -> Result<()> {
457 let state = client.fetch_user().await?;
458 let identity = load_identity(opts)?;
459 let secret = state
460 .secrets
461 .iter()
462 .find(|s| s.name == name)
463 .ok_or_else(|| anyhow!("no user secret named {name}"))?;
464 let envelope = secret.parse()?;
465 let plaintext = envelope.open(&user_aad(&client.username, name), &identity)?;
466 print!("{}", String::from_utf8_lossy(&plaintext));
467 Ok(())
468}
469
470async fn user_unlock(client: &Client, opts: &SecretOpts, ttl: &str) -> Result<()> {
471 let state = client.fetch_user().await?;
472 if state.secrets.is_empty() {
473 bail!("no user secrets to unlock");
474 }
475 let identity = load_identity(opts)?;
476 let mut values = BTreeMap::new();
477 for secret in &state.secrets {
478 let envelope = secret.parse()?;
479 let plaintext = envelope
480 .open(&user_aad(&client.username, &secret.name), &identity)
481 .with_context(|| format!("opening {}", secret.name))?;
482 values.insert(
483 secret.name.clone(),
484 String::from_utf8(plaintext)
485 .with_context(|| format!("{} is not valid UTF-8", secret.name))?,
486 );
487 }
488 let response = client.unlock_user(values, parse_ttl(ttl)?).await?;
489 println!(
490 "unlocked {} value(s) until {} — held in memory only, and lost on restart",
491 response.count,
492 fmt_time(response.unlocked_until)
493 );
494 Ok(())
495}
496
497async fn user_rekey(client: &Client, opts: &SecretOpts) -> Result<()> {
498 let state = client.fetch_user().await?;
499 let recipients = state.recipient_keys()?;
500 let identity = load_identity(opts)?;
501 let mut rekeyed = 0;
502 for secret in &state.secrets {
503 let current: Vec<String> = recipients.iter().map(|r| r.fingerprint.clone()).collect();
504 if current.len() == secret.recipients.len()
505 && current.iter().all(|fp| secret.recipients.contains(fp))
506 {
507 continue;
508 }
509 let aad = user_aad(&client.username, &secret.name);
510 let plaintext = secret
511 .parse()?
512 .open(&aad, &identity)
513 .with_context(|| format!("opening {}", secret.name))?;
514 let resealed = seal(&plaintext, &aad, &recipients)?;
515 client
516 .put_user(
517 &secret.name,
518 &secret.kind,
519 &secret.dest_path,
520 &secret.field,
521 &resealed,
522 )
523 .await?;
524 println!("re-sealed {} to {} key(s)", secret.name, recipients.len());
525 rekeyed += 1;
526 }
527 if rekeyed == 0 {
528 println!("nothing to do — every secret is already sealed to the current keys");
529 }
530 Ok(())
531}
532
533// --- identity --------------------------------------------------------------
534
535fn load_identity(opts: &SecretOpts) -> Result<Identity> {
536 let path = opts
537 .identity
538 .clone()
539 .or_else(|| std::env::var("ANVIL_IDENTITY").ok().map(PathBuf::from))
540 .or_else(|| {
541 std::env::var("HOME")
542 .ok()
543 .map(|home| PathBuf::from(home).join(".ssh/id_ed25519"))
544 })
545 .ok_or_else(|| anyhow!("no ssh key given; pass --identity"))?;
546
547 let key = ssh_key::PrivateKey::read_openssh_file(&path)
548 .with_context(|| format!("reading ssh key {}", path.display()))?;
549 let key = if key.is_encrypted() {
550 // ssh-agent is no help here: the agent protocol only signs, and opening
551 // an envelope needs the scalar itself for key agreement. So the key file
552 // has to be decrypted in this process.
553 let passphrase = match std::env::var("ANVIL_KEY_PASSPHRASE") {
554 Ok(passphrase) => passphrase,
555 Err(_) => prompt_passphrase(&path)?,
556 };
557 key.decrypt(passphrase)
558 .with_context(|| format!("decrypting {} (wrong passphrase?)", path.display()))?
559 } else {
560 key
561 };
562 Ok(Identity::from_private_key(&key)?)
563}
564
565/// Ask for the key's passphrase, explaining the way out when there is no
566/// terminal to ask on (a cron job, a pipeline, an agent's shell).
567fn prompt_passphrase(path: &std::path::Path) -> Result<String> {
568 rpassword::prompt_password(format!("passphrase for {}: ", path.display())).map_err(|e| {
569 anyhow!(
570 "{} is passphrase-protected and there is no terminal to prompt on ({e}). \
571 Set ANVIL_KEY_PASSPHRASE, or point --identity at an unencrypted key.",
572 path.display()
573 )
574 })
575}
576
577// --- HTTP client -----------------------------------------------------------
578
579struct Client {
580 base: String,
581 username: String,
582 password: String,
583 http: reqwest::Client,
584}
585
586#[derive(Deserialize)]
587struct SecretsState {
588 unlocked_until: i64,
589 recipients: Vec<RecipientJson>,
590 secrets: Vec<SecretJson>,
591}
592
593#[derive(Deserialize)]
594struct RecipientJson {
595 fingerprint: String,
596 key: String,
597}
598
599#[derive(Deserialize)]
600struct SecretJson {
601 name: String,
602 envelope: serde_json::Value,
603 recipients: Vec<String>,
604}
605
606#[derive(Deserialize)]
607struct UnlockResponse {
608 unlocked_until: i64,
609 count: usize,
610}
611
612impl SecretsState {
613 fn recipient_keys(&self) -> Result<Vec<Recipient>> {
614 if self.recipients.is_empty() {
615 bail!("the repository owner has no ssh-ed25519 key registered — add one first");
616 }
617 self.recipients
618 .iter()
619 .map(|r| Recipient::from_openssh(&r.key).map_err(Into::into))
620 .collect()
621 }
622}
623
624impl SecretJson {
625 fn parse(&self) -> Result<Envelope> {
626 Ok(Envelope::parse(&serde_json::to_string(&self.envelope)?)?)
627 }
628}
629
630#[derive(Deserialize)]
631struct UserSecretsState {
632 unlocked_until: i64,
633 recipients: Vec<RecipientJson>,
634 secrets: Vec<UserSecretJson>,
635}
636
637#[derive(Deserialize)]
638struct UserSecretJson {
639 name: String,
640 kind: String,
641 dest_path: String,
642 field: String,
643 envelope: serde_json::Value,
644 recipients: Vec<String>,
645}
646
647impl UserSecretsState {
648 fn recipient_keys(&self) -> Result<Vec<Recipient>> {
649 if self.recipients.is_empty() {
650 bail!("you have no ssh-ed25519 key registered — add one first");
651 }
652 self.recipients
653 .iter()
654 .map(|r| Recipient::from_openssh(&r.key).map_err(Into::into))
655 .collect()
656 }
657}
658
659impl UserSecretJson {
660 fn parse(&self) -> Result<Envelope> {
661 Ok(Envelope::parse(&serde_json::to_string(&self.envelope)?)?)
662 }
663}
664
665impl Client {
666 fn new(opts: &SecretOpts, config_base_url: &str) -> Result<Self> {
667 // HTTPS needs a crypto provider installed; the build deliberately has
668 // only ring (see the workspace manifest).
669 let _ = rustls::crypto::ring::default_provider().install_default();
670
671 let base = opts
672 .server
673 .clone()
674 .or_else(|| std::env::var("ANVIL_SERVER").ok())
675 .unwrap_or_else(|| config_base_url.to_string());
676 if base.is_empty() {
677 bail!("no server URL; pass --server or set ANVIL_SERVER");
678 }
679 let username = opts
680 .username
681 .clone()
682 .or_else(|| std::env::var("ANVIL_USER").ok())
683 .ok_or_else(|| anyhow!("no username; pass --as or set ANVIL_USER"))?;
684 let password = match std::env::var("ANVIL_PASSWORD") {
685 Ok(p) => p,
686 Err(_) => rpassword::prompt_password(format!("anvil password for {username}: "))?,
687 };
688 Ok(Self {
689 base: base.trim_end_matches('/').to_string(),
690 username,
691 password,
692 http: reqwest::Client::new(),
693 })
694 }
695
696 fn url(&self, repo: &str, suffix: &str) -> String {
697 format!("{}/{repo}/-/api/secrets{suffix}", self.base)
698 }
699
700 async fn fetch(&self, repo: &str) -> Result<SecretsState> {
701 split_repo(repo)?;
702 let response = self
703 .http
704 .get(self.url(repo, ""))
705 .basic_auth(&self.username, Some(&self.password))
706 .send()
707 .await
708 .context("contacting anvil")?;
709 check(response).await?.json().await.context("reading reply")
710 }
711
712 async fn put(&self, repo: &str, name: &str, envelope: &Envelope) -> Result<()> {
713 let response = self
714 .http
715 .post(self.url(repo, ""))
716 .basic_auth(&self.username, Some(&self.password))
717 .json(&serde_json::json!({ "name": name, "envelope": envelope }))
718 .send()
719 .await
720 .context("contacting anvil")?;
721 check(response).await?;
722 Ok(())
723 }
724
725 async fn delete(&self, repo: &str, name: &str) -> Result<()> {
726 let response = self
727 .http
728 .delete(self.url(repo, &format!("/{name}")))
729 .basic_auth(&self.username, Some(&self.password))
730 .send()
731 .await
732 .context("contacting anvil")?;
733 check(response).await?;
734 Ok(())
735 }
736
737 async fn unlock(
738 &self,
739 repo: &str,
740 values: BTreeMap<String, String>,
741 ttl_secs: i64,
742 ) -> Result<UnlockResponse> {
743 let response = self
744 .http
745 .post(self.url(repo, "/unlock"))
746 .basic_auth(&self.username, Some(&self.password))
747 .json(&serde_json::json!({ "values": values, "ttl_secs": ttl_secs }))
748 .send()
749 .await
750 .context("contacting anvil")?;
751 check(response).await?.json().await.context("reading reply")
752 }
753
754 async fn lock(&self, repo: &str) -> Result<()> {
755 let response = self
756 .http
757 .post(self.url(repo, "/lock"))
758 .basic_auth(&self.username, Some(&self.password))
759 .send()
760 .await
761 .context("contacting anvil")?;
762 check(response).await?;
763 Ok(())
764 }
765
766 fn user_url(&self, suffix: &str) -> String {
767 format!("{}/-/api/user/secrets{suffix}", self.base)
768 }
769
770 async fn fetch_user(&self) -> Result<UserSecretsState> {
771 let response = self
772 .http
773 .get(self.user_url(""))
774 .basic_auth(&self.username, Some(&self.password))
775 .send()
776 .await
777 .context("contacting anvil")?;
778 check(response).await?.json().await.context("reading reply")
779 }
780
781 async fn put_user(
782 &self,
783 name: &str,
784 put_kind: &str,
785 dest_path: &str,
786 field: &str,
787 envelope: &Envelope,
788 ) -> Result<()> {
789 let response = self
790 .http
791 .post(self.user_url(""))
792 .basic_auth(&self.username, Some(&self.password))
793 .json(&serde_json::json!({
794 "name": name,
795 "kind": put_kind,
796 "dest_path": dest_path,
797 "field": field,
798 "envelope": envelope,
799 }))
800 .send()
801 .await
802 .context("contacting anvil")?;
803 check(response).await?;
804 Ok(())
805 }
806
807 async fn delete_user(&self, name: &str) -> Result<()> {
808 let response = self
809 .http
810 .delete(self.user_url(&format!("/{name}")))
811 .basic_auth(&self.username, Some(&self.password))
812 .send()
813 .await
814 .context("contacting anvil")?;
815 check(response).await?;
816 Ok(())
817 }
818
819 async fn unlock_user(
820 &self,
821 values: BTreeMap<String, String>,
822 ttl_secs: i64,
823 ) -> Result<UnlockResponse> {
824 let response = self
825 .http
826 .post(self.user_url("/unlock"))
827 .basic_auth(&self.username, Some(&self.password))
828 .json(&serde_json::json!({ "values": values, "ttl_secs": ttl_secs }))
829 .send()
830 .await
831 .context("contacting anvil")?;
832 check(response).await?.json().await.context("reading reply")
833 }
834
835 async fn lock_user(&self) -> Result<()> {
836 let response = self
837 .http
838 .post(self.user_url("/lock"))
839 .basic_auth(&self.username, Some(&self.password))
840 .send()
841 .await
842 .context("contacting anvil")?;
843 check(response).await?;
844 Ok(())
845 }
846}
847
848async fn check(response: reqwest::Response) -> Result<reqwest::Response> {
849 if response.status().is_success() {
850 return Ok(response);
851 }
852 let status = response.status();
853 let body = response.text().await.unwrap_or_default();
854 bail!("anvil returned {status}: {}", body.trim())
855}
856
857// --- small helpers ---------------------------------------------------------
858
859fn split_repo(repo: &str) -> Result<(&str, &str)> {
860 repo.split_once('/')
861 .filter(|(o, n)| !o.is_empty() && !n.is_empty() && !n.contains('/'))
862 .ok_or_else(|| anyhow!("expected a repository as `owner/name`, got `{repo}`"))
863}
864
865/// Parse `30m` / `8h` / `7d` (bare digits are seconds) into seconds.
866fn parse_ttl(ttl: &str) -> Result<i64> {
867 let (digits, multiplier) = match ttl.chars().last() {
868 Some('s') => (&ttl[..ttl.len() - 1], 1),
869 Some('m') => (&ttl[..ttl.len() - 1], 60),
870 Some('h') => (&ttl[..ttl.len() - 1], 3600),
871 Some('d') => (&ttl[..ttl.len() - 1], 86400),
872 _ => (ttl, 1),
873 };
874 let n: i64 = digits
875 .parse()
876 .with_context(|| format!("bad --ttl `{ttl}` (try 45m, 8h, 7d)"))?;
877 Ok(n * multiplier)
878}
879
880fn fmt_time(unix: i64) -> String {
881 time::OffsetDateTime::from_unix_timestamp(unix)
882 .ok()
883 .and_then(|t| {
884 t.format(&time::format_description::well_known::Rfc3339)
885 .ok()
886 })
887 .unwrap_or_else(|| unix.to_string())
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn parses_ttls() {
896 assert_eq!(parse_ttl("45m").unwrap(), 2700);
897 assert_eq!(parse_ttl("8h").unwrap(), 28800);
898 assert_eq!(parse_ttl("7d").unwrap(), 604800);
899 assert_eq!(parse_ttl("90").unwrap(), 90);
900 assert!(parse_ttl("soon").is_err());
901 }
902
903 #[test]
904 fn splits_repository_references() {
905 assert_eq!(split_repo("collin/anvil").unwrap(), ("collin", "anvil"));
906 assert!(split_repo("anvil").is_err());
907 assert!(split_repo("collin/anvil/extra").is_err());
908 assert!(split_repo("/anvil").is_err());
909 }
910}