shadowforge_lib/domain/canary/
mod.rs1use sha2::{Digest, Sha256};
8use uuid::Uuid;
9
10use crate::domain::types::{CanaryShard, Shard};
11
12pub fn generate_canary_shard(
20 canary_id: Uuid,
21 shard_size: usize,
22 total_shards: u8,
23 notify_url: Option<String>,
24) -> CanaryShard {
25 let marker = Sha256::digest(canary_id.as_bytes());
27 let mut data = Vec::with_capacity(shard_size);
28 while data.len() < shard_size {
29 let remaining = shard_size.saturating_sub(data.len());
30 let chunk = remaining.min(marker.len());
31 #[expect(clippy::indexing_slicing, reason = "chunk bounded by marker.len()")]
33 data.extend_from_slice(&marker[..chunk]);
34 }
35
36 let canary_index = total_shards;
39
40 let mut hmac_input = Vec::new();
43 hmac_input.extend_from_slice(canary_id.as_bytes());
44 hmac_input.push(canary_index);
45 hmac_input.push(total_shards);
46 hmac_input.extend_from_slice(&data);
47 let hmac_tag: [u8; 32] = Sha256::digest(&hmac_input).into();
48
49 let shard = Shard {
50 index: canary_index,
51 total: total_shards,
52 data,
53 hmac_tag,
54 };
55
56 CanaryShard {
57 shard,
58 canary_id,
59 notify_url,
60 }
61}
62
63pub fn is_canary_shard(shard: &Shard, canary_id: Uuid) -> bool {
68 use subtle::ConstantTimeEq;
69
70 let marker = Sha256::digest(canary_id.as_bytes());
71 if shard.data.len() < marker.len() {
72 return false;
73 }
74 shard
76 .data
77 .get(..marker.len())
78 .is_some_and(|prefix| prefix.ct_eq(&marker).into())
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn canary_shard_has_correct_size() {
87 let canary_id = Uuid::new_v4();
88 let shard_size = 128;
89 let total = 5;
90
91 let canary = generate_canary_shard(canary_id, shard_size, total, None);
92
93 assert_eq!(canary.shard.data.len(), shard_size);
94 assert_eq!(canary.canary_id, canary_id);
95 assert!(canary.notify_url.is_none());
96 }
97
98 #[test]
99 fn canary_shard_index_is_beyond_valid_range() {
100 let canary_id = Uuid::new_v4();
101 let total = 5;
102
103 let canary = generate_canary_shard(canary_id, 64, total, None);
104
105 assert_eq!(canary.shard.index, total);
107 }
108
109 #[test]
110 fn canary_shard_data_contains_marker() {
111 let canary_id = Uuid::new_v4();
112 let canary = generate_canary_shard(canary_id, 64, 5, None);
113
114 assert!(is_canary_shard(&canary.shard, canary_id));
115 }
116
117 #[test]
118 fn canary_shard_not_detected_with_wrong_id() {
119 let canary_id = Uuid::new_v4();
120 let wrong_id = Uuid::new_v4();
121 let canary = generate_canary_shard(canary_id, 64, 5, None);
122
123 assert!(!is_canary_shard(&canary.shard, wrong_id));
124 }
125
126 #[test]
127 fn canary_shard_preserves_notify_url() {
128 let canary_id = Uuid::new_v4();
129 let url = "https://example.com/canary/abc123".to_owned();
130
131 let canary = generate_canary_shard(canary_id, 64, 5, Some(url.clone()));
132
133 assert_eq!(canary.notify_url.as_deref(), Some(url.as_str()));
134 }
135
136 #[test]
137 fn normal_shard_not_detected_as_canary() {
138 let canary_id = Uuid::new_v4();
139 let normal = Shard {
140 index: 0,
141 total: 5,
142 data: vec![0u8; 64],
143 hmac_tag: [0u8; 32],
144 };
145
146 assert!(!is_canary_shard(&normal, canary_id));
147 }
148
149 #[test]
150 fn different_canary_ids_produce_different_shards() {
151 let id1 = Uuid::new_v4();
152 let id2 = Uuid::new_v4();
153
154 let c1 = generate_canary_shard(id1, 64, 5, None);
155 let c2 = generate_canary_shard(id2, 64, 5, None);
156
157 assert_ne!(c1.shard.data, c2.shard.data);
158 assert_ne!(c1.shard.hmac_tag, c2.shard.hmac_tag);
159 }
160}