use serde::{Deserialize, Serialize};
use crate::canonical_hash::sha256_json_prefixed;
use crate::error::Result;
use crate::model::{ObjectId, RevisionRefV1, TrackId};
pub const REVISION_RESOURCE_SCHEMA: &str = "pointbreak.review-revision-resource";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentAvailabilityV1 {
Available,
Removed,
Missing,
Mismatch,
NonTextual,
}
pub type RevisionResourceAvailabilityV1 = ContentAvailabilityV1;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevisionResourceRefV1 {
pub revision: RevisionRefV1,
pub object_id: ObjectId,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RevisionResourceProjectionV1 {
#[serde(skip_serializing_if = "Option::is_none")]
pub track_id: Option<TrackId>,
pub include_body: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevisionResourceDocumentV1 {
pub schema: String,
pub version: u32,
pub resource: RevisionResourceRefV1,
pub projection: RevisionResourceProjectionV1,
pub availability: ContentAvailabilityV1,
#[serde(skip_serializing_if = "Option::is_none")]
pub captured_document_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captured_document: Option<serde_json::Value>,
pub diagnostics: Vec<String>,
pub cache_key: String,
}
impl RevisionResourceDocumentV1 {
pub fn available(
resource: RevisionResourceRefV1,
projection: RevisionResourceProjectionV1,
actual_artifact_hash: &str,
captured_document: serde_json::Value,
) -> Result<Self> {
let matches = resource.revision.object_artifact_content_hash == actual_artifact_hash;
let availability = if matches {
ContentAvailabilityV1::Available
} else {
ContentAvailabilityV1::Mismatch
};
let captured_document_hash = matches
.then(|| sha256_json_prefixed(&captured_document))
.transpose()?;
let cache_key = exact_cache_key(
REVISION_RESOURCE_SCHEMA,
1,
&serde_json::json!({
"resource": resource,
"projection": projection,
"availability": availability,
"capturedDocumentHash": captured_document_hash,
}),
)?;
Ok(Self {
schema: REVISION_RESOURCE_SCHEMA.to_owned(),
version: 1,
resource,
projection,
availability,
captured_document_hash,
captured_document: matches.then_some(captured_document),
diagnostics: (!matches)
.then_some("captured_artifact_hash_mismatch".to_owned())
.into_iter()
.collect(),
cache_key,
})
}
pub fn unavailable(
resource: RevisionResourceRefV1,
projection: RevisionResourceProjectionV1,
availability: ContentAvailabilityV1,
) -> Result<Self> {
if availability == ContentAvailabilityV1::Available {
return Err(crate::error::ShoreError::Message(
"available captured resources require the captured document and verified hash"
.to_owned(),
));
}
let cache_key = exact_cache_key(
REVISION_RESOURCE_SCHEMA,
1,
&serde_json::json!({
"resource": resource,
"projection": projection,
"availability": availability,
}),
)?;
Ok(Self {
schema: REVISION_RESOURCE_SCHEMA.to_owned(),
version: 1,
resource,
projection,
availability,
captured_document_hash: None,
captured_document: None,
diagnostics: vec![format!("captured_resource_{availability:?}").to_lowercase()],
cache_key,
})
}
}
pub(crate) fn exact_cache_key<T: Serialize>(
schema: &str,
version: u32,
identity: &T,
) -> Result<String> {
sha256_json_prefixed(&serde_json::json!({
"schema": schema,
"version": version,
"identity": identity,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::RevisionId;
fn reference(byte: char) -> RevisionRefV1 {
RevisionRefV1::new(
RevisionId::new("rev:sha256:resource"),
format!("sha256:{}", byte.to_string().repeat(64)),
)
.unwrap()
}
fn projection() -> RevisionResourceProjectionV1 {
RevisionResourceProjectionV1 {
track_id: None,
include_body: true,
}
}
#[test]
fn hash_mismatch_is_typed_and_never_returns_captured_bytes() {
let resource = RevisionResourceRefV1 {
revision: reference('a'),
object_id: ObjectId::new("obj:sha256:resource"),
};
let document = RevisionResourceDocumentV1::available(
resource,
projection(),
&format!("sha256:{}", "b".repeat(64)),
serde_json::json!({"rows": ["must-not-leak"]}),
)
.unwrap();
assert_eq!(
document.availability,
RevisionResourceAvailabilityV1::Mismatch
);
assert!(document.captured_document.is_none());
}
#[test]
fn every_unavailable_state_is_typed_and_bodyless() {
for availability in [
RevisionResourceAvailabilityV1::Removed,
RevisionResourceAvailabilityV1::Missing,
RevisionResourceAvailabilityV1::Mismatch,
RevisionResourceAvailabilityV1::NonTextual,
] {
let document = RevisionResourceDocumentV1::unavailable(
RevisionResourceRefV1 {
revision: reference('a'),
object_id: ObjectId::new("obj:sha256:resource"),
},
projection(),
availability,
)
.unwrap();
assert_eq!(document.availability, availability);
assert!(document.captured_document.is_none());
}
}
#[test]
fn cache_key_binds_projection_availability_and_exact_document_bytes() {
let resource = RevisionResourceRefV1 {
revision: reference('a'),
object_id: ObjectId::new("obj:sha256:resource"),
};
let available = RevisionResourceDocumentV1::available(
resource.clone(),
projection(),
&resource.revision.object_artifact_content_hash,
serde_json::json!({"rows": ["one"]}),
)
.unwrap();
let mut filtered = projection();
filtered.track_id = Some(crate::model::TrackId::new("agent:author"));
let filtered = RevisionResourceDocumentV1::available(
resource.clone(),
filtered,
&resource.revision.object_artifact_content_hash,
serde_json::json!({"rows": ["one"]}),
)
.unwrap();
let changed_body = RevisionResourceDocumentV1::available(
resource.clone(),
projection(),
&resource.revision.object_artifact_content_hash,
serde_json::json!({"rows": ["two"]}),
)
.unwrap();
let missing = RevisionResourceDocumentV1::unavailable(
resource,
projection(),
ContentAvailabilityV1::Missing,
)
.unwrap();
assert_ne!(available.cache_key, filtered.cache_key);
assert_ne!(available.cache_key, changed_body.cache_key);
assert_ne!(available.cache_key, missing.cache_key);
}
}