diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 321d03b..4ecdb20 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -17,7 +17,6 @@ arboard = "3" chrono = { version = "0.4", default-features = false, features = ["clock"] } dirs = "5" hex = "0.4" -ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -28,6 +27,7 @@ tar = { version = "0.4", default-features = false } clap_complete = "4" image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } rqrr = "0.7" +reqwest = { version = "0.12", features = ["blocking", "json"] } [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-cli/src/gitea.rs b/crates/relicario-cli/src/gitea.rs new file mode 100644 index 0000000..29173b4 --- /dev/null +++ b/crates/relicario-cli/src/gitea.rs @@ -0,0 +1,114 @@ +//! Gitea API client for deploy key management. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +pub struct GiteaClient { + api_url: String, + token: String, + owner: String, + repo: String, +} + +#[derive(Debug, Serialize)] +struct CreateKeyRequest<'a> { + title: &'a str, + key: &'a str, + read_only: bool, +} + +#[derive(Debug, Deserialize)] +pub struct DeployKey { + pub id: u64, + pub title: String, + pub key: String, +} + +impl GiteaClient { + pub fn new(api_url: &str, token: &str, owner: &str, repo: &str) -> Self { + Self { + api_url: api_url.trim_end_matches('/').to_string(), + token: token.to_string(), + owner: owner.to_string(), + repo: repo.to_string(), + } + } + + /// Create a deploy key, returning its ID. + pub fn create_deploy_key(&self, title: &str, public_key: &str) -> Result { + let url = format!( + "{}/repos/{}/{}/keys", + self.api_url, self.owner, self.repo + ); + + let client = reqwest::blocking::Client::new(); + let resp = client + .post(&url) + .header("Authorization", format!("token {}", self.token)) + .header("Content-Type", "application/json") + .json(&CreateKeyRequest { + title, + key: public_key, + read_only: false, + }) + .send() + .context("Gitea API request failed")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + anyhow::bail!("Gitea API error {}: {}", status, body); + } + + let key: DeployKey = resp.json().context("parse deploy key response")?; + Ok(key.id) + } + + /// Delete a deploy key by ID. + pub fn delete_deploy_key(&self, key_id: u64) -> Result<()> { + let url = format!( + "{}/repos/{}/{}/keys/{}", + self.api_url, self.owner, self.repo, key_id + ); + + let client = reqwest::blocking::Client::new(); + let resp = client + .delete(&url) + .header("Authorization", format!("token {}", self.token)) + .send() + .context("Gitea API request failed")?; + + if !resp.status().is_success() && resp.status().as_u16() != 404 { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + anyhow::bail!("Gitea API error {}: {}", status, body); + } + + Ok(()) + } + + /// List all deploy keys. + pub fn list_deploy_keys(&self) -> Result> { + let url = format!( + "{}/repos/{}/{}/keys", + self.api_url, self.owner, self.repo + ); + + let client = reqwest::blocking::Client::new(); + let resp = client + .get(&url) + .header("Authorization", format!("token {}", self.token)) + .send() + .context("Gitea API request failed")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + anyhow::bail!("Gitea API error {}: {}", status, body); + } + + let keys: Vec = resp.json().context("parse deploy keys response")?; + Ok(keys) + } +} diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 7d95eec..f884db7 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -4,6 +4,7 @@ mod helpers; mod session; +mod gitea; use std::path::PathBuf; diff --git a/crates/relicario-cli/tests/basic_flows.rs b/crates/relicario-cli/tests/basic_flows.rs index dc493ec..8f85095 100644 --- a/crates/relicario-cli/tests/basic_flows.rs +++ b/crates/relicario-cli/tests/basic_flows.rs @@ -8,7 +8,8 @@ fn init_creates_expected_layout() { let v = TestVault::init(); assert!(v.path().join(".relicario/salt").exists()); assert!(v.path().join(".relicario/params.json").exists()); - assert!(v.path().join(".relicario/devices.json").exists()); + // devices.json removed — device key system was security theater + assert!(!v.path().join(".relicario/devices.json").exists()); assert!(v.path().join("manifest.enc").exists()); assert!(v.path().join("settings.enc").exists()); assert!(v.path().join("reference.jpg").exists()); diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs index b79c7b1..a673d03 100644 --- a/crates/relicario-cli/tests/settings.rs +++ b/crates/relicario-cli/tests/settings.rs @@ -66,7 +66,7 @@ fn generate_uses_vault_default_length() { } #[test] -fn status_reports_item_attachment_and_device_counts() { +fn status_reports_item_and_attachment_counts() { let v = TestVault::init(); v.run(&["add", "login", "--title", "active", "--username", "u", "--password", "p"]); @@ -99,8 +99,7 @@ fn status_reports_item_attachment_and_device_counts() { assert!(lower.contains("attachment"), "missing attachment section: {stdout}"); assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}"); - // 0 devices in default test vault (init does not register one). - assert!(lower.contains("device"), "missing devices section: {stdout}"); + // device count line removed — device key system was security theater (audit B1). // Last-commit line. assert!(