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