use crate::minhash::{self, MinHash};
use crate::simhash::{self, Fingerprint, Hash256};
use crate::soft_binding::{SoftBinding, ALG_FINGERPRINT, ALG_MINHASH, ALG_STRUCTURE};
use crate::structure;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub fn crosscheck_tag(key: &[u8], repo_id: &[u8], content_hash: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(repo_id);
mac.update(content_hash);
mac.finalize().into_bytes().into()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Confidence {
Bound,
Likely,
Review,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Evidence {
pub watermark_verified: bool,
pub durable_fingerprint_match: bool,
pub structural_match: bool,
pub crosscheck_ok: bool,
}
pub fn classify(ev: &Evidence) -> Confidence {
if ev.durable_fingerprint_match && ev.crosscheck_ok {
Confidence::Bound
} else if ev.watermark_verified || ev.durable_fingerprint_match || ev.structural_match {
Confidence::Likely
} else {
Confidence::Review
}
}
pub fn fingerprint_evidence(text: &str, candidate: &SoftBinding) -> Evidence {
let mut ev = Evidence::default();
match candidate.alg.as_str() {
ALG_FINGERPRINT => {
let current = Fingerprint::compute(text).whole;
ev.durable_fingerprint_match = candidate
.blocks
.iter()
.filter_map(|b| Hash256::from_hex(&b.value))
.any(|stored| current.hamming(&stored) <= simhash::MATCH_THRESHOLD);
}
ALG_MINHASH => {
if let Some(stored) = candidate
.blocks
.first()
.and_then(|b| parse_minhash(&b.value))
{
let current = MinHash::compute(text);
ev.durable_fingerprint_match = current.jaccard(&stored) >= minhash::MATCH_JACCARD
|| current.shares_band(&stored);
}
}
ALG_STRUCTURE => {
if let Some(stored) = candidate
.blocks
.first()
.and_then(|b| Hash256::from_hex(&b.value))
{
ev.structural_match = structure::matches(&structure::compute(text), &stored);
}
}
_ => {}
}
ev
}
pub fn verify(
text: &str,
candidate: &SoftBinding,
watermark_verified: bool,
crosscheck_ok: bool,
) -> Confidence {
let mut ev = fingerprint_evidence(text, candidate);
ev.watermark_verified = watermark_verified;
ev.crosscheck_ok = crosscheck_ok;
classify(&ev)
}
fn parse_minhash(value_hex: &str) -> Option<MinHash> {
let bytes = hex::decode(value_hex).ok()?;
if bytes.len() != minhash::NUM_PERM * 8 {
return None;
}
let mut sig = [0u64; minhash::NUM_PERM];
for (word, chunk) in sig.iter_mut().zip(bytes.chunks_exact(8)) {
*word = u64::from_be_bytes(chunk.try_into().expect("chunk is 8 bytes"));
}
Some(MinHash::from_signature(sig))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crosscheck_is_deterministic_and_binds_inputs() {
let a = crosscheck_tag(b"key", b"repo-1", b"hashA");
assert_eq!(a, crosscheck_tag(b"key", b"repo-1", b"hashA"));
assert_ne!(a, crosscheck_tag(b"key", b"repo-2", b"hashA"));
assert_ne!(a, crosscheck_tag(b"key", b"repo-1", b"hashB"));
}
#[test]
fn bound_requires_durable_fingerprint_and_crosscheck() {
let ev = Evidence {
watermark_verified: true,
durable_fingerprint_match: true,
structural_match: true,
crosscheck_ok: true,
};
assert_eq!(classify(&ev), Confidence::Bound);
}
#[test]
fn watermark_alone_is_only_likely() {
let ev = Evidence {
watermark_verified: true,
crosscheck_ok: false,
..Default::default()
};
assert_eq!(
classify(&ev),
Confidence::Likely,
"a watermark hit alone must never reach BOUND"
);
}
#[test]
fn durable_fingerprint_without_crosscheck_is_likely() {
let ev = Evidence {
durable_fingerprint_match: true,
crosscheck_ok: false,
..Default::default()
};
assert_eq!(classify(&ev), Confidence::Likely);
}
#[test]
fn structural_match_never_reaches_bound() {
let ev = Evidence {
structural_match: true,
crosscheck_ok: true,
..Default::default()
};
assert_eq!(classify(&ev), Confidence::Likely);
}
#[test]
fn no_evidence_is_review() {
assert_eq!(classify(&Evidence::default()), Confidence::Review);
}
#[test]
fn verify_recomputes_durable_fingerprint_from_text() {
use crate::soft_binding;
let text = "The principles of provenance require that a document's origin can be \
recovered even after it has been copied, reformatted, or lightly edited, so a \
manifest can be found again when the embedded one is stripped away.";
let sb = soft_binding::from_fingerprint(&Fingerprint::compute(text));
assert_eq!(verify(text, &sb, false, true), Confidence::Bound);
assert_eq!(verify(text, &sb, false, false), Confidence::Likely);
let unrelated = "Chocolate chip cookies need flour, butter, sugar, eggs, and vanilla \
before the oven ever reaches three hundred and fifty degrees for the bake.";
assert_eq!(verify(unrelated, &sb, false, true), Confidence::Review);
}
#[test]
fn verify_structural_candidate_caps_at_likely() {
use crate::soft_binding;
let text = "Provenance must survive editing. A soft binding derives a durable value \
from the words themselves. When the embedded manifest is stripped, a resolver \
recomputes the value and finds the manifest again.";
let sb = soft_binding::from_structure(&structure::compute(text));
assert_eq!(verify(text, &sb, false, true), Confidence::Likely);
}
}