use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::validate::{validate_file_path, validate_project_key};
pub const EVALUATIONS_PATH: &str = "/v1/evaluations";
pub fn evaluation_path(id: &str) -> String {
format!("{EVALUATIONS_PATH}/{id}")
}
pub const MAX_PROJECTS: usize = 100;
pub const MAX_FINDINGS: usize = 1000;
pub const MAX_RELATED: usize = 10;
pub const KIND_SECRET: &str = "secret";
pub const KIND_CONTRADICTION: &str = "contradiction";
pub const KIND_DEAD_LINK: &str = "dead_link";
pub const KIND_WRONG_SCOPE: &str = "wrong_scope";
pub const KIND_DUPLICATE: &str = "duplicate";
pub const KIND_STALE: &str = "stale";
pub const KINDS: [&str; 6] = [
KIND_SECRET,
KIND_CONTRADICTION,
KIND_DEAD_LINK,
KIND_WRONG_SCOPE,
KIND_DUPLICATE,
KIND_STALE,
];
pub const SEVERITY_LOW: &str = "low";
pub const SEVERITY_MEDIUM: &str = "medium";
pub const SEVERITY_HIGH: &str = "high";
pub const SEVERITIES: [&str; 3] = [SEVERITY_LOW, SEVERITY_MEDIUM, SEVERITY_HIGH];
pub const STATE_QUEUED: &str = "queued";
pub const STATE_RUNNING: &str = "running";
pub const STATE_DONE: &str = "done";
pub const STATE_FAILED: &str = "failed";
pub const GLOBAL_PREFIX: &str = "global:";
pub const MACHINE_PREFIX: &str = "machine:";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationRequest {
#[serde(default)]
pub projects: Vec<String>,
#[serde(default)]
pub contradictions: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationCreated {
pub id: String,
pub state: String,
pub job: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationSummary {
pub id: String,
pub state: String,
pub created_at: String,
pub finished_at: Option<String>,
#[serde(default)]
pub counts: BTreeMap<String, u64>,
#[serde(default)]
pub projects: Vec<String>,
#[serde(default)]
pub contradictions: bool,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationList {
pub evaluations: Vec<EvaluationSummary>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Evaluation {
pub id: String,
pub state: String,
pub created_at: String,
pub finished_at: Option<String>,
#[serde(default)]
pub findings: Vec<Finding>,
#[serde(default)]
pub details: Option<Value>,
#[serde(default)]
pub projects: Vec<String>,
#[serde(default)]
pub contradictions: bool,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub id: String,
pub kind: String,
pub severity: String,
pub project_key: String,
pub file_path: String,
pub lines: [u32; 2],
#[serde(default)]
pub related: Vec<FileRef>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct FileRef {
pub project_key: String,
pub file_path: String,
}
pub const FINDING_KEYS: [&str; 7] = [
"id",
"kind",
"severity",
"project_key",
"file_path",
"lines",
"related",
];
pub const FILE_REF_KEYS: [&str; 2] = ["project_key", "file_path"];
pub fn is_finding_id(id: &str) -> bool {
let Some(digits) = id.strip_prefix('f') else {
return false;
};
(1..=6).contains(&digits.len())
&& digits.bytes().all(|b| b.is_ascii_digit())
&& !digits.starts_with('0')
}
pub fn check_finding(value: &Value) -> Result<Finding, String> {
let Some(map) = value.as_object() else {
return Err("a finding is not an object".to_string());
};
let id = map
.get("id")
.and_then(Value::as_str)
.unwrap_or("(no id)")
.to_string();
exact_keys(map, &FINDING_KEYS, &format!("finding {id}"))?;
let finding: Finding =
serde_json::from_value(value.clone()).map_err(|e| format!("finding {id}: {e}"))?;
if !is_finding_id(&finding.id) {
return Err(format!("finding {id}: an id is f and a number, such as f1"));
}
if !KINDS.contains(&finding.kind.as_str()) {
return Err(format!("finding {id}: no kind {:?} exists", finding.kind));
}
if !SEVERITIES.contains(&finding.severity.as_str()) {
return Err(format!(
"finding {id}: no severity {:?} exists",
finding.severity
));
}
let [first, last] = finding.lines;
if first == 0 || last < first {
return Err(format!(
"finding {id}: lines are two line numbers from 1, the first no later than the last"
));
}
check_file(&finding.project_key, &finding.file_path)
.map_err(|e| format!("finding {id}: {e}"))?;
let related = map["related"]
.as_array()
.ok_or_else(|| format!("finding {id}: related is not a list"))?;
if related.len() > MAX_RELATED {
return Err(format!("finding {id}: at most {MAX_RELATED} related files"));
}
for r in related {
let Some(r) = r.as_object() else {
return Err(format!("finding {id}: a related file is not an object"));
};
exact_keys(r, &FILE_REF_KEYS, &format!("finding {id}'s related file"))?;
}
for r in &finding.related {
check_file(&r.project_key, &r.file_path).map_err(|e| format!("finding {id}: {e}"))?;
}
Ok(finding)
}
fn check_file(project_key: &str, file_path: &str) -> Result<(), String> {
validate_project_key(project_key).map_err(|e| e.to_string())?;
validate_file_path(file_path).map_err(|e| e.to_string())?;
Ok(())
}
fn exact_keys(
map: &serde_json::Map<String, Value>,
keys: &[&str],
what: &str,
) -> Result<(), String> {
if let Some(extra) = map.keys().find(|k| !keys.contains(&k.as_str())) {
return Err(format!(
"{what} has a key {extra:?}; it may have only {}",
keys.join(", ")
));
}
if let Some(missing) = keys.iter().find(|k| !map.contains_key(**k)) {
return Err(format!("{what} has no {missing}"));
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Details {
#[serde(default)]
pub findings: BTreeMap<String, FindingDetail>,
#[serde(default)]
pub skipped: Vec<Skipped>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindingDetail {
#[serde(default)]
pub excerpt: String,
#[serde(default)]
pub reasoning: String,
#[serde(default)]
pub suggested_edit: Option<SuggestedEdit>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SuggestedEdit {
pub project_key: String,
pub file_path: String,
pub base_sha256: String,
pub lines: [u32; 2],
pub replacement: String,
}
impl SuggestedEdit {
pub fn apply_to(&self, content: &str) -> Result<String, String> {
if crate::content_sha256(content) != self.base_sha256 {
return Err(format!(
"{} has changed since the report read it",
self.file_path
));
}
let lines: Vec<&str> = content.split_inclusive('\n').collect();
let [first, last] = self.lines;
if first == 0 || last < first || last as usize > lines.len() {
return Err(format!("{} has no lines {first} to {last}", self.file_path));
}
let mut out = String::with_capacity(content.len() + self.replacement.len());
for line in &lines[..first as usize - 1] {
out.push_str(line);
}
out.push_str(&self.replacement);
for line in &lines[last as usize..] {
out.push_str(line);
}
Ok(out)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Skipped {
pub check: String,
#[serde(default)]
pub project_key: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn finding() -> Value {
json!({"id": "f1", "kind": "secret", "severity": "high", "project_key": "acme/app",
"file_path": "topics/deploy.md", "lines": [12, 12], "related": []})
}
#[test]
fn a_finding_of_the_documented_shape_is_read() {
let f = check_finding(&finding()).unwrap();
assert_eq!((f.id.as_str(), f.lines), ("f1", [12, 12]));
assert_eq!(
serde_json::to_value(&f).unwrap(),
finding(),
"the keys, in order"
);
}
#[test]
fn a_finding_with_any_other_key_is_refused() {
for key in ["excerpt", "reasoning", "note", "Id"] {
let mut f = finding();
f[key] = json!("tokens live in 1Password");
let err = check_finding(&f).unwrap_err();
assert!(err.contains(&format!("{key:?}")), "{err}");
}
let mut f = finding();
f["related"] = json!([{"project_key": "global:eko", "file_path": "tools.md", "why": "x"}]);
assert!(check_finding(&f).unwrap_err().contains("\"why\""));
let mut f = finding();
f.as_object_mut().unwrap().remove("related");
assert!(check_finding(&f).unwrap_err().contains("has no related"));
}
#[test]
fn every_member_is_held_to_its_shape() {
let cases: [(&str, Value); 9] = [
("id", json!("finding one")),
("id", json!("f0")),
("id", json!("f1234567")),
("kind", json!("typo")),
("severity", json!("urgent")),
("lines", json!([0, 1])),
("lines", json!([5, 4])),
("file_path", json!("../etc/passwd")),
(
"related",
json!(vec![json!({"project_key": "a/b", "file_path": "c.md"}); 11]),
),
];
for (key, value) in cases {
let mut f = finding();
f[key] = value.clone();
assert!(check_finding(&f).is_err(), "{key} = {value} was accepted");
}
assert!(is_finding_id("f999999"));
}
#[test]
fn a_request_defaults_to_every_project_without_contradictions() {
let req: EvaluationRequest = serde_json::from_str("{}").unwrap();
assert_eq!(req, EvaluationRequest::default());
assert!(!req.contradictions);
}
#[test]
fn an_edit_applies_only_to_the_version_it_was_made_against() {
let content = "# Deploy\n- token: abc\n- use make deploy\n";
let edit = SuggestedEdit {
project_key: "acme/app".into(),
file_path: "deploy.md".into(),
base_sha256: crate::content_sha256(content),
lines: [2, 2],
replacement: "- token: [removed]\n".into(),
};
assert_eq!(
edit.apply_to(content).unwrap(),
"# Deploy\n- token: [removed]\n- use make deploy\n"
);
let removal = SuggestedEdit {
replacement: String::new(),
lines: [2, 3],
..edit.clone()
};
assert_eq!(removal.apply_to(content).unwrap(), "# Deploy\n");
assert!(edit.apply_to("# Deploy\n").unwrap_err().contains("changed"));
let beyond = SuggestedEdit {
lines: [4, 4],
..edit
};
assert!(beyond.apply_to(content).unwrap_err().contains("no lines"));
}
#[test]
fn a_path() {
assert_eq!(evaluation_path("eval_a"), "/v1/evaluations/eval_a");
}
}