use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::domain::types::{CanaryShard, Shard};
#[must_use]
pub fn generate_canary_shard(
canary_id: Uuid,
shard_size: usize,
total_shards: u8,
notify_url: Option<String>,
) -> CanaryShard {
let marker = Sha256::digest(canary_id.as_bytes());
let mut data = Vec::with_capacity(shard_size);
while data.len() < shard_size {
let remaining = shard_size.saturating_sub(data.len());
let chunk = remaining.min(marker.len());
#[expect(clippy::indexing_slicing, reason = "chunk bounded by marker.len()")]
data.extend_from_slice(&marker[..chunk]);
}
let canary_index = total_shards;
let mut hmac_input = Vec::new();
hmac_input.extend_from_slice(canary_id.as_bytes());
hmac_input.push(canary_index);
hmac_input.push(total_shards);
hmac_input.extend_from_slice(&data);
let hmac_tag: [u8; 32] = Sha256::digest(&hmac_input).into();
let shard = Shard {
index: canary_index,
total: total_shards,
data,
hmac_tag,
};
CanaryShard {
shard,
canary_id,
notify_url,
}
}
#[must_use]
pub fn is_canary_shard(shard: &Shard, canary_id: Uuid) -> bool {
use subtle::ConstantTimeEq;
let marker = Sha256::digest(canary_id.as_bytes());
if shard.data.len() < marker.len() {
return false;
}
shard
.data
.get(..marker.len())
.is_some_and(|prefix| prefix.ct_eq(&marker).into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canary_shard_has_correct_size() {
let canary_id = Uuid::new_v4();
let shard_size = 128;
let total = 5;
let canary = generate_canary_shard(canary_id, shard_size, total, None);
assert_eq!(canary.shard.data.len(), shard_size);
assert_eq!(canary.canary_id, canary_id);
assert!(canary.notify_url.is_none());
}
#[test]
fn canary_shard_index_is_beyond_valid_range() {
let canary_id = Uuid::new_v4();
let total = 5;
let canary = generate_canary_shard(canary_id, 64, total, None);
assert_eq!(canary.shard.index, total);
}
#[test]
fn canary_shard_data_contains_marker() {
let canary_id = Uuid::new_v4();
let canary = generate_canary_shard(canary_id, 64, 5, None);
assert!(is_canary_shard(&canary.shard, canary_id));
}
#[test]
fn canary_shard_not_detected_with_wrong_id() {
let canary_id = Uuid::new_v4();
let wrong_id = Uuid::new_v4();
let canary = generate_canary_shard(canary_id, 64, 5, None);
assert!(!is_canary_shard(&canary.shard, wrong_id));
}
#[test]
fn canary_shard_preserves_notify_url() {
let canary_id = Uuid::new_v4();
let url = "https://example.com/canary/abc123".to_owned();
let canary = generate_canary_shard(canary_id, 64, 5, Some(url.clone()));
assert_eq!(canary.notify_url.as_deref(), Some(url.as_str()));
}
#[test]
fn normal_shard_not_detected_as_canary() {
let canary_id = Uuid::new_v4();
let normal = Shard {
index: 0,
total: 5,
data: vec![0u8; 64],
hmac_tag: [0u8; 32],
};
assert!(!is_canary_shard(&normal, canary_id));
}
#[test]
fn different_canary_ids_produce_different_shards() {
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let c1 = generate_canary_shard(id1, 64, 5, None);
let c2 = generate_canary_shard(id2, 64, 5, None);
assert_ne!(c1.shard.data, c2.shard.data);
assert_ne!(c1.shard.hmac_tag, c2.shard.hmac_tag);
}
}