1use std::collections::BTreeMap;
31
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35use crate::validate::{validate_file_path, validate_project_key};
36
37pub const EVALUATIONS_PATH: &str = "/v1/evaluations";
39
40pub fn evaluation_path(id: &str) -> String {
42 format!("{EVALUATIONS_PATH}/{id}")
43}
44
45pub const MAX_PROJECTS: usize = 100;
47
48pub const MAX_FINDINGS: usize = 1000;
51
52pub const MAX_RELATED: usize = 10;
54
55pub const KIND_SECRET: &str = "secret";
57pub const KIND_CONTRADICTION: &str = "contradiction";
60pub const KIND_DEAD_LINK: &str = "dead_link";
62pub const KIND_WRONG_SCOPE: &str = "wrong_scope";
65pub const KIND_DUPLICATE: &str = "duplicate";
67pub const KIND_STALE: &str = "stale";
70
71pub const KINDS: [&str; 6] = [
74 KIND_SECRET,
75 KIND_CONTRADICTION,
76 KIND_DEAD_LINK,
77 KIND_WRONG_SCOPE,
78 KIND_DUPLICATE,
79 KIND_STALE,
80];
81
82pub const SEVERITY_LOW: &str = "low";
84pub const SEVERITY_MEDIUM: &str = "medium";
86pub const SEVERITY_HIGH: &str = "high";
88
89pub const SEVERITIES: [&str; 3] = [SEVERITY_LOW, SEVERITY_MEDIUM, SEVERITY_HIGH];
91
92pub const STATE_QUEUED: &str = "queued";
94pub const STATE_RUNNING: &str = "running";
96pub const STATE_DONE: &str = "done";
98pub const STATE_FAILED: &str = "failed";
100
101pub const GLOBAL_PREFIX: &str = "global:";
104
105pub const MACHINE_PREFIX: &str = "machine:";
107
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct EvaluationRequest {
112 #[serde(default)]
117 pub projects: Vec<String>,
118 #[serde(default)]
121 pub contradictions: bool,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126pub struct EvaluationCreated {
127 pub id: String,
129 pub state: String,
131 pub job: String,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
138pub struct EvaluationSummary {
139 pub id: String,
141 pub state: String,
144 pub created_at: String,
146 pub finished_at: Option<String>,
148 #[serde(default)]
150 pub counts: BTreeMap<String, u64>,
151 #[serde(default)]
153 pub projects: Vec<String>,
154 #[serde(default)]
156 pub contradictions: bool,
157 #[serde(default)]
160 pub error: Option<String>,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
165pub struct EvaluationList {
166 pub evaluations: Vec<EvaluationSummary>,
168}
169
170#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
172pub struct Evaluation {
173 pub id: String,
175 pub state: String,
177 pub created_at: String,
179 pub finished_at: Option<String>,
181 #[serde(default)]
183 pub findings: Vec<Finding>,
184 #[serde(default)]
188 pub details: Option<Value>,
189 #[serde(default)]
191 pub projects: Vec<String>,
192 #[serde(default)]
194 pub contradictions: bool,
195 #[serde(default)]
197 pub error: Option<String>,
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
203pub struct Finding {
204 pub id: String,
207 pub kind: String,
209 pub severity: String,
211 pub project_key: String,
213 pub file_path: String,
215 pub lines: [u32; 2],
217 #[serde(default)]
220 pub related: Vec<FileRef>,
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
225pub struct FileRef {
226 pub project_key: String,
228 pub file_path: String,
230}
231
232pub const FINDING_KEYS: [&str; 7] = [
234 "id",
235 "kind",
236 "severity",
237 "project_key",
238 "file_path",
239 "lines",
240 "related",
241];
242
243pub const FILE_REF_KEYS: [&str; 2] = ["project_key", "file_path"];
245
246pub fn is_finding_id(id: &str) -> bool {
249 let Some(digits) = id.strip_prefix('f') else {
250 return false;
251 };
252 (1..=6).contains(&digits.len())
253 && digits.bytes().all(|b| b.is_ascii_digit())
254 && !digits.starts_with('0')
255}
256
257pub fn check_finding(value: &Value) -> Result<Finding, String> {
267 let Some(map) = value.as_object() else {
268 return Err("a finding is not an object".to_string());
269 };
270 let id = map
271 .get("id")
272 .and_then(Value::as_str)
273 .unwrap_or("(no id)")
274 .to_string();
275 exact_keys(map, &FINDING_KEYS, &format!("finding {id}"))?;
276 let finding: Finding =
277 serde_json::from_value(value.clone()).map_err(|e| format!("finding {id}: {e}"))?;
278 if !is_finding_id(&finding.id) {
279 return Err(format!("finding {id}: an id is f and a number, such as f1"));
280 }
281 if !KINDS.contains(&finding.kind.as_str()) {
282 return Err(format!("finding {id}: no kind {:?} exists", finding.kind));
283 }
284 if !SEVERITIES.contains(&finding.severity.as_str()) {
285 return Err(format!(
286 "finding {id}: no severity {:?} exists",
287 finding.severity
288 ));
289 }
290 let [first, last] = finding.lines;
291 if first == 0 || last < first {
292 return Err(format!(
293 "finding {id}: lines are two line numbers from 1, the first no later than the last"
294 ));
295 }
296 check_file(&finding.project_key, &finding.file_path)
297 .map_err(|e| format!("finding {id}: {e}"))?;
298 let related = map["related"]
299 .as_array()
300 .ok_or_else(|| format!("finding {id}: related is not a list"))?;
301 if related.len() > MAX_RELATED {
302 return Err(format!("finding {id}: at most {MAX_RELATED} related files"));
303 }
304 for r in related {
305 let Some(r) = r.as_object() else {
306 return Err(format!("finding {id}: a related file is not an object"));
307 };
308 exact_keys(r, &FILE_REF_KEYS, &format!("finding {id}'s related file"))?;
309 }
310 for r in &finding.related {
311 check_file(&r.project_key, &r.file_path).map_err(|e| format!("finding {id}: {e}"))?;
312 }
313 Ok(finding)
314}
315
316fn check_file(project_key: &str, file_path: &str) -> Result<(), String> {
317 validate_project_key(project_key).map_err(|e| e.to_string())?;
318 validate_file_path(file_path).map_err(|e| e.to_string())?;
319 Ok(())
320}
321
322fn exact_keys(
323 map: &serde_json::Map<String, Value>,
324 keys: &[&str],
325 what: &str,
326) -> Result<(), String> {
327 if let Some(extra) = map.keys().find(|k| !keys.contains(&k.as_str())) {
328 return Err(format!(
329 "{what} has a key {extra:?}; it may have only {}",
330 keys.join(", ")
331 ));
332 }
333 if let Some(missing) = keys.iter().find(|k| !map.contains_key(**k)) {
334 return Err(format!("{what} has no {missing}"));
335 }
336 Ok(())
337}
338
339#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
343pub struct Details {
344 #[serde(default)]
346 pub findings: BTreeMap<String, FindingDetail>,
347 #[serde(default)]
351 pub skipped: Vec<Skipped>,
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
356pub struct FindingDetail {
357 #[serde(default)]
360 pub excerpt: String,
361 #[serde(default)]
363 pub reasoning: String,
364 #[serde(default)]
366 pub suggested_edit: Option<SuggestedEdit>,
367}
368
369#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
373pub struct SuggestedEdit {
374 pub project_key: String,
376 pub file_path: String,
378 pub base_sha256: String,
381 pub lines: [u32; 2],
383 pub replacement: String,
386}
387
388impl SuggestedEdit {
389 pub fn apply_to(&self, content: &str) -> Result<String, String> {
392 if crate::content_sha256(content) != self.base_sha256 {
393 return Err(format!(
394 "{} has changed since the report read it",
395 self.file_path
396 ));
397 }
398 let lines: Vec<&str> = content.split_inclusive('\n').collect();
399 let [first, last] = self.lines;
400 if first == 0 || last < first || last as usize > lines.len() {
401 return Err(format!("{} has no lines {first} to {last}", self.file_path));
402 }
403 let mut out = String::with_capacity(content.len() + self.replacement.len());
404 for line in &lines[..first as usize - 1] {
405 out.push_str(line);
406 }
407 out.push_str(&self.replacement);
408 for line in &lines[last as usize..] {
409 out.push_str(line);
410 }
411 Ok(out)
412 }
413}
414
415#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
417pub struct Skipped {
418 pub check: String,
420 #[serde(default)]
422 pub project_key: String,
423 pub reason: String,
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use serde_json::json;
431
432 fn finding() -> Value {
433 json!({"id": "f1", "kind": "secret", "severity": "high", "project_key": "acme/app",
434 "file_path": "topics/deploy.md", "lines": [12, 12], "related": []})
435 }
436
437 #[test]
438 fn a_finding_of_the_documented_shape_is_read() {
439 let f = check_finding(&finding()).unwrap();
440 assert_eq!((f.id.as_str(), f.lines), ("f1", [12, 12]));
441 assert_eq!(
442 serde_json::to_value(&f).unwrap(),
443 finding(),
444 "the keys, in order"
445 );
446 }
447
448 #[test]
450 fn a_finding_with_any_other_key_is_refused() {
451 for key in ["excerpt", "reasoning", "note", "Id"] {
452 let mut f = finding();
453 f[key] = json!("tokens live in 1Password");
454 let err = check_finding(&f).unwrap_err();
455 assert!(err.contains(&format!("{key:?}")), "{err}");
456 }
457 let mut f = finding();
458 f["related"] = json!([{"project_key": "global:eko", "file_path": "tools.md", "why": "x"}]);
459 assert!(check_finding(&f).unwrap_err().contains("\"why\""));
460 let mut f = finding();
461 f.as_object_mut().unwrap().remove("related");
462 assert!(check_finding(&f).unwrap_err().contains("has no related"));
463 }
464
465 #[test]
466 fn every_member_is_held_to_its_shape() {
467 let cases: [(&str, Value); 9] = [
468 ("id", json!("finding one")),
469 ("id", json!("f0")),
470 ("id", json!("f1234567")),
471 ("kind", json!("typo")),
472 ("severity", json!("urgent")),
473 ("lines", json!([0, 1])),
474 ("lines", json!([5, 4])),
475 ("file_path", json!("../etc/passwd")),
476 (
477 "related",
478 json!(vec![json!({"project_key": "a/b", "file_path": "c.md"}); 11]),
479 ),
480 ];
481 for (key, value) in cases {
482 let mut f = finding();
483 f[key] = value.clone();
484 assert!(check_finding(&f).is_err(), "{key} = {value} was accepted");
485 }
486 assert!(is_finding_id("f999999"));
487 }
488
489 #[test]
490 fn a_request_defaults_to_every_project_without_contradictions() {
491 let req: EvaluationRequest = serde_json::from_str("{}").unwrap();
492 assert_eq!(req, EvaluationRequest::default());
493 assert!(!req.contradictions);
494 }
495
496 #[test]
497 fn an_edit_applies_only_to_the_version_it_was_made_against() {
498 let content = "# Deploy\n- token: abc\n- use make deploy\n";
499 let edit = SuggestedEdit {
500 project_key: "acme/app".into(),
501 file_path: "deploy.md".into(),
502 base_sha256: crate::content_sha256(content),
503 lines: [2, 2],
504 replacement: "- token: [removed]\n".into(),
505 };
506 assert_eq!(
507 edit.apply_to(content).unwrap(),
508 "# Deploy\n- token: [removed]\n- use make deploy\n"
509 );
510 let removal = SuggestedEdit {
511 replacement: String::new(),
512 lines: [2, 3],
513 ..edit.clone()
514 };
515 assert_eq!(removal.apply_to(content).unwrap(), "# Deploy\n");
516 assert!(edit.apply_to("# Deploy\n").unwrap_err().contains("changed"));
517 let beyond = SuggestedEdit {
518 lines: [4, 4],
519 ..edit
520 };
521 assert!(beyond.apply_to(content).unwrap_err().contains("no lines"));
522 }
523
524 #[test]
525 fn a_path() {
526 assert_eq!(evaluation_path("eval_a"), "/v1/evaluations/eval_a");
527 }
528}