mod render;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use loupe_proto::{
FindingDetail, JobInfo, ListFindingsResponse, ListReposResponse, RegisterRepoRequest,
RegisterRepoResponse, RegisterWorkerRequest, RegisterWorkerResponse, ReportingSetup,
RetryVerifyRequest, RetryVerifyResponse, RotateRepoPatRequest, ScanRequest, ScanResponse,
SetRepoGithubReportingRequest, UpdateRepoRequest, PROTOCOL_VERSION,
};
#[derive(Debug, Parser)]
#[command(version, about = "loupe operator CLI")]
struct Cli {
#[command(flatten)]
conn: ConnArgs,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Debug, Args)]
struct ConnArgs {
#[arg(long, env = "LOUPE_SERVER_URL")]
server_url: Option<reqwest::Url>,
#[arg(long, env = "LOUPE_CA_CERT")]
ca_cert: Option<PathBuf>,
#[arg(long, env = "LOUPE_CA_CERT_PEM", hide_env_values = true)]
ca_cert_pem: Option<String>,
#[arg(long, env = "LOUPE_CA_CERT_PEM_B64", hide_env_values = true)]
ca_cert_pem_b64: Option<String>,
#[arg(long, env = "LOUPE_ADMIN_CERT")]
admin_cert: Option<PathBuf>,
#[arg(long, env = "LOUPE_ADMIN_CERT_PEM", hide_env_values = true)]
admin_cert_pem: Option<String>,
#[arg(long, env = "LOUPE_ADMIN_CERT_PEM_B64", hide_env_values = true)]
admin_cert_pem_b64: Option<String>,
#[arg(long, env = "LOUPE_ADMIN_KEY")]
admin_key: Option<PathBuf>,
#[arg(long, env = "LOUPE_ADMIN_KEY_PEM", hide_env_values = true)]
admin_key_pem: Option<String>,
#[arg(long, env = "LOUPE_ADMIN_KEY_PEM_B64", hide_env_values = true)]
admin_key_pem_b64: Option<String>,
}
#[derive(Debug, Subcommand)]
enum Cmd {
#[command(subcommand)]
Repo(RepoCmd),
#[command(subcommand)]
Worker(WorkerCmd),
#[command(subcommand)]
Job(JobCmd),
#[command(subcommand)]
Finding(FindingCmd),
#[command(subcommand)]
Cert(CertCmd),
}
#[derive(Debug, Subcommand)]
enum RepoCmd {
Add(RepoAddArgs),
List(ListArgs),
Rm { id: i64 },
Update(RepoUpdateArgs),
RotatePat(RepoRotatePatArgs),
SetGithubReporting(RepoSetGithubReportingArgs),
Scan {
id: i64,
#[arg(long, default_value_t = false)]
incremental: bool,
},
}
#[derive(Debug, Args)]
struct RepoRotatePatArgs {
id: i64,
#[arg(long, env = "LOUPE_TRACKER_PAT", hide_env_values = true)]
pat: String,
}
#[derive(Debug, Args)]
struct RepoSetGithubReportingArgs {
id: i64,
#[arg(long)]
target_owner: String,
#[arg(long)]
target_repo: String,
#[arg(long, env = "LOUPE_TRACKER_PAT", hide_env_values = true)]
pat: String,
}
#[derive(Debug, Args)]
struct RepoUpdateArgs {
id: i64,
#[arg(long, conflicts_with = "enable")]
disable: bool,
#[arg(long, conflicts_with = "disable")]
enable: bool,
#[arg(long)]
interval: Option<u64>,
#[arg(long, conflicts_with = "no_verification")]
verification_enabled: bool,
#[arg(long, conflicts_with = "verification_enabled")]
no_verification: bool,
#[arg(long, conflicts_with_all = ["no_require_approval", "inherit_approval"])]
require_approval: bool,
#[arg(long, conflicts_with_all = ["require_approval", "inherit_approval"])]
no_require_approval: bool,
#[arg(long, conflicts_with_all = ["require_approval", "no_require_approval"])]
inherit_approval: bool,
}
#[derive(Debug, Args)]
struct RepoAddArgs {
#[arg(long)]
clone_url: String,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
scan_interval_seconds: Option<u64>,
#[arg(long, required_unless_present = "no_reporting")]
target_owner: Option<String>,
#[arg(long, required_unless_present = "no_reporting")]
target_repo: Option<String>,
#[arg(
long,
env = "LOUPE_TRACKER_PAT",
hide_env_values = true,
required_unless_present = "no_reporting"
)]
pat: Option<String>,
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["target_owner", "target_repo", "pat"],
)]
no_reporting: bool,
#[arg(long, conflicts_with = "no_verification")]
verification_enabled: bool,
#[arg(long, conflicts_with = "verification_enabled")]
no_verification: bool,
#[arg(long, conflicts_with = "no_require_approval")]
require_approval: bool,
#[arg(long, conflicts_with = "require_approval")]
no_require_approval: bool,
}
#[derive(Debug, Subcommand)]
enum WorkerCmd {
Register(WorkerRegisterArgs),
Rm { id: i64 },
}
#[derive(Debug, Args)]
struct WorkerRegisterArgs {
#[arg(long)]
name: String,
#[arg(long, conflicts_with = "emit_env")]
out: Option<PathBuf>,
#[arg(long, default_value_t = false)]
emit_env: bool,
}
#[derive(Debug, Subcommand)]
enum JobCmd {
List(ListArgs),
Get {
id: i64,
},
Retry {
id: i64,
},
Cancel {
id: i64,
},
}
#[derive(Debug, Subcommand)]
enum FindingCmd {
List(FindingListArgs),
Search {
repo_id: i64,
query: String,
#[arg(long, default_value_t = 20)]
limit: i64,
},
Show {
id: i64,
#[arg(long, default_value_t = false)]
json: bool,
},
Approve { id: i64 },
RetryReport { id: i64 },
RetryVerify(RetryVerifyArgs),
Reject { id: i64 },
}
#[derive(Debug, Args)]
struct ListArgs {
#[arg(short = 'n', long = "limit", value_parser = parse_positive_i64)]
limit: Option<i64>,
}
#[derive(Debug, Args)]
struct FindingListArgs {
repo_id: i64,
#[arg(short = 'n', long = "limit", value_parser = parse_positive_i64)]
limit: Option<i64>,
}
fn parse_positive_i64(raw: &str) -> Result<i64, String> {
let value = raw.parse::<i64>().map_err(|_| "limit must be an integer".to_owned())?;
if value <= 0 {
return Err("limit must be positive".to_owned());
}
Ok(value)
}
#[derive(Debug, Args)]
struct RetryVerifyArgs {
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(long, default_value_t = false)]
include_inconclusive: bool,
#[arg(long)]
repo_id: Option<i64>,
#[arg(long)]
limit: Option<i64>,
}
#[derive(Debug, Subcommand)]
enum CertCmd {
MintServer(CertMintServerArgs),
}
#[derive(Debug, Args)]
struct CertMintServerArgs {
#[arg(long = "hostname", required = true)]
hostnames: Vec<String>,
#[arg(long, default_value = "loupe-server")]
common_name: String,
#[arg(long, default_value_t = false)]
emit_env: bool,
#[arg(long, env = "LOUPE_CA_CERT")]
ca_cert: Option<PathBuf>,
#[arg(long, env = "LOUPE_CA_CERT_PEM", hide_env_values = true)]
ca_cert_pem: Option<String>,
#[arg(long, env = "LOUPE_CA_CERT_PEM_B64", hide_env_values = true)]
ca_cert_pem_b64: Option<String>,
#[arg(long, env = "LOUPE_CA_KEY")]
ca_key: Option<PathBuf>,
#[arg(long, env = "LOUPE_CA_KEY_PEM", hide_env_values = true)]
ca_key_pem: Option<String>,
#[arg(long, env = "LOUPE_CA_KEY_PEM_B64", hide_env_values = true)]
ca_key_pem_b64: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let Cli { conn, cmd } = Cli::parse();
match cmd {
Cmd::Repo(c) => match c {
RepoCmd::Add(a) => {
let (client, base) = client_and_url(&conn)?;
repo_add(&client, base, a).await
},
RepoCmd::List(args) => {
let (client, base) = client_and_url(&conn)?;
repo_list(&client, base, args.limit).await
},
RepoCmd::Rm { id } => {
let (client, base) = client_and_url(&conn)?;
repo_rm(&client, base, id).await
},
RepoCmd::Update(a) => {
let (client, base) = client_and_url(&conn)?;
repo_update(&client, base, a).await
},
RepoCmd::RotatePat(a) => {
let (client, base) = client_and_url(&conn)?;
repo_rotate_pat(&client, base, a).await
},
RepoCmd::SetGithubReporting(a) => {
let (client, base) = client_and_url(&conn)?;
repo_set_github_reporting(&client, base, a).await
},
RepoCmd::Scan { id, incremental } => {
let (client, base) = client_and_url(&conn)?;
repo_scan(&client, base, id, incremental).await
},
},
Cmd::Worker(c) => match c {
WorkerCmd::Register(a) => {
let (client, base) = client_and_url(&conn)?;
worker_register(&client, base, a).await
},
WorkerCmd::Rm { id } => {
let (client, base) = client_and_url(&conn)?;
worker_rm(&client, base, id).await
},
},
Cmd::Job(c) => match c {
JobCmd::List(args) => {
let (client, base) = client_and_url(&conn)?;
job_list(&client, base, args.limit).await
},
JobCmd::Get { id } => {
let (client, base) = client_and_url(&conn)?;
job_get(&client, base, id).await
},
JobCmd::Retry { id } => {
let (client, base) = client_and_url(&conn)?;
job_retry(&client, base, id).await
},
JobCmd::Cancel { id } => {
let (client, base) = client_and_url(&conn)?;
job_cancel(&client, base, id).await
},
},
Cmd::Finding(c) => match c {
FindingCmd::List(args) => {
let (client, base) = client_and_url(&conn)?;
finding_list(&client, base, args.repo_id, args.limit).await
},
FindingCmd::Search { repo_id, query, limit } => {
let (client, base) = client_and_url(&conn)?;
finding_search(&client, base, repo_id, &query, limit).await
},
FindingCmd::Show { id, json } => {
let (client, base) = client_and_url(&conn)?;
finding_show(&client, base, id, json).await
},
FindingCmd::Approve { id } => {
let (client, base) = client_and_url(&conn)?;
finding_approve(&client, base, id).await
},
FindingCmd::RetryReport { id } => {
let (client, base) = client_and_url(&conn)?;
finding_retry_report(&client, base, id).await
},
FindingCmd::RetryVerify(a) => {
let (client, base) = client_and_url(&conn)?;
finding_retry_verify(&client, base, a).await
},
FindingCmd::Reject { id } => {
let (client, base) = client_and_url(&conn)?;
finding_reject(&client, base, id).await
},
},
Cmd::Cert(c) => match c {
CertCmd::MintServer(a) => cert_mint_server(a),
},
}
}
fn client_and_url(c: &ConnArgs) -> Result<(reqwest::Client, &reqwest::Url)> {
let base = server_url(c)?;
let client = build_client(c)?;
Ok((client, base))
}
fn server_url(c: &ConnArgs) -> Result<&reqwest::Url> {
c.server_url.as_ref().context("server URL missing — set LOUPE_SERVER_URL or pass --server-url")
}
fn build_client(c: &ConnArgs) -> Result<reqwest::Client> {
let ca = pem_from_env_or_file(
"CA cert",
&c.ca_cert_pem,
&c.ca_cert_pem_b64,
c.ca_cert.as_ref(),
"CA cert missing — set LOUPE_CA_CERT_PEM, LOUPE_CA_CERT_PEM_B64, or LOUPE_CA_CERT",
)?;
let cert = pem_from_env_or_file(
"admin cert",
&c.admin_cert_pem,
&c.admin_cert_pem_b64,
c.admin_cert.as_ref(),
"admin cert missing — set LOUPE_ADMIN_CERT_PEM, LOUPE_ADMIN_CERT_PEM_B64, or LOUPE_ADMIN_CERT",
)?;
let key = pem_from_env_or_file(
"admin key",
&c.admin_key_pem,
&c.admin_key_pem_b64,
c.admin_key.as_ref(),
"admin key missing — set LOUPE_ADMIN_KEY_PEM, LOUPE_ADMIN_KEY_PEM_B64, or LOUPE_ADMIN_KEY",
)?;
let mut combined = String::with_capacity(cert.len() + key.len() + 1);
combined.push_str(&cert);
if !cert.ends_with('\n') {
combined.push('\n');
}
combined.push_str(&key);
let identity =
reqwest::Identity::from_pem(combined.as_bytes()).context("parsing admin identity")?;
let root = reqwest::Certificate::from_pem(ca.as_bytes()).context("parsing CA cert")?;
reqwest::Client::builder()
.add_root_certificate(root)
.identity(identity)
.use_rustls_tls()
.build()
.context("building reqwest client")
}
fn pem_from_env_or_file(
label: &str, pem: &Option<String>, pem_b64: &Option<String>, path: Option<&PathBuf>,
missing: &'static str,
) -> Result<String> {
if let Some(pem) = pem.as_deref().filter(|s| !s.is_empty()) {
return Ok(pem.to_owned());
}
if let Some(pem_b64) = pem_b64.as_deref().filter(|s| !s.is_empty()) {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(pem_b64.trim())
.with_context(|| format!("decoding base64 {label} PEM"))?;
return String::from_utf8(bytes).with_context(|| format!("{label} PEM is not valid UTF-8"));
}
let path = path.context(missing)?;
std::fs::read_to_string(path).with_context(|| format!("reading {label} at {}", path.display()))
}
fn url(base: &reqwest::Url, path: &str) -> reqwest::Url {
base.join(path).expect("path is always valid")
}
async fn repo_add(client: &reqwest::Client, base: &reqwest::Url, a: RepoAddArgs) -> Result<()> {
let require_approval = match (a.require_approval, a.no_require_approval) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
};
let verification_enabled = match (a.verification_enabled, a.no_verification) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
};
let reporting = if a.no_reporting {
ReportingSetup::Manual
} else {
ReportingSetup::GithubIssue {
target_owner: a.target_owner.expect("clap enforces target_owner"),
target_repo: a.target_repo.expect("clap enforces target_repo"),
github_pat: a.pat.expect("clap enforces pat"),
}
};
let req = RegisterRepoRequest {
protocol_version: PROTOCOL_VERSION,
clone_url: a.clone_url,
branch: a.branch,
scan_interval_seconds: a.scan_interval_seconds,
reporting,
scanner_config: serde_json::Value::Null,
verification_enabled,
require_approval,
};
let resp = client.post(url(base, "/v1/repos")).json(&req).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("register repo: {} — {}", status, resp.text().await.unwrap_or_default());
}
let body: RegisterRepoResponse = resp.json().await?;
println!("repo_id={}", body.repo_id);
Ok(())
}
async fn repo_list(
client: &reqwest::Client, base: &reqwest::Url, limit: Option<i64>,
) -> Result<()> {
let req = client.get(url(base, "/v1/repos"));
let req = if let Some(limit) = limit { req.query(&[("limit", limit)]) } else { req };
let resp = req.send().await?;
let body: ListReposResponse = resp.error_for_status()?.json().await?;
for r in body.repos {
let approval = r.require_approval.map_or("inherit".to_owned(), |v| v.to_string());
let disabled = r.disabled_at.map_or("active".to_owned(), |ts| format!("disabled@{ts}"));
println!(
"{:>4}\t{}\t{}/{}\tinterval={:?}\tverify={}\tapproval={}\t{}\tlast_sha={:?}",
r.id,
r.host,
r.owner,
r.repo,
r.scan_interval_seconds,
r.verification_enabled,
approval,
disabled,
r.last_scanned_sha,
);
}
Ok(())
}
async fn repo_rm(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.delete(url(base, &format!("/v1/repos/{id}"))).send().await?;
resp.error_for_status()?;
Ok(())
}
async fn repo_update(
client: &reqwest::Client, base: &reqwest::Url, a: RepoUpdateArgs,
) -> Result<()> {
let disabled = match (a.disable, a.enable) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
};
let verification_enabled = match (a.verification_enabled, a.no_verification) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
};
let require_approval = match (a.require_approval, a.no_require_approval) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
};
let req = UpdateRepoRequest {
protocol_version: PROTOCOL_VERSION,
disabled,
scan_interval_seconds: a.interval,
verification_enabled,
require_approval,
inherit_require_approval: a.inherit_approval,
};
let resp = client.patch(url(base, &format!("/v1/repos/{}", a.id))).json(&req).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("update repo: {} — {}", status, resp.text().await.unwrap_or_default());
}
Ok(())
}
async fn repo_rotate_pat(
client: &reqwest::Client, base: &reqwest::Url, a: RepoRotatePatArgs,
) -> Result<()> {
let req = RotateRepoPatRequest { protocol_version: PROTOCOL_VERSION, github_pat: a.pat };
let resp = client
.post(url(base, &format!("/v1/repos/{}/reporting/github-pat", a.id)))
.json(&req)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("rotate repo PAT: {} — {}", status, resp.text().await.unwrap_or_default());
}
Ok(())
}
async fn repo_set_github_reporting(
client: &reqwest::Client, base: &reqwest::Url, a: RepoSetGithubReportingArgs,
) -> Result<()> {
let req = SetRepoGithubReportingRequest {
protocol_version: PROTOCOL_VERSION,
target_owner: a.target_owner,
target_repo: a.target_repo,
github_pat: a.pat,
};
let resp = client
.put(url(base, &format!("/v1/repos/{}/reporting/github", a.id)))
.json(&req)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!(
"set GitHub reporting: {} — {}",
status,
resp.text().await.unwrap_or_default()
);
}
Ok(())
}
async fn repo_scan(
client: &reqwest::Client, base: &reqwest::Url, id: i64, incremental: bool,
) -> Result<()> {
let resp = client
.post(url(base, &format!("/v1/repos/{id}/scan")))
.json(&ScanRequest { protocol_version: PROTOCOL_VERSION, incremental })
.send()
.await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("scan: {} — {}", status, resp.text().await.unwrap_or_default());
}
let body: ScanResponse = resp.json().await?;
println!("job_id={}", body.job_id);
Ok(())
}
async fn worker_register(
client: &reqwest::Client, base: &reqwest::Url, a: WorkerRegisterArgs,
) -> Result<()> {
let resp = client
.post(url(base, "/v1/workers"))
.json(&RegisterWorkerRequest { protocol_version: PROTOCOL_VERSION, name: a.name })
.send()
.await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("register worker: {} — {}", status, resp.text().await.unwrap_or_default());
}
let bundle: RegisterWorkerResponse = resp.json().await?;
if a.emit_env {
for (name, value) in worker_env_assignments(base, &bundle) {
println!("{name}={value}");
}
return Ok(());
}
let serialised = serde_json::to_string_pretty(&bundle)?;
if let Some(path) = a.out {
write_secret_file(&path, serialised.as_bytes())?;
println!("worker_id={} bundle written to {}", bundle.worker_id, path.display());
} else {
println!("{serialised}");
}
Ok(())
}
fn write_secret_file(path: &Path, contents: &[u8]) -> Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file =
options.open(path).with_context(|| format!("creating secret file {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("setting permissions on {}", path.display()))?;
}
file.write_all(contents).with_context(|| format!("writing secret file {}", path.display()))?;
Ok(())
}
fn worker_env_assignments(
base: &reqwest::Url, bundle: &RegisterWorkerResponse,
) -> Vec<(&'static str, String)> {
vec![
("LOUPE_SERVER_URL", base.as_str().to_owned()),
("LOUPE_WORKER_CA_CERT_PEM_B64", b64(&bundle.ca_cert_pem)),
("LOUPE_WORKER_CERT_PEM_B64", b64(&bundle.client_cert_pem)),
("LOUPE_WORKER_KEY_PEM_B64", b64(&bundle.client_key_pem)),
]
}
fn b64(value: &str) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(value.as_bytes())
}
fn cert_mint_server(a: CertMintServerArgs) -> Result<()> {
let bundle = mint_server_cert(&a)?;
if a.emit_env {
for (name, value) in server_cert_env_assignments(&bundle) {
println!("{name}={value}");
}
return Ok(());
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"server_cert_pem": bundle.cert_pem,
"server_key_pem": bundle.key_pem,
}))?
);
Ok(())
}
fn mint_server_cert(a: &CertMintServerArgs) -> Result<loupe_tls::CertBundle> {
let ca_cert = pem_from_env_or_file(
"CA cert",
&a.ca_cert_pem,
&a.ca_cert_pem_b64,
a.ca_cert.as_ref(),
"CA cert missing — set LOUPE_CA_CERT_PEM, LOUPE_CA_CERT_PEM_B64, or LOUPE_CA_CERT",
)?;
let ca_key = pem_from_env_or_file(
"CA key",
&a.ca_key_pem,
&a.ca_key_pem_b64,
a.ca_key.as_ref(),
"CA key missing — set LOUPE_CA_KEY_PEM, LOUPE_CA_KEY_PEM_B64, or LOUPE_CA_KEY",
)?;
let ca = loupe_tls::Ca::from_pem(&ca_cert, &ca_key)?;
ca.mint_server(&a.common_name, &a.hostnames)
}
fn server_cert_env_assignments(bundle: &loupe_tls::CertBundle) -> Vec<(&'static str, String)> {
vec![
("LOUPE_SERVER_CERT_PEM_B64", b64(&bundle.cert_pem)),
("LOUPE_SERVER_KEY_PEM_B64", b64(&bundle.key_pem)),
]
}
async fn worker_rm(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.delete(url(base, &format!("/v1/workers/{id}"))).send().await?;
resp.error_for_status()?;
Ok(())
}
async fn job_list(client: &reqwest::Client, base: &reqwest::Url, limit: Option<i64>) -> Result<()> {
let req = client.get(url(base, "/v1/jobs"));
let req = if let Some(limit) = limit { req.query(&[("limit", limit)]) } else { req };
let resp = req.send().await?;
let jobs: Vec<JobInfo> = resp.error_for_status()?.json().await?;
for j in jobs.into_iter().rev() {
println!(
"{:>4}\trepo={}\tkind={:?}\tstate={:?}\tattempts={}\thead={:?}",
j.job_id, j.repo_id, j.kind, j.state, j.attempts, j.head_sha,
);
}
Ok(())
}
async fn job_get(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.get(url(base, &format!("/v1/jobs/{id}"))).send().await?;
let job: JobInfo = resp.error_for_status()?.json().await?;
println!("{}", serde_json::to_string_pretty(&job)?);
Ok(())
}
async fn job_retry(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.post(url(base, &format!("/v1/jobs/{id}/retry"))).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("retry job: {} — {}", status, resp.text().await.unwrap_or_default());
}
let job: JobInfo = resp.json().await?;
println!("job_id={} state={:?} attempts={}", job.job_id, job.state, job.attempts);
Ok(())
}
async fn job_cancel(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.post(url(base, &format!("/v1/jobs/{id}/cancel"))).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("cancel job: {} — {}", status, resp.text().await.unwrap_or_default());
}
let job: JobInfo = resp.json().await?;
println!("job_id={} state={:?} attempts={}", job.job_id, job.state, job.attempts);
Ok(())
}
async fn finding_list(
client: &reqwest::Client, base: &reqwest::Url, repo_id: i64, limit: Option<i64>,
) -> Result<()> {
let req = client.get(url(base, &format!("/v1/repos/{repo_id}/findings")));
let req = if let Some(limit) = limit { req.query(&[("limit", limit)]) } else { req };
let resp = req.send().await?;
let body: ListFindingsResponse = resp.error_for_status()?.json().await?;
for f in body.findings.into_iter().rev() {
let loc = match (f.file_path.as_deref(), f.line_start) {
(Some(p), Some(l)) => format!("{p}:{l}"),
(Some(p), None) => p.to_string(),
_ => "-".into(),
};
println!(
"{:>5}\tjob={}\t{:?}\t{}\tstate={}\tverify={}\t{}\t{}",
f.id,
f.job_id,
f.severity,
f.scanner_id,
f.state,
f.verification_required,
loc,
f.title,
);
}
Ok(())
}
async fn finding_search(
client: &reqwest::Client, base: &reqwest::Url, repo_id: i64, query: &str, limit: i64,
) -> Result<()> {
let url = url(base, &format!("/v1/repos/{repo_id}/findings/search"));
let resp = client.get(url).query(&[("q", query), ("limit", &limit.to_string())]).send().await?;
let body: ListFindingsResponse = resp.error_for_status()?.json().await?;
if body.findings.is_empty() {
println!("(no matches)");
return Ok(());
}
for f in body.findings {
let loc = match (f.file_path.as_deref(), f.line_start) {
(Some(p), Some(l)) => format!("{p}:{l}"),
(Some(p), None) => p.to_string(),
_ => "-".into(),
};
println!(
"{:>5}\t{:?}\t{}\tstate={}\t{}\t{}",
f.id, f.severity, f.scanner_id, f.state, loc, f.title,
);
}
Ok(())
}
async fn finding_show(
client: &reqwest::Client, base: &reqwest::Url, id: i64, as_json: bool,
) -> Result<()> {
let resp = client.get(url(base, &format!("/v1/findings/{id}"))).send().await?;
let detail: FindingDetail = resp.error_for_status()?.json().await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&detail)?);
} else {
print!("{}", render::finding(&detail, render::Style::detect()));
}
Ok(())
}
async fn finding_approve(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.post(url(base, &format!("/v1/findings/{id}/approve"))).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("approve finding: {} — {}", status, resp.text().await.unwrap_or_default());
}
Ok(())
}
async fn finding_retry_report(
client: &reqwest::Client, base: &reqwest::Url, id: i64,
) -> Result<()> {
let resp = client.post(url(base, &format!("/v1/findings/{id}/retry-report"))).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("retry report: {} — {}", status, resp.text().await.unwrap_or_default());
}
Ok(())
}
async fn finding_retry_verify(
client: &reqwest::Client, base: &reqwest::Url, args: RetryVerifyArgs,
) -> Result<()> {
let req = RetryVerifyRequest {
protocol_version: PROTOCOL_VERSION,
dry_run: args.dry_run,
include_inconclusive: args.include_inconclusive,
repo_id: args.repo_id,
limit: args.limit,
};
let resp = client.post(url(base, "/v1/findings/retry-verify")).json(&req).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("retry verify: {} — {}", status, resp.text().await.unwrap_or_default());
}
let body: RetryVerifyResponse = resp.json().await?;
let label = if body.dry_run { "would_revive" } else { "revived" };
println!(
"dry_run={} matched={} {}={} requeued_jobs={} created_jobs={} left_queued_or_leased={}",
body.dry_run,
body.matched,
label,
body.revived,
body.requeued_jobs,
body.created_jobs,
body.left_queued_or_leased,
);
Ok(())
}
async fn finding_reject(client: &reqwest::Client, base: &reqwest::Url, id: i64) -> Result<()> {
let resp = client.post(url(base, &format!("/v1/findings/{id}/reject"))).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("reject finding: {} — {}", status, resp.text().await.unwrap_or_default());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pem_b64_env_wins_over_file_path() {
let file = std::env::temp_dir().join("loupectl-test-path-that-should-not-be-read.pem");
let pem = "-----BEGIN CERTIFICATE-----\nfrom-env\n-----END CERTIFICATE-----\n";
let pem_b64 = b64(pem);
let got =
pem_from_env_or_file("CA cert", &None, &Some(pem_b64), Some(&file), "missing").unwrap();
assert_eq!(got, pem);
}
#[test]
fn worker_register_out_conflicts_with_emit_env() {
let err = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"worker",
"register",
"--name",
"worker-1",
"--out",
"worker.json",
"--emit-env",
])
.expect_err("clap should reject --out with --emit-env");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[cfg(unix)]
fn unique_temp_dir(prefix: &str) -> PathBuf {
let suffix =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn worker_bundle_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_temp_dir("loupectl-worker-bundle-perms");
let path = dir.join("worker.json");
write_secret_file(&path, b"{\"client_key_pem\":\"PRIVATE KEY\"}").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(
mode, 0o600,
"worker bundle persisted by --out must be owner-only, got mode {mode:o}",
);
}
#[test]
fn secret_file_refuses_to_overwrite_existing_path() {
let suffix =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir()
.join(format!("loupectl-worker-bundle-overwrite-{}-{suffix}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("worker.json");
std::fs::write(&path, b"existing").unwrap();
let err = write_secret_file(&path, b"replacement").expect_err("existing path is refused");
let contents = std::fs::read(&path).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(err.to_string().contains("creating secret file"), "unexpected error: {err}");
assert_eq!(contents, b"existing");
}
#[test]
fn repo_rotate_pat_parses_explicit_pat() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"repo",
"rotate-pat",
"7",
"--pat",
"ghp_replacement",
])
.unwrap();
let Cmd::Repo(RepoCmd::RotatePat(args)) = cli.cmd else {
panic!("expected repo rotate-pat command");
};
assert_eq!(args.id, 7);
assert_eq!(args.pat, "ghp_replacement");
}
#[test]
fn repo_add_inherits_verification_default_unless_pinned() {
let base = [
"loupectl",
"--server-url",
"https://loupe.example:8443",
"repo",
"add",
"--clone-url",
"https://github.com/acme/widget.git",
"--no-reporting",
];
let cli = Cli::try_parse_from(base).unwrap();
let Cmd::Repo(RepoCmd::Add(args)) = cli.cmd else {
panic!("expected repo add command");
};
assert!(!args.verification_enabled);
assert!(!args.no_verification);
let mut with_verify = base.to_vec();
with_verify.push("--verification-enabled");
let cli = Cli::try_parse_from(with_verify).unwrap();
let Cmd::Repo(RepoCmd::Add(args)) = cli.cmd else {
panic!("expected repo add command");
};
assert!(args.verification_enabled);
assert!(!args.no_verification);
let mut without_verify = base.to_vec();
without_verify.push("--no-verification");
let cli = Cli::try_parse_from(without_verify).unwrap();
let Cmd::Repo(RepoCmd::Add(args)) = cli.cmd else {
panic!("expected repo add command");
};
assert!(!args.verification_enabled);
assert!(args.no_verification);
}
#[test]
fn repo_set_github_reporting_parses_explicit_pat() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"repo",
"set-github-reporting",
"7",
"--target-owner",
"acme",
"--target-repo",
"tracker",
"--pat",
"ghp_replacement",
])
.unwrap();
let Cmd::Repo(RepoCmd::SetGithubReporting(args)) = cli.cmd else {
panic!("expected repo set-github-reporting command");
};
assert_eq!(args.id, 7);
assert_eq!(args.target_owner, "acme");
assert_eq!(args.target_repo, "tracker");
assert_eq!(args.pat, "ghp_replacement");
}
#[test]
fn finding_retry_report_parses() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"finding",
"retry-report",
"11",
])
.unwrap();
let Cmd::Finding(FindingCmd::RetryReport { id }) = cli.cmd else {
panic!("expected finding retry-report command");
};
assert_eq!(id, 11);
}
#[test]
fn list_limits_parse() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"repo",
"list",
"-n",
"25",
])
.unwrap();
let Cmd::Repo(RepoCmd::List(args)) = cli.cmd else {
panic!("expected repo list command");
};
assert_eq!(args.limit, Some(25));
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"job",
"list",
"-n",
"25",
])
.unwrap();
let Cmd::Job(JobCmd::List(args)) = cli.cmd else {
panic!("expected job list command");
};
assert_eq!(args.limit, Some(25));
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"finding",
"list",
"7",
"-n",
"250",
])
.unwrap();
let Cmd::Finding(FindingCmd::List(args)) = cli.cmd else {
panic!("expected finding list command");
};
assert_eq!(args.repo_id, 7);
assert_eq!(args.limit, Some(250));
}
#[test]
fn list_limits_must_be_positive() {
let err = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"finding",
"list",
"7",
"-n",
"0",
])
.expect_err("zero limit must be rejected");
assert!(err.to_string().contains("limit must be positive"));
}
#[test]
fn finding_retry_verify_parses() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"finding",
"retry-verify",
"--dry-run",
"--include-inconclusive",
"--repo-id",
"7",
"--limit",
"50",
])
.unwrap();
let Cmd::Finding(FindingCmd::RetryVerify(args)) = cli.cmd else {
panic!("expected finding retry-verify command");
};
assert!(args.dry_run);
assert!(args.include_inconclusive);
assert_eq!(args.repo_id, Some(7));
assert_eq!(args.limit, Some(50));
}
#[test]
fn job_retry_parses() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"job",
"retry",
"33",
])
.unwrap();
let Cmd::Job(JobCmd::Retry { id }) = cli.cmd else {
panic!("expected job retry command");
};
assert_eq!(id, 33);
}
#[test]
fn job_cancel_parses() {
let cli = Cli::try_parse_from([
"loupectl",
"--server-url",
"https://loupe.example:8443",
"job",
"cancel",
"34",
])
.unwrap();
let Cmd::Job(JobCmd::Cancel { id }) = cli.cmd else {
panic!("expected job cancel command");
};
assert_eq!(id, 34);
}
#[test]
fn cert_mint_server_parses_without_server_url() {
let cli = Cli::try_parse_from([
"loupectl",
"cert",
"mint-server",
"--hostname",
"loupe.example.com",
"--ca-cert-pem",
"ca-cert",
"--ca-key-pem",
"ca-key",
])
.unwrap();
let Cmd::Cert(CertCmd::MintServer(args)) = cli.cmd else {
panic!("expected cert mint-server command");
};
assert_eq!(args.hostnames, vec!["loupe.example.com"]);
assert!(cli.conn.server_url.is_none());
}
#[test]
fn cert_mint_server_uses_existing_ca() {
let ca = loupe_tls::Ca::new("loupe-test-ca").unwrap();
let args = CertMintServerArgs {
hostnames: vec!["loupe.example.com".into(), "loupe.internal".into()],
common_name: "loupe-server".into(),
emit_env: true,
ca_cert: None,
ca_cert_pem: Some(ca.cert_pem().to_owned()),
ca_cert_pem_b64: None,
ca_key: None,
ca_key_pem: Some(ca.key_pem().to_owned()),
ca_key_pem_b64: None,
};
let bundle = mint_server_cert(&args).unwrap();
assert!(bundle.cert_pem.contains("BEGIN CERTIFICATE"));
assert!(bundle.key_pem.contains("PRIVATE KEY"));
}
#[test]
fn server_cert_env_assignments_are_single_line_and_decode_to_bundle() {
let bundle = loupe_tls::CertBundle {
cert_pem: "server\ncert\n".into(),
key_pem: "server\nkey\n".into(),
};
let assignments = server_cert_env_assignments(&bundle);
let names: Vec<_> = assignments.iter().map(|(name, _)| *name).collect();
assert_eq!(names, vec!["LOUPE_SERVER_CERT_PEM_B64", "LOUPE_SERVER_KEY_PEM_B64"]);
for (name, value) in &assignments {
assert!(!value.is_empty(), "{name} value must be present");
assert!(!value.contains('\n'), "{name} value must fit dotenv/env-file syntax");
}
use base64::Engine as _;
let decoded_cert =
base64::engine::general_purpose::STANDARD.decode(&assignments[0].1).unwrap();
assert_eq!(String::from_utf8(decoded_cert).unwrap(), bundle.cert_pem);
let decoded_key =
base64::engine::general_purpose::STANDARD.decode(&assignments[1].1).unwrap();
assert_eq!(String::from_utf8(decoded_key).unwrap(), bundle.key_pem);
}
#[test]
fn worker_env_assignments_are_single_line_and_decode_to_bundle() {
let base = reqwest::Url::parse("https://loupe.example:8443/").unwrap();
let bundle = RegisterWorkerResponse {
protocol_version: PROTOCOL_VERSION,
worker_id: 42,
ca_cert_pem: "ca\ncert\n".into(),
client_cert_pem: "worker\ncert\n".into(),
client_key_pem: "worker\nkey\n".into(),
};
let assignments = worker_env_assignments(&base, &bundle);
let names: Vec<_> = assignments.iter().map(|(name, _)| *name).collect();
assert_eq!(
names,
vec![
"LOUPE_SERVER_URL",
"LOUPE_WORKER_CA_CERT_PEM_B64",
"LOUPE_WORKER_CERT_PEM_B64",
"LOUPE_WORKER_KEY_PEM_B64",
]
);
for (name, value) in &assignments {
assert!(!value.is_empty(), "{name} value must be present");
assert!(!value.contains('\n'), "{name} value must fit dotenv/env-file syntax");
}
use base64::Engine as _;
let decoded_ca =
base64::engine::general_purpose::STANDARD.decode(&assignments[1].1).unwrap();
assert_eq!(String::from_utf8(decoded_ca).unwrap(), bundle.ca_cert_pem);
}
}