use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::{
AnnotationId, ArtaId, CommitHash, CoveredRegion, Sha256, Status, VerifiedOutcome, VerifyLevel,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum IndexEntry {
Intent(IntentEntry),
Assume(AssumeEntry),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntentEntry {
pub text: String,
pub verify: VerifyLevel,
pub status: Status,
pub text_hash: Sha256,
pub body_hash: Sha256,
pub file: String,
pub site: String,
pub covered_region: CoveredRegion,
pub binding: BindingState,
pub parent: Option<ParentLink>,
pub last_critiqued_at_text_hash: Option<Sha256>,
pub last_critique_finding_count: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AssumeEntry {
pub text: String,
pub status: Status,
pub text_hash: Sha256,
pub body_hash: Sha256,
pub file: String,
pub site: String,
pub covered_region: CoveredRegion,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub linked: Option<ArtaId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<ParentLink>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindingState {
Local,
Bound { linked: ArtaId },
Certified {
linked: ArtaId,
verified_outcome: VerifiedOutcome,
last_verified_at_commit: CommitHash,
},
}
impl BindingState {
pub fn try_from_fields(
linked: Option<ArtaId>,
verified_outcome: Option<VerifiedOutcome>,
last_verified_at_commit: Option<CommitHash>,
) -> Result<Self, BindingFieldsError> {
match (linked, verified_outcome, last_verified_at_commit) {
(None, None, None) => Ok(Self::Local),
(Some(linked), None, None) => Ok(Self::Bound { linked }),
(Some(linked), Some(verified_outcome), Some(last_verified_at_commit)) => {
Ok(Self::Certified {
linked,
verified_outcome,
last_verified_at_commit,
})
}
(None, Some(_), _) | (None, _, Some(_)) => {
Err(BindingFieldsError::OutcomeOrCommitWithoutLinked)
}
(Some(_), Some(_), None) => Err(BindingFieldsError::OutcomeWithoutCommit),
(Some(_), None, Some(_)) => Err(BindingFieldsError::CommitWithoutOutcome),
}
}
pub fn is_bound(&self) -> bool {
!matches!(self, Self::Local)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BindingFieldsError {
#[error(
"`verified_outcome` or `last_verified_at_commit` requires `linked`; \
the outcome is meaningless without the server identity it was issued for"
)]
OutcomeOrCommitWithoutLinked,
#[error("`verified_outcome` is present but `last_verified_at_commit` is missing")]
OutcomeWithoutCommit,
#[error("`last_verified_at_commit` is present but `verified_outcome` is missing")]
CommitWithoutOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ParentLink {
Single(AnnotationId),
Multiple(Vec<AnnotationId>),
}
impl ParentLink {
pub fn iter(&self) -> Box<dyn Iterator<Item = &AnnotationId> + '_> {
match self {
Self::Single(id) => Box::new(std::iter::once(id)),
Self::Multiple(v) => Box::new(v.iter()),
}
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct IntentEntryWire {
text: String,
verify: VerifyLevel,
status: Status,
text_hash: Sha256,
body_hash: Sha256,
file: String,
site: String,
covered_region: CoveredRegion,
#[serde(default, skip_serializing_if = "Option::is_none")]
linked: Option<ArtaId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
verified_outcome: Option<VerifiedOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_verified_at_commit: Option<CommitHash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent: Option<ParentLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_critiqued_at_text_hash: Option<Sha256>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_critique_finding_count: Option<u32>,
}
impl Serialize for IntentEntry {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
IntentEntryWire::from(self.clone()).serialize(s)
}
}
impl<'de> Deserialize<'de> for IntentEntry {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let wire = IntentEntryWire::deserialize(d)?;
Self::try_from(wire).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for IntentEntry {
fn schema_name() -> String {
"IntentEntry".to_owned()
}
fn json_schema(generator: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
IntentEntryWire::json_schema(generator)
}
}
impl From<IntentEntry> for IntentEntryWire {
fn from(e: IntentEntry) -> Self {
let (linked, verified_outcome, last_verified_at_commit) = match e.binding {
BindingState::Local => (None, None, None),
BindingState::Bound { linked } => (Some(linked), None, None),
BindingState::Certified {
linked,
verified_outcome,
last_verified_at_commit,
} => (
Some(linked),
Some(verified_outcome),
Some(last_verified_at_commit),
),
};
Self {
text: e.text,
verify: e.verify,
status: e.status,
text_hash: e.text_hash,
body_hash: e.body_hash,
file: e.file,
site: e.site,
covered_region: e.covered_region,
linked,
verified_outcome,
last_verified_at_commit,
parent: e.parent,
last_critiqued_at_text_hash: e.last_critiqued_at_text_hash,
last_critique_finding_count: e.last_critique_finding_count,
}
}
}
impl TryFrom<IntentEntryWire> for IntentEntry {
type Error = BindingFieldsError;
fn try_from(w: IntentEntryWire) -> Result<Self, Self::Error> {
let binding =
BindingState::try_from_fields(w.linked, w.verified_outcome, w.last_verified_at_commit)?;
Ok(Self {
text: w.text,
verify: w.verify,
status: w.status,
text_hash: w.text_hash,
body_hash: w.body_hash,
file: w.file,
site: w.site,
covered_region: w.covered_region,
binding,
parent: w.parent,
last_critiqued_at_text_hash: w.last_critiqued_at_text_hash,
last_critique_finding_count: w.last_critique_finding_count,
})
}
}
#[cfg(test)]
mod tests {
use super::super::{AnnotationKind, VerifyMethod};
use super::*;
fn sha(byte: char) -> Sha256 {
Sha256::parse(&format!("sha256:{}", byte.to_string().repeat(64))).unwrap()
}
fn arta() -> ArtaId {
ArtaId::parse("arta_op4q3z9NbV").unwrap()
}
fn outcome() -> VerifiedOutcome {
VerifiedOutcome::parse(&format!("v1:{}", "A".repeat(86))).unwrap()
}
fn commit() -> CommitHash {
CommitHash::parse(&"a".repeat(40)).unwrap()
}
fn intent_local() -> IntentEntry {
IntentEntry {
text: "stub".into(),
verify: VerifyLevel::Method(VerifyMethod::Test),
status: Status::Tested,
text_hash: sha('a'),
body_hash: sha('b'),
file: "src/lib.rs".into(),
site: "fn foo".into(),
covered_region: CoveredRegion::Function,
binding: BindingState::Local,
parent: None,
last_critiqued_at_text_hash: None,
last_critique_finding_count: None,
}
}
fn assume_local() -> AssumeEntry {
AssumeEntry {
text: "external invariant".into(),
status: Status::Unknown,
text_hash: sha('a'),
body_hash: sha('b'),
file: "src/lib.rs".into(),
site: "fn foo".into(),
covered_region: CoveredRegion::Function,
linked: None,
parent: None,
}
}
#[test]
fn binding_local_round_trip() {
let b = BindingState::try_from_fields(None, None, None).unwrap();
assert_eq!(b, BindingState::Local);
assert!(!b.is_bound());
}
#[test]
fn binding_bound_round_trip() {
let b = BindingState::try_from_fields(Some(arta()), None, None).unwrap();
assert!(matches!(b, BindingState::Bound { .. }));
assert!(b.is_bound());
}
#[test]
fn binding_certified_round_trip() {
let b =
BindingState::try_from_fields(Some(arta()), Some(outcome()), Some(commit())).unwrap();
assert!(matches!(b, BindingState::Certified { .. }));
assert!(b.is_bound());
}
#[test]
fn binding_outcome_without_linked_rejected() {
assert_eq!(
BindingState::try_from_fields(None, Some(outcome()), Some(commit())),
Err(BindingFieldsError::OutcomeOrCommitWithoutLinked),
);
}
#[test]
fn binding_commit_without_linked_rejected() {
assert_eq!(
BindingState::try_from_fields(None, None, Some(commit())),
Err(BindingFieldsError::OutcomeOrCommitWithoutLinked),
);
}
#[test]
fn binding_outcome_without_commit_rejected() {
assert_eq!(
BindingState::try_from_fields(Some(arta()), Some(outcome()), None),
Err(BindingFieldsError::OutcomeWithoutCommit),
);
}
#[test]
fn binding_commit_without_outcome_rejected() {
assert_eq!(
BindingState::try_from_fields(Some(arta()), None, Some(commit())),
Err(BindingFieldsError::CommitWithoutOutcome),
);
}
#[test]
fn intent_local_round_trips_through_json() {
let e = intent_local();
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("linked"));
assert!(!json.contains("verified_outcome"));
let back: IntentEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
#[test]
fn intent_certified_round_trips_through_json() {
let mut e = intent_local();
e.binding = BindingState::Certified {
linked: arta(),
verified_outcome: outcome(),
last_verified_at_commit: commit(),
};
e.status = Status::Verified;
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("\"linked\""));
assert!(json.contains("\"verified_outcome\""));
assert!(json.contains("\"last_verified_at_commit\""));
let back: IntentEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
#[test]
fn intent_bound_round_trips_through_json() {
let mut e = intent_local();
e.binding = BindingState::Bound { linked: arta() };
e.status = Status::Unknown;
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("\"linked\""));
assert!(!json.contains("verified_outcome"));
let back: IntentEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
#[test]
fn intent_critique_cache_fields_absent_by_default() {
let e = intent_local();
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("last_critiqued_at_text_hash"));
assert!(!json.contains("last_critique_finding_count"));
let back: IntentEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.last_critiqued_at_text_hash, None);
assert_eq!(back.last_critique_finding_count, None);
}
#[test]
fn intent_critique_cache_fields_round_trip_when_populated() {
let mut e = intent_local();
e.last_critiqued_at_text_hash = Some(sha('c'));
e.last_critique_finding_count = Some(3);
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("\"last_critiqued_at_text_hash\""));
assert!(json.contains("\"last_critique_finding_count\""));
let back: IntentEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
#[test]
fn intent_deserialize_rejects_outcome_without_linked() {
let json = serde_json::json!({
"text": "x",
"verify": "test",
"status": "verified",
"text_hash": format!("sha256:{}", "a".repeat(64)),
"body_hash": format!("sha256:{}", "b".repeat(64)),
"file": "src/lib.rs",
"site": "fn foo",
"covered_region": "function",
"verified_outcome": format!("v1:{}", "A".repeat(86)),
"last_verified_at_commit": "a".repeat(40),
});
let result: Result<IntentEntry, _> = serde_json::from_value(json);
let err = result.unwrap_err().to_string();
assert!(err.contains("requires `linked`"), "got: {err}");
}
#[test]
fn intent_deserialize_rejects_outcome_without_commit() {
let json = serde_json::json!({
"text": "x",
"verify": "test",
"status": "verified",
"text_hash": format!("sha256:{}", "a".repeat(64)),
"body_hash": format!("sha256:{}", "b".repeat(64)),
"file": "src/lib.rs",
"site": "fn foo",
"covered_region": "function",
"linked": "arta_op4q3z9NbV",
"verified_outcome": format!("v1:{}", "A".repeat(86)),
});
let result: Result<IntentEntry, _> = serde_json::from_value(json);
let err = result.unwrap_err().to_string();
assert!(
err.contains("`last_verified_at_commit` is missing"),
"got: {err}"
);
}
#[test]
fn indexentry_dispatches_intent_by_kind_tag() {
let json = serde_json::json!({
"kind": "intent",
"text": "x",
"verify": "test",
"status": "tested",
"text_hash": format!("sha256:{}", "a".repeat(64)),
"body_hash": format!("sha256:{}", "b".repeat(64)),
"file": "src/lib.rs",
"site": "fn foo",
"covered_region": "function",
});
let entry: IndexEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, IndexEntry::Intent(_)));
}
#[test]
fn indexentry_dispatches_assume_by_kind_tag() {
let json = serde_json::json!({
"kind": "assume",
"text": "external",
"status": "unknown",
"text_hash": format!("sha256:{}", "a".repeat(64)),
"body_hash": format!("sha256:{}", "b".repeat(64)),
"file": "src/lib.rs",
"site": "fn foo",
"covered_region": "function",
});
let entry: IndexEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, IndexEntry::Assume(_)));
}
#[test]
fn assume_with_verify_field_rejected_by_serde() {
let json = serde_json::json!({
"kind": "assume",
"text": "external",
"verify": "test",
"status": "unknown",
"text_hash": format!("sha256:{}", "a".repeat(64)),
"body_hash": format!("sha256:{}", "b".repeat(64)),
"file": "src/lib.rs",
"site": "fn foo",
"covered_region": "function",
});
let result: Result<IndexEntry, _> = serde_json::from_value(json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("verify"), "got: {err}");
}
#[test]
fn assume_with_verified_outcome_rejected_by_serde() {
let json = serde_json::json!({
"kind": "assume",
"text": "external",
"status": "unknown",
"text_hash": format!("sha256:{}", "a".repeat(64)),
"body_hash": format!("sha256:{}", "b".repeat(64)),
"file": "src/lib.rs",
"site": "fn foo",
"covered_region": "function",
"verified_outcome": format!("v1:{}", "A".repeat(86)),
});
let result: Result<IndexEntry, _> = serde_json::from_value(json);
assert!(result.is_err());
}
#[test]
fn assume_can_be_server_bound() {
let mut a = assume_local();
a.linked = Some(arta());
let json = serde_json::to_string(&IndexEntry::Assume(a.clone())).unwrap();
let back: IndexEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, IndexEntry::Assume(a));
}
#[test]
fn parent_link_iter_handles_both_forms() {
let single = ParentLink::Single(AnnotationId::parse("a").unwrap());
assert_eq!(single.iter().count(), 1);
let many = ParentLink::Multiple(vec![
AnnotationId::parse("a").unwrap(),
AnnotationId::parse("b").unwrap(),
AnnotationId::parse("c").unwrap(),
]);
assert_eq!(many.iter().count(), 3);
}
#[test]
fn parent_link_serializes_singular_as_string() {
let single = ParentLink::Single(AnnotationId::parse("foo").unwrap());
assert_eq!(serde_json::to_string(&single).unwrap(), "\"foo\"");
}
#[test]
fn parent_link_serializes_multiple_as_array() {
let many = ParentLink::Multiple(vec![
AnnotationId::parse("a").unwrap(),
AnnotationId::parse("b").unwrap(),
]);
assert_eq!(serde_json::to_string(&many).unwrap(), "[\"a\",\"b\"]");
}
#[test]
fn kind_discriminator_matches_annotation_kind_serialization() {
assert_eq!(
serde_json::to_value(AnnotationKind::Intent).unwrap(),
serde_json::json!("intent")
);
assert_eq!(
serde_json::to_value(AnnotationKind::Assume).unwrap(),
serde_json::json!("assume")
);
}
}