1#![deny(clippy::all)]
8#![warn(clippy::pedantic)]
9#![deny(missing_docs)]
10
11mod exchange;
12mod finding_id;
13mod scoring;
14mod serde_helpers;
15mod signal;
16mod technique;
17
18pub use exchange::{DifferentialSet, ProbeExchange};
19pub use finding_id::finding_id;
20pub use scoring::{ScoringDimension, ScoringReason};
21pub use signal::{ImpactClass, Signal, SignalKind};
22pub use technique::{NormativeStrength, Technique, Vector};
23
24use bytes::Bytes;
25use http::{HeaderMap, Method, StatusCode};
26use serde::{Deserialize, Serialize};
27use serde_helpers::{
28 bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
29};
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ResponseSurface {
37 #[serde(with = "status_code_serde")]
39 pub status: StatusCode,
40 #[serde(with = "header_map_serde")]
42 pub headers: HeaderMap,
43 #[serde(with = "bytes_serde")]
45 pub body: Bytes,
46 pub timing_ns: u64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ProbeDefinition {
58 pub url: String,
60 #[serde(with = "method_serde")]
62 pub method: Method,
63 #[serde(with = "header_map_serde")]
65 pub headers: HeaderMap,
66 #[serde(with = "opt_bytes_serde")]
68 pub body: Option<Bytes>,
69}
70
71#[deprecated(since = "0.4.0", note = "use DifferentialSet instead — see system-design.md")]
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ProbeSet {
84 pub baseline: Vec<ResponseSurface>,
86 pub probe: Vec<ResponseSurface>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[non_exhaustive]
95pub enum OracleClass {
96 Existence,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub enum OracleVerdict {
103 Confirmed,
106 Likely,
109 Inconclusive,
111 NotPresent,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub enum Severity {
120 High,
123 Medium,
125 Low,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct OracleResult {
136 pub class: OracleClass,
138 pub verdict: OracleVerdict,
140 pub severity: Option<Severity>,
142 #[serde(default)]
144 pub confidence: u8,
145 #[serde(skip_serializing_if = "Option::is_none", default)]
147 pub impact_class: Option<ImpactClass>,
148 #[serde(skip_serializing_if = "Vec::is_empty", default)]
150 pub reasons: Vec<ScoringReason>,
151 #[serde(skip_serializing_if = "Vec::is_empty", default)]
153 pub signals: Vec<Signal>,
154 #[serde(skip_serializing_if = "Option::is_none", default)]
156 pub technique_id: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none", default)]
159 pub vector: Option<Vector>,
160 #[serde(skip_serializing_if = "Option::is_none", default)]
162 pub normative_strength: Option<NormativeStrength>,
163 #[serde(skip_serializing_if = "Option::is_none", default)]
165 pub label: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none", default)]
169 pub leaks: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none", default)]
172 pub rfc_basis: Option<String>,
173}
174
175impl OracleResult {
176 #[must_use]
180 pub fn primary_evidence(&self) -> &str {
181 self.signals
182 .iter()
183 .find(|s| s.kind == SignalKind::StatusCodeDiff)
184 .or_else(|| self.signals.first())
185 .map_or("—", |s| s.evidence.as_str())
186 }
187}
188
189#[derive(Debug, thiserror::Error)]
191#[non_exhaustive]
192pub enum Error {
193 #[error("http error: {0}")]
195 Http(String),
196 #[error("analysis error: {0}")]
198 Analysis(String),
199 #[error("serialization error: {0}")]
201 Serialization(#[from] serde_json::Error),
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn confirmed_result_with_metadata() -> OracleResult {
209 OracleResult {
210 class: OracleClass::Existence,
211 verdict: OracleVerdict::Confirmed,
212 severity: Some(Severity::High),
213 confidence: 0,
214 impact_class: None,
215 reasons: vec![],
216 signals: vec![Signal {
217 kind: SignalKind::StatusCodeDiff,
218 evidence: "403 (baseline) vs 404 (probe)".into(),
219 rfc_basis: None,
220 }],
221 technique_id: None,
222 vector: None,
223 normative_strength: None,
224 label: Some("Authorization-based differential".into()),
225 leaks: Some("Resource existence confirmed to low-privilege callers".into()),
226 rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
227 }
228 }
229
230 fn not_present_result() -> OracleResult {
231 OracleResult {
232 class: OracleClass::Existence,
233 verdict: OracleVerdict::NotPresent,
234 severity: None,
235 confidence: 0,
236 impact_class: None,
237 reasons: vec![],
238 signals: vec![Signal {
239 kind: SignalKind::StatusCodeDiff,
240 evidence: "404 (baseline) vs 404 (probe)".into(),
241 rfc_basis: None,
242 }],
243 technique_id: None,
244 vector: None,
245 normative_strength: None,
246 label: None,
247 leaks: None,
248 rfc_basis: None,
249 }
250 }
251
252 #[test]
253 fn serialize_confirmed_includes_metadata_fields() {
254 let result = confirmed_result_with_metadata();
255 let json = serde_json::to_value(&result).expect("serialization failed");
256 assert_eq!(json["label"], "Authorization-based differential");
257 assert_eq!(json["leaks"], "Resource existence confirmed to low-privilege callers");
258 assert_eq!(json["rfc_basis"], "RFC 9110 \u{00a7}15.5.4");
259 }
260
261 #[test]
262 fn serialize_not_present_omits_none_metadata() {
263 let result = not_present_result();
264 let json = serde_json::to_value(&result).expect("serialization failed");
265 assert!(!json.as_object().expect("expected object").contains_key("label"));
266 assert!(!json.as_object().expect("expected object").contains_key("leaks"));
267 assert!(!json.as_object().expect("expected object").contains_key("rfc_basis"));
268 }
269
270 #[test]
271 fn roundtrip_confirmed_preserves_metadata() {
272 let original = confirmed_result_with_metadata();
273 let json = serde_json::to_string(&original).expect("serialization failed");
274 let deserialized: OracleResult =
275 serde_json::from_str(&json).expect("deserialization failed");
276 assert_eq!(deserialized.label, original.label);
277 assert_eq!(deserialized.leaks, original.leaks);
278 assert_eq!(deserialized.rfc_basis, original.rfc_basis);
279 }
280
281 #[test]
282 fn roundtrip_not_present_preserves_none_metadata() {
283 let original = not_present_result();
284 let json = serde_json::to_string(&original).expect("serialization failed");
285 let deserialized: OracleResult =
286 serde_json::from_str(&json).expect("deserialization failed");
287 assert_eq!(deserialized.label, None);
288 assert_eq!(deserialized.leaks, None);
289 assert_eq!(deserialized.rfc_basis, None);
290 }
291
292 #[test]
293 fn deserialize_minimal_json_defaults_to_none() {
294 let minimal = r#"{
295 "class": "Existence",
296 "verdict": "Confirmed",
297 "severity": "High"
298 }"#;
299 let result: OracleResult =
300 serde_json::from_str(minimal).expect("deserialization failed");
301 assert_eq!(result.label, None);
302 assert_eq!(result.leaks, None);
303 assert_eq!(result.rfc_basis, None);
304 assert!(result.signals.is_empty());
305 assert_eq!(result.technique_id, None);
306 assert_eq!(result.confidence, 0);
307 assert_eq!(result.impact_class, None);
308 assert!(result.reasons.is_empty());
309 }
310
311 #[test]
312 fn oracle_result_with_technique_context_serializes() {
313 let result = OracleResult {
314 class: OracleClass::Existence,
315 verdict: OracleVerdict::Confirmed,
316 severity: Some(Severity::High),
317 confidence: 0,
318 impact_class: None,
319 reasons: vec![],
320 signals: vec![Signal {
321 kind: SignalKind::StatusCodeDiff,
322 evidence: "304 vs 404".into(),
323 rfc_basis: Some("RFC 9110 \u{00a7}13.1.2".into()),
324 }],
325 technique_id: Some("if-none-match".into()),
326 vector: Some(Vector::CacheProbing),
327 normative_strength: Some(NormativeStrength::Must),
328 label: None,
329 leaks: None,
330 rfc_basis: None,
331 };
332 let json = serde_json::to_value(&result).expect("serialization failed");
333 assert_eq!(json["technique_id"], "if-none-match");
334 assert_eq!(json["vector"], "CacheProbing");
335 assert_eq!(json["normative_strength"], "Must");
336 assert_eq!(json["signals"][0]["kind"], "StatusCodeDiff");
337 assert_eq!(json["signals"][0]["evidence"], "304 vs 404");
338 assert_eq!(json["signals"][0]["rfc_basis"], "RFC 9110 \u{00a7}13.1.2");
339 }
340
341 #[test]
342 fn oracle_result_roundtrip_with_technique_context() {
343 let original = OracleResult {
344 class: OracleClass::Existence,
345 verdict: OracleVerdict::Likely,
346 severity: Some(Severity::Medium),
347 confidence: 0,
348 impact_class: None,
349 reasons: vec![],
350 signals: vec![Signal {
351 kind: SignalKind::HeaderPresence,
352 evidence: "ETag present in baseline, absent in probe".into(),
353 rfc_basis: None,
354 }],
355 technique_id: Some("get-200-404".into()),
356 vector: Some(Vector::StatusCodeDiff),
357 normative_strength: Some(NormativeStrength::Should),
358 label: Some("Status code differential".into()),
359 leaks: Some("Resource existence".into()),
360 rfc_basis: Some("RFC 9110 \u{00a7}15.5.5".into()),
361 };
362 let json = serde_json::to_string(&original).expect("serialization failed");
363 let back: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
364 assert_eq!(back.technique_id, original.technique_id);
365 assert_eq!(back.vector, original.vector);
366 assert_eq!(back.normative_strength, original.normative_strength);
367 assert_eq!(back.signals.len(), 1);
368 assert_eq!(back.signals[0].kind, SignalKind::HeaderPresence);
369 }
370
371 #[test]
372 fn primary_evidence_returns_status_code_diff() {
373 let result = confirmed_result_with_metadata();
374 assert_eq!(result.primary_evidence(), "403 (baseline) vs 404 (probe)");
375 }
376
377 #[test]
378 fn primary_evidence_falls_back_to_first_signal() {
379 let result = OracleResult {
380 class: OracleClass::Existence,
381 verdict: OracleVerdict::Confirmed,
382 severity: Some(Severity::Medium),
383 confidence: 0,
384 impact_class: None,
385 reasons: vec![],
386 signals: vec![Signal {
387 kind: SignalKind::HeaderPresence,
388 evidence: "etag present in baseline".into(),
389 rfc_basis: None,
390 }],
391 technique_id: None,
392 vector: None,
393 normative_strength: None,
394 label: None,
395 leaks: None,
396 rfc_basis: None,
397 };
398 assert_eq!(result.primary_evidence(), "etag present in baseline");
399 }
400
401 #[test]
402 fn primary_evidence_returns_dash_when_empty() {
403 let result = not_present_result();
404 let mut empty = result;
405 empty.signals.clear();
406 assert_eq!(empty.primary_evidence(), "\u{2014}");
407 }
408
409 #[test]
410 fn signal_kind_copy_and_eq() {
411 let a = SignalKind::StatusCodeDiff;
412 let b = a;
413 assert_eq!(a, b);
414 }
415
416 #[test]
417 fn vector_copy_and_eq() {
418 let a = Vector::CacheProbing;
419 let b = a;
420 assert_eq!(a, b);
421 }
422
423 #[test]
424 fn normative_strength_copy_and_eq() {
425 let a = NormativeStrength::Must;
426 let b = a;
427 assert_eq!(a, b);
428 assert_ne!(a, NormativeStrength::May);
429 }
430
431 #[test]
432 fn technique_clone() {
433 let t = Technique {
434 id: "test",
435 name: "Test technique",
436 oracle_class: OracleClass::Existence,
437 vector: Vector::StatusCodeDiff,
438 strength: NormativeStrength::Must,
439 };
440 let t2 = t.clone();
441 assert_eq!(t2.id, "test");
442 assert_eq!(t2.vector, Vector::StatusCodeDiff);
443 }
444
445 #[test]
446 fn probe_exchange_pairs_request_and_response() {
447 let exchange = ProbeExchange {
448 request: ProbeDefinition {
449 url: "https://example.com/resource/1".into(),
450 method: http::Method::GET,
451 headers: HeaderMap::new(),
452 body: None,
453 },
454 response: ResponseSurface {
455 status: http::StatusCode::OK,
456 headers: HeaderMap::new(),
457 body: Bytes::new(),
458 timing_ns: 1_000_000,
459 },
460 };
461 assert_eq!(exchange.request.url, "https://example.com/resource/1");
462 assert_eq!(exchange.response.status, http::StatusCode::OK);
463 }
464
465 #[test]
466 fn differential_set_carries_technique() {
467 let technique = Technique {
468 id: "get-200-404",
469 name: "GET 200/404",
470 oracle_class: OracleClass::Existence,
471 vector: Vector::StatusCodeDiff,
472 strength: NormativeStrength::Must,
473 };
474 let ds = DifferentialSet {
475 baseline: vec![],
476 probe: vec![],
477 technique,
478 };
479 assert_eq!(ds.technique.id, "get-200-404");
480 assert_eq!(ds.technique.strength, NormativeStrength::Must);
481 }
482
483 #[test]
484 fn technique_without_target_signals_constructs() {
485 let t = Technique {
486 id: "range-416",
487 name: "Range 416/404",
488 oracle_class: OracleClass::Existence,
489 vector: Vector::CacheProbing,
490 strength: NormativeStrength::Should,
491 };
492 let t2 = t.clone();
493 assert_eq!(t2.id, "range-416");
494 assert_eq!(t2.strength, NormativeStrength::Should);
495 assert_eq!(t2.oracle_class, OracleClass::Existence);
496 }
497
498 #[test]
499 fn impact_class_copy_and_eq() {
500 let a = ImpactClass::High;
501 let b = a;
502 assert_eq!(a, b);
503 assert_ne!(a, ImpactClass::Low);
504 }
505
506 #[test]
507 fn impact_class_serialize_roundtrip() {
508 let json = serde_json::to_string(&ImpactClass::Medium).expect("serialization failed");
509 let back: ImpactClass = serde_json::from_str(&json).expect("deserialization failed");
510 assert_eq!(back, ImpactClass::Medium);
511 }
512
513 #[test]
514 fn scoring_dimension_copy_and_eq() {
515 let a = ScoringDimension::Confidence;
516 let b = a;
517 assert_eq!(a, b);
518 assert_ne!(a, ScoringDimension::Impact);
519 }
520
521 #[test]
522 fn scoring_reason_serialize_roundtrip() {
523 let reason = ScoringReason {
524 description: "Status differential 416 vs 404".into(),
525 points: 75,
526 dimension: ScoringDimension::Confidence,
527 };
528 let json = serde_json::to_string(&reason).expect("serialization failed");
529 let back: ScoringReason = serde_json::from_str(&json).expect("deserialization failed");
530 assert_eq!(back.description, "Status differential 416 vs 404");
531 assert_eq!(back.points, 75);
532 assert_eq!(back.dimension, ScoringDimension::Confidence);
533 }
534
535 #[test]
536 fn scoring_reason_negative_points() {
537 let reason = ScoringReason {
538 description: "Inconsistent across samples".into(),
539 points: -10,
540 dimension: ScoringDimension::Confidence,
541 };
542 let json = serde_json::to_string(&reason).expect("serialization failed");
543 let back: ScoringReason = serde_json::from_str(&json).expect("deserialization failed");
544 assert_eq!(back.points, -10);
545 }
546
547 #[test]
548 fn oracle_result_with_confidence_and_impact_serializes() {
549 let result = OracleResult {
550 class: OracleClass::Existence,
551 verdict: OracleVerdict::Confirmed,
552 severity: Some(Severity::High),
553 confidence: 88,
554 impact_class: Some(ImpactClass::High),
555 reasons: vec![
556 ScoringReason {
557 description: "Status differential 416 vs 404".into(),
558 points: 75,
559 dimension: ScoringDimension::Confidence,
560 },
561 ScoringReason {
562 description: "Content-Range reveals exact size".into(),
563 points: 12,
564 dimension: ScoringDimension::Impact,
565 },
566 ],
567 signals: vec![],
568 technique_id: None,
569 vector: None,
570 normative_strength: None,
571 label: None,
572 leaks: None,
573 rfc_basis: None,
574 };
575 let json = serde_json::to_value(&result).expect("serialization failed");
576 assert_eq!(json["confidence"], 88);
577 assert_eq!(json["impact_class"], "High");
578 assert_eq!(json["reasons"].as_array().expect("expected array").len(), 2);
579 assert_eq!(json["reasons"][0]["points"], 75);
580 assert_eq!(json["reasons"][1]["dimension"], "Impact");
581 }
582
583 #[test]
584 fn oracle_result_zero_confidence_omits_impact_and_reasons() {
585 let result = OracleResult {
586 class: OracleClass::Existence,
587 verdict: OracleVerdict::NotPresent,
588 severity: None,
589 confidence: 0,
590 impact_class: None,
591 reasons: vec![],
592 signals: vec![],
593 technique_id: None,
594 vector: None,
595 normative_strength: None,
596 label: None,
597 leaks: None,
598 rfc_basis: None,
599 };
600 let json = serde_json::to_value(&result).expect("serialization failed");
601 let obj = json.as_object().expect("expected object");
602 assert!(!obj.contains_key("impact_class"));
603 assert!(!obj.contains_key("reasons"));
604 }
605}