entrouter_universal/
lib.rs1use base64::{engine::general_purpose::STANDARD, Engine};
39use sha2::{Digest, Sha256};
40use thiserror::Error;
41
42pub mod chain;
43pub mod envelope;
44pub mod guardian;
45pub mod signed_envelope;
46pub mod universal_struct;
47pub mod verify;
48
49#[cfg(feature = "compression")]
50pub mod compress;
51
52pub use chain::Chain;
53pub use chain::ChainDiff;
54pub use envelope::Envelope;
55pub use guardian::Guardian;
56pub use signed_envelope::SignedEnvelope;
57pub use universal_struct::UniversalStruct;
58pub use verify::VerifyResult;
59
60#[derive(Debug, Clone, PartialEq, Error)]
64#[non_exhaustive]
65pub enum UniversalError {
66 #[error("Integrity violation: data was mutated in transit. Expected {expected}, got {actual}")]
67 IntegrityViolation { expected: String, actual: String },
68
69 #[error("Decode error: {0}")]
70 DecodeError(String),
71
72 #[error("Envelope malformed: {0}")]
73 MalformedEnvelope(String),
74
75 #[error("Expired: envelope expired at {expired_at}, current time {now}")]
76 Expired { expired_at: u64, now: u64 },
77
78 #[error("Compress error: {0}")]
79 CompressError(String),
80
81 #[error("Serialization error: {0}")]
82 SerializationError(String),
83
84 #[error("Chain merge conflict: chains diverge at link {diverges_at}")]
85 ChainMergeConflict { diverges_at: usize },
86}
87
88#[must_use]
97pub fn encode(input: &[u8]) -> String {
98 STANDARD.encode(input)
99}
100
101pub fn decode(input: &str) -> Result<Vec<u8>, UniversalError> {
103 STANDARD
104 .decode(input)
105 .map_err(|e| UniversalError::DecodeError(e.to_string()))
106}
107
108#[must_use]
115pub fn encode_str(input: &str) -> String {
116 encode(input.as_bytes())
117}
118
119pub fn decode_str(input: &str) -> Result<String, UniversalError> {
121 let bytes = decode(input)?;
122 String::from_utf8(bytes).map_err(|e| UniversalError::DecodeError(e.to_string()))
123}
124
125#[must_use]
132pub fn fingerprint(input: &[u8]) -> String {
133 let mut hasher = Sha256::new();
134 hasher.update(input);
135 hex::encode(hasher.finalize())
136}
137
138#[must_use]
140pub fn fingerprint_str(input: &str) -> String {
141 fingerprint(input.as_bytes())
142}
143
144pub fn verify(encoded: &str, original_fingerprint: &str) -> Result<VerifyResult, UniversalError> {
149 let decoded = decode(encoded)?;
150 let actual_fingerprint = fingerprint(&decoded);
151 if actual_fingerprint == original_fingerprint {
152 Ok(VerifyResult {
153 intact: true,
154 decoded,
155 fingerprint: actual_fingerprint,
156 })
157 } else {
158 Err(UniversalError::IntegrityViolation {
159 expected: original_fingerprint.to_string(),
160 actual: actual_fingerprint,
161 })
162 }
163}
164
165#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::thread::sleep;
173 use std::time::Duration;
174
175 #[test]
178 fn round_trip_special_chars() {
179 let original = r#"hello "world" it's \fine\ with 日本語 and 🔥"#;
180 assert_eq!(original, decode_str(&encode_str(original)).unwrap());
181 }
182
183 #[test]
186 fn envelope_standard() {
187 let data = r#"{"token":"abc\"def","user":"john's"}"#;
188 let env = Envelope::wrap(data);
189 assert_eq!(data, env.unwrap_verified().unwrap());
190 }
191
192 #[test]
193 fn envelope_url_safe() {
194 let data = "race_token: abc\"123\"\nspecial chars & stuff";
195 let env = Envelope::wrap_url_safe(data);
196 assert!(env
198 .d
199 .chars()
200 .all(|c| c.is_alphanumeric() || c == '-' || c == '_'));
201 assert_eq!(data, env.unwrap_verified().unwrap());
202 }
203
204 #[cfg(feature = "compression")]
205 #[test]
206 fn envelope_compressed() {
207 let data = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".repeat(100);
209 let env = Envelope::wrap_compressed(&data).unwrap();
210 assert!(env.d.len() < data.len());
212 assert_eq!(data, env.unwrap_verified().unwrap());
213 }
214
215 #[test]
216 fn envelope_ttl_valid() {
217 let env = Envelope::wrap_with_ttl("fresh data", 60);
218 assert!(!env.is_expired());
219 assert_eq!("fresh data", env.unwrap_verified().unwrap());
220 }
221
222 #[test]
223 fn envelope_ttl_expired() {
224 let env = Envelope::wrap_with_ttl("stale data", 0);
225 sleep(Duration::from_millis(10));
226 assert!(env.is_expired());
227 assert!(env.unwrap_verified().is_err());
228 }
229
230 #[test]
231 fn envelope_detects_mutation() {
232 let env = Envelope::wrap("original");
233 let mut json = env.to_json().unwrap();
234 let idx = json.find('"').unwrap() + 5;
236 json.replace_range(idx..idx + 1, "X");
237 let tampered = Envelope::from_json(&json);
238 let result = tampered.and_then(|e| e.unwrap_verified());
239 assert!(result.is_err());
240 }
241
242 #[test]
245 fn chain_builds_and_verifies() {
246 let mut chain = Chain::new("genesis: race started");
247 chain.append("link 2: user_a joined");
248 chain.append("link 3: user_b joined");
249 chain.append("link 4: winner = user_a");
250
251 let result = chain.verify();
252 assert!(result.valid);
253 assert_eq!(result.total_links, 4);
254 }
255
256 #[test]
257 fn chain_detects_tampering() {
258 let mut chain = Chain::new("genesis");
259 chain.append("link 2");
260 chain.append("link 3");
261
262 let mut tampered = chain.clone();
264 tampered.links[1].d = encode_str("TAMPERED");
265
266 let result = tampered.verify();
267 assert!(!result.valid);
268 assert_eq!(result.broken_at, Some(2));
269 }
270
271 #[test]
272 fn chain_serialises_round_trip() {
273 let mut chain = Chain::new("start");
274 chain.append("middle");
275 chain.append("end");
276
277 let json = chain.to_json().unwrap();
278 let restored = Chain::from_json(&json).unwrap();
279 assert!(restored.verify().valid);
280 }
281
282 #[test]
285 fn struct_wraps_all_fields() {
286 let wrapped = UniversalStruct::wrap_fields(&[
287 ("token", "000001739850123456-abc\"def"),
288 ("user_id", "john's account"),
289 ("amount", "99.99"),
290 ]);
291
292 let result = wrapped.verify_all();
293 assert!(result.all_intact);
294 assert_eq!(wrapped.get("token").unwrap(), "000001739850123456-abc\"def");
295 assert_eq!(wrapped.get("user_id").unwrap(), "john's account");
296 assert_eq!(wrapped.get("amount").unwrap(), "99.99");
297 }
298
299 #[test]
300 fn struct_detects_field_mutation() {
301 let mut wrapped = UniversalStruct::wrap_fields(&[
302 ("token", "abc123"),
303 ("user_id", "john"),
304 ("amount", "99.99"),
305 ]);
306
307 wrapped.fields[2].d = encode_str("999999.99");
309
310 let result = wrapped.verify_all();
311 assert!(!result.all_intact);
312 assert!(result.violations.contains(&"amount".to_string()));
313 assert!(result.fields[0].intact);
315 assert!(result.fields[1].intact);
316 assert!(!result.fields[2].intact);
317 }
318
319 #[test]
320 fn struct_to_map() {
321 let wrapped = UniversalStruct::wrap_fields(&[("a", "hello"), ("b", "world")]);
322 let map = wrapped.to_map().unwrap();
323 assert_eq!(map["a"], "hello");
324 assert_eq!(map["b"], "world");
325 }
326
327 #[test]
328 fn struct_serialises_round_trip() {
329 let wrapped =
330 UniversalStruct::wrap_fields(&[("token", r#"abc"def\ghi"#), ("user", "john")]);
331 let json = wrapped.to_json().unwrap();
332 let restored = UniversalStruct::from_json(&json).unwrap();
333 restored.assert_intact();
334 assert_eq!(restored.get("token").unwrap(), r#"abc"def\ghi"#);
335 }
336
337 #[test]
340 fn guardian_clean_pipeline() {
341 let mut g = Guardian::new("clean data 🔥");
342 let encoded = g.encoded().to_string();
343 g.checkpoint("http", &encoded);
344 g.checkpoint("redis", &encoded);
345 g.checkpoint("postgres", &encoded);
346 g.assert_intact();
347 }
348
349 #[test]
350 fn guardian_finds_violation() {
351 let mut g = Guardian::new("original");
352 let clean = g.encoded().to_string();
353 g.checkpoint("http", &clean);
354 g.checkpoint("redis", &encode_str("mangled"));
355 assert_eq!(g.first_violation().unwrap().layer, "redis");
356 }
357}