Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = [
]

[workspace.package]
version = "0.1.13"
version = "0.1.14"
edition = "2021"
authors = ["brin contributors"]
license = "MIT"
Expand Down
209 changes: 186 additions & 23 deletions crates/cli/src/api_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ pub struct CheckResult {
pub headers: BrinHeaders,
}

/// Optional query parameters for a check request
#[derive(Debug, Default)]
pub struct CheckOptions<'a> {
pub details: bool,
pub webhook: Option<&'a str>,
pub tolerance: Option<&'a str>,
pub refresh: bool,
pub mode: Option<&'a str>,
pub format: Option<&'a str>,
}

/// Client for the brin API
pub struct BrinClient {
client: Client,
Expand All @@ -43,24 +54,34 @@ impl BrinClient {
///
/// - `origin` — e.g. `"npm"`, `"pypi"`, `"repo"`, `"mcp"`, `"skill"`, `"domain"`, `"commit"`
/// - `identifier` — the artifact identifier, e.g. `"express"`, `"owner/repo"`, `"owner/repo@sha"`
/// - `details` — if true, appends `?details=true` to include sub-scores
/// - `webhook` — if provided, appends `?webhook=<url>` so the API POSTs tier events
/// - `opts` — optional query parameters (details, webhook, tolerance, refresh, mode, format)
pub async fn check(
&self,
origin: &str,
identifier: &str,
details: bool,
webhook: Option<&str>,
opts: &CheckOptions<'_>,
) -> Result<CheckResult> {
let url = format!("{}/{}/{}", self.base_url, origin, identifier);

let mut query: Vec<(&str, String)> = Vec::new();
if details {
if opts.details {
query.push(("details", "true".into()));
}
if let Some(wh) = webhook {
if let Some(wh) = opts.webhook {
query.push(("webhook", wh.to_string()));
}
if let Some(t) = opts.tolerance {
query.push(("tolerance", t.to_string()));
}
if opts.refresh {
query.push(("refresh", "true".into()));
}
if let Some(m) = opts.mode {
query.push(("mode", m.to_string()));
}
if let Some(f) = opts.format {
query.push(("format", f.to_string()));
}

let response = self
.client
Expand Down Expand Up @@ -168,7 +189,10 @@ mod tests {
.await;

let client = BrinClient::new(&server.uri());
let result = client.check("npm", "express", false, None).await.unwrap();
let result = client
.check("npm", "express", &CheckOptions::default())
.await
.unwrap();

// body is valid JSON containing expected fields
let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
Expand Down Expand Up @@ -200,7 +224,7 @@ mod tests {

let client = BrinClient::new(&server.uri());
let result = client
.check("repo", "expressjs/express", false, None)
.check("repo", "expressjs/express", &CheckOptions::default())
.await
.unwrap();

Expand All @@ -227,7 +251,7 @@ mod tests {

let client = BrinClient::new(&server.uri());
let result = client
.check("npm", "lodash@4.17.21", false, None)
.check("npm", "lodash@4.17.21", &CheckOptions::default())
.await
.unwrap();

Expand All @@ -250,7 +274,11 @@ mod tests {
.await;

let client = BrinClient::new(&server.uri());
let result = client.check("npm", "express", true, None).await.unwrap();
let opts = CheckOptions {
details: true,
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert!(
Expand All @@ -274,8 +302,10 @@ mod tests {
.await;

let client = BrinClient::new(&server.uri());
// details=false — should succeed without the query param being required
let result = client.check("npm", "express", false, None).await.unwrap();
let result = client
.check("npm", "express", &CheckOptions::default())
.await
.unwrap();
let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert!(v["sub_scores"].is_null() || !v.as_object().unwrap().contains_key("sub_scores"));
}
Expand All @@ -294,10 +324,11 @@ mod tests {
.await;

let client = BrinClient::new(&server.uri());
let result = client
.check("npm", "express", false, Some("https://my-server.com/cb"))
.await
.unwrap();
let opts = CheckOptions {
webhook: Some("https://my-server.com/cb"),
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert_eq!(v["verdict"], "safe");
Expand All @@ -316,10 +347,12 @@ mod tests {
.await;

let client = BrinClient::new(&server.uri());
let result = client
.check("npm", "express", true, Some("https://my-server.com/cb"))
.await
.unwrap();
let opts = CheckOptions {
details: true,
webhook: Some("https://my-server.com/cb"),
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert!(v["sub_scores"].is_object());
Expand All @@ -339,7 +372,10 @@ mod tests {
.await;

let client = BrinClient::new(&server.uri());
let result = client.check("npm", "express", false, None).await.unwrap();
let result = client
.check("npm", "express", &CheckOptions::default())
.await
.unwrap();

assert!(result.headers.score.is_none());
assert!(result.headers.verdict.is_none());
Expand All @@ -361,7 +397,7 @@ mod tests {

let client = BrinClient::new(&server.uri());
let err = client
.check("npm", "nonexistent", false, None)
.check("npm", "nonexistent", &CheckOptions::default())
.await
.unwrap_err();

Expand All @@ -383,10 +419,137 @@ mod tests {

let client = BrinClient::new(&server.uri());
let err = client
.check("npm", "express", false, None)
.check("npm", "express", &CheckOptions::default())
.await
.unwrap_err();

assert!(err.to_string().contains("error"));
}

// ── check — ?tolerance=<level> ───────────────────────────────────────

#[tokio::test]
async fn check_tolerance_appends_query_param() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/npm/express"))
.and(query_param("tolerance", "lenient"))
.respond_with(ResponseTemplate::new(200).set_body_json(safe_body()))
.mount(&server)
.await;

let client = BrinClient::new(&server.uri());
let opts = CheckOptions {
tolerance: Some("lenient"),
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert_eq!(v["verdict"], "safe");
}

// ── check — ?refresh=true ────────────────────────────────────────────

#[tokio::test]
async fn check_refresh_appends_query_param() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/npm/express"))
.and(query_param("refresh", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(safe_body()))
.mount(&server)
.await;

let client = BrinClient::new(&server.uri());
let opts = CheckOptions {
refresh: true,
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert_eq!(v["verdict"], "safe");
}

// ── check — ?mode=full ───────────────────────────────────────────────

#[tokio::test]
async fn check_mode_appends_query_param() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/npm/express"))
.and(query_param("mode", "full"))
.respond_with(ResponseTemplate::new(200).set_body_json(safe_body()))
.mount(&server)
.await;

let client = BrinClient::new(&server.uri());
let opts = CheckOptions {
mode: Some("full"),
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert_eq!(v["verdict"], "safe");
}

// ── check — ?format=<fmt> ────────────────────────────────────────────

#[tokio::test]
async fn check_format_appends_query_param() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/npm/express"))
.and(query_param("format", "simple"))
.respond_with(ResponseTemplate::new(200).set_body_string("safe 85"))
.mount(&server)
.await;

let client = BrinClient::new(&server.uri());
let opts = CheckOptions {
format: Some("simple"),
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

assert_eq!(result.body, "safe 85");
}

// ── check — all new params combined ──────────────────────────────────

#[tokio::test]
async fn check_all_new_params_combined() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/npm/express"))
.and(query_param("details", "true"))
.and(query_param("tolerance", "yolo"))
.and(query_param("refresh", "true"))
.and(query_param("mode", "full"))
.and(query_param("format", "json"))
.respond_with(ResponseTemplate::new(200).set_body_json(safe_body_with_sub_scores()))
.mount(&server)
.await;

let client = BrinClient::new(&server.uri());
let opts = CheckOptions {
details: true,
tolerance: Some("yolo"),
refresh: true,
mode: Some("full"),
format: Some("json"),
..Default::default()
};
let result = client.check("npm", "express", &opts).await.unwrap();

let v: serde_json::Value = serde_json::from_str(&result.body).unwrap();
assert!(v["sub_scores"].is_object());
}
}
7 changes: 3 additions & 4 deletions crates/cli/src/commands/check.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! check command — look up an artifact's security assessment

use crate::api_client::BrinClient;
use crate::api_client::{BrinClient, CheckOptions};
use anyhow::{bail, Result};

/// Parse `<origin>/<identifier>` from the artifact string.
Expand Down Expand Up @@ -36,13 +36,12 @@ pub(crate) fn parse_artifact(artifact: &str) -> Result<(&str, &str)> {
pub async fn run(
client: &BrinClient,
artifact: &str,
details: bool,
webhook: Option<&str>,
opts: &CheckOptions<'_>,
headers: bool,
) -> Result<()> {
let (origin, identifier) = parse_artifact(artifact)?;

let result = client.check(origin, identifier, details, webhook).await?;
let result = client.check(origin, identifier, opts).await?;

if headers {
// Print only the X-Brin-* response headers, one per line
Expand Down
33 changes: 32 additions & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod api_client;
mod commands;

use api_client::CheckOptions;
use clap::{Parser, Subcommand};

#[derive(Parser)]
Expand Down Expand Up @@ -48,6 +49,22 @@ enum Commands {
#[arg(long, value_name = "URL")]
webhook: Option<String>,

/// Safety tolerance: conservative (default), lenient, or yolo
#[arg(long, value_name = "LEVEL")]
tolerance: Option<String>,

/// Force a fresh scan, ignoring any cached result
#[arg(long)]
refresh: bool,

/// Scan mode: omit for async (returns preliminary score), or "full" for synchronous complete scan
#[arg(long, value_name = "MODE")]
mode: Option<String>,

/// Response format: json (default), simple, or badge
#[arg(long, value_name = "FORMAT")]
format: Option<String>,

/// Print only the X-Brin-* response headers instead of the JSON body
#[arg(long)]
headers: bool,
Expand All @@ -64,7 +81,21 @@ async fn main() -> anyhow::Result<()> {
artifact,
details,
webhook,
tolerance,
refresh,
mode,
format,
headers,
} => commands::check::run(&client, &artifact, details, webhook.as_deref(), headers).await,
} => {
let opts = CheckOptions {
details,
webhook: webhook.as_deref(),
tolerance: tolerance.as_deref(),
refresh,
mode: mode.as_deref(),
format: format.as_deref(),
};
commands::check::run(&client, &artifact, &opts, headers).await
}
}
}
Loading