use serde::{Deserialize, Serialize};
use crate::identity::FrameId;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifyRequest {
#[serde(default)]
pub frames: Vec<FrameId>,
}
impl VerifyRequest {
pub fn new(frames: Vec<FrameId>) -> Self {
Self { frames }
}
pub fn len(&self) -> usize {
self.frames.len()
}
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Verdict {
Valid,
Stale {
#[serde(default, skip_serializing_if = "Option::is_none")]
replacement_digest: Option<String>,
},
Gone,
Unknown,
}
impl Verdict {
pub fn permits_reuse(&self) -> bool {
matches!(self, Self::Valid)
}
pub fn warrants_requery(&self) -> bool {
matches!(self, Self::Stale { .. } | Self::Unknown)
}
pub fn status(&self) -> &'static str {
match self {
Self::Valid => "valid",
Self::Stale { .. } => "stale",
Self::Gone => "gone",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameVerdict {
pub frame: FrameId,
#[serde(flatten)]
pub verdict: Verdict,
}
impl FrameVerdict {
pub fn new(frame: FrameId, verdict: Verdict) -> Self {
Self { frame, verdict }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifyResponse {
#[serde(default)]
pub verdicts: Vec<FrameVerdict>,
}
impl VerifyResponse {
pub fn new(verdicts: Vec<FrameVerdict>) -> Self {
Self { verdicts }
}
pub fn uniform(request: &VerifyRequest, verdict: Verdict) -> Self {
Self {
verdicts: request
.frames
.iter()
.map(|frame| FrameVerdict::new(frame.clone(), verdict.clone()))
.collect(),
}
}
pub fn verdict_for(&self, frame: &FrameId) -> Option<&Verdict> {
self.verdicts
.iter()
.find(|entry| &entry.frame == frame)
.map(|entry| &entry.verdict)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(frame: &str, digest: Option<&str>) -> FrameId {
FrameId::new("repo-graph", frame, digest.map(String::from))
}
#[test]
fn a_request_carries_identities_and_no_frame_bodies() {
let request = VerifyRequest::new(vec![
id("retry-doc", Some("sha256:9f2c")),
id("timeout-doc", Some("sha256:aa01")),
]);
let json = serde_json::to_string(&request).unwrap();
assert!(
!json.contains("content\"") && !json.contains("title"),
"a verify request must never carry frame bodies: {json}"
);
let back: VerifyRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back, request);
assert_eq!(back.len(), 2);
}
#[test]
fn every_verdict_round_trips_through_its_tagged_wire_form() {
for (verdict, status) in [
(Verdict::Valid, "valid"),
(
Verdict::Stale {
replacement_digest: None,
},
"stale",
),
(
Verdict::Stale {
replacement_digest: Some("sha256:beef".into()),
},
"stale",
),
(Verdict::Gone, "gone"),
(Verdict::Unknown, "unknown"),
] {
let json = serde_json::to_string(&verdict).unwrap();
assert!(
json.contains(&format!("\"status\":\"{status}\"")),
"verdict must be tagged on `status`: {json}"
);
assert_eq!(verdict.status(), status);
let back: Verdict = serde_json::from_str(&json).unwrap();
assert_eq!(back, verdict);
}
}
#[test]
fn a_stale_verdict_without_a_replacement_omits_the_field() {
let json = serde_json::to_string(&Verdict::Stale {
replacement_digest: None,
})
.unwrap();
assert!(
!json.contains("replacement_digest"),
"an absent replacement must be omitted, not null: {json}"
);
let back: Verdict = serde_json::from_str(&json).unwrap();
assert!(!back.permits_reuse());
assert!(back.warrants_requery());
}
#[test]
fn a_stale_verdict_carries_a_replacement_digest_but_never_a_body() {
let verdict = Verdict::Stale {
replacement_digest: Some("sha256:beef".into()),
};
let json = serde_json::to_string(&verdict).unwrap();
assert!(json.contains("sha256:beef"));
let back: Verdict = serde_json::from_str(&json).unwrap();
assert_eq!(back, verdict);
}
#[test]
fn only_valid_permits_reuse() {
assert!(Verdict::Valid.permits_reuse());
for verdict in [
Verdict::Stale {
replacement_digest: None,
},
Verdict::Gone,
Verdict::Unknown,
] {
assert!(
!verdict.permits_reuse(),
"{} must not permit reuse",
verdict.status()
);
}
}
#[test]
fn gone_is_the_one_verdict_that_does_not_warrant_a_requery() {
assert!(!Verdict::Gone.warrants_requery());
assert!(
Verdict::Stale {
replacement_digest: None
}
.warrants_requery()
);
assert!(Verdict::Unknown.warrants_requery());
assert!(!Verdict::Valid.warrants_requery());
}
#[test]
fn a_response_round_trips_and_flattens_the_verdict_into_each_entry() {
let response = VerifyResponse::new(vec![
FrameVerdict::new(id("retry-doc", Some("sha256:9f2c")), Verdict::Valid),
FrameVerdict::new(
id("timeout-doc", Some("sha256:aa01")),
Verdict::Stale {
replacement_digest: Some("sha256:bb02".into()),
},
),
]);
let json = serde_json::to_string(&response).unwrap();
assert!(
!json.contains("\"verdict\""),
"verdict must flatten: {json}"
);
let back: VerifyResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back, response);
}
#[test]
fn a_verdict_is_correlated_by_full_identity_not_by_position() {
let valid_frame = id("retry-doc", Some("sha256:9f2c"));
let stale_frame = id("timeout-doc", Some("sha256:aa01"));
let response = VerifyResponse::new(vec![
FrameVerdict::new(stale_frame.clone(), Verdict::Gone),
FrameVerdict::new(valid_frame.clone(), Verdict::Valid),
]);
assert_eq!(response.verdict_for(&valid_frame), Some(&Verdict::Valid));
assert_eq!(response.verdict_for(&stale_frame), Some(&Verdict::Gone));
}
#[test]
fn a_verdict_for_a_different_digest_does_not_match_the_asked_identity() {
let asked = id("retry-doc", Some("sha256:9f2c"));
let response = VerifyResponse::new(vec![FrameVerdict::new(
id("retry-doc", Some("sha256:0000")),
Verdict::Valid,
)]);
assert_eq!(
response.verdict_for(&asked),
None,
"a verdict about a different digest must not answer for this one"
);
}
#[test]
fn a_uniform_response_answers_every_requested_identity() {
let request = VerifyRequest::new(vec![
id("retry-doc", Some("sha256:9f2c")),
id("timeout-doc", Some("sha256:aa01")),
]);
let response = VerifyResponse::uniform(&request, Verdict::Unknown);
assert_eq!(response.verdicts.len(), 2);
for frame in &request.frames {
assert_eq!(response.verdict_for(frame), Some(&Verdict::Unknown));
}
}
}