Skip to main content

entrouter_universal/
lib.rs

1//! # Entrouter Universal
2//!
3//! Pipeline integrity guardian. What goes in, comes out identical.
4//!
5//! This crate provides Base64 encoding, SHA-256 fingerprinting, and integrity
6//! verification primitives that can be composed into higher-level constructs:
7//!
8//! - [`Envelope`] -- wrap data in one of four modes (standard, URL-safe, compressed, TTL)
9//! - [`Guardian`] -- track data through a multi-layer pipeline and detect where mutations occur
10//! - [`Chain`] -- build a cryptographic audit trail where each link references the previous
11//! - [`UniversalStruct`] -- wrap individual struct fields so you know *which* field was tampered with
12//!
13//! # Quick start
14//!
15//! ```rust
16//! use entrouter_universal::{encode_str, decode_str, fingerprint_str, verify};
17//!
18//! let encoded = encode_str("hello world");
19//! let fp = fingerprint_str("hello world");
20//! let result = verify(&encoded, &fp).unwrap();
21//! assert!(result.intact);
22//! ```
23
24use base64::{engine::general_purpose::STANDARD, Engine};
25use sha2::{Digest, Sha256};
26use thiserror::Error;
27
28pub mod chain;
29pub mod envelope;
30pub mod guardian;
31pub mod signed_envelope;
32pub mod universal_struct;
33pub mod verify;
34
35#[cfg(feature = "compression")]
36pub mod compress;
37
38pub use chain::Chain;
39pub use chain::ChainDiff;
40pub use envelope::Envelope;
41pub use guardian::Guardian;
42pub use signed_envelope::SignedEnvelope;
43pub use universal_struct::UniversalStruct;
44pub use verify::VerifyResult;
45
46// ── Errors ────────────────────────────────────────────────
47
48/// Errors returned by Entrouter Universal operations.
49#[derive(Debug, Clone, PartialEq, Error)]
50#[non_exhaustive]
51pub enum UniversalError {
52    #[error("Integrity violation: data was mutated in transit. Expected {expected}, got {actual}")]
53    IntegrityViolation { expected: String, actual: String },
54
55    #[error("Decode error: {0}")]
56    DecodeError(String),
57
58    #[error("Envelope malformed: {0}")]
59    MalformedEnvelope(String),
60
61    #[error("Expired: envelope expired at {expired_at}, current time {now}")]
62    Expired { expired_at: u64, now: u64 },
63
64    #[error("Compress error: {0}")]
65    CompressError(String),
66
67    #[error("Serialization error: {0}")]
68    SerializationError(String),
69
70    #[error("Chain merge conflict: chains diverge at link {diverges_at}")]
71    ChainMergeConflict { diverges_at: usize },
72}
73
74// ── Core primitives ───────────────────────────────────────
75
76/// Base64-encode raw bytes.
77///
78/// ```
79/// let b64 = entrouter_universal::encode(b"hello");
80/// assert_eq!(b64, "aGVsbG8=");
81/// ```
82#[must_use]
83pub fn encode(input: &[u8]) -> String {
84    STANDARD.encode(input)
85}
86
87/// Decode a Base64 string back to raw bytes.
88pub fn decode(input: &str) -> Result<Vec<u8>, UniversalError> {
89    STANDARD
90        .decode(input)
91        .map_err(|e| UniversalError::DecodeError(e.to_string()))
92}
93
94/// Base64-encode a UTF-8 string.
95///
96/// ```
97/// let b64 = entrouter_universal::encode_str("hello");
98/// assert_eq!(entrouter_universal::decode_str(&b64).unwrap(), "hello");
99/// ```
100#[must_use]
101pub fn encode_str(input: &str) -> String {
102    encode(input.as_bytes())
103}
104
105/// Decode a Base64 string back to a UTF-8 [`String`].
106pub fn decode_str(input: &str) -> Result<String, UniversalError> {
107    let bytes = decode(input)?;
108    String::from_utf8(bytes).map_err(|e| UniversalError::DecodeError(e.to_string()))
109}
110
111/// Compute a SHA-256 fingerprint of raw bytes, returned as a hex string.
112///
113/// ```
114/// let fp = entrouter_universal::fingerprint(b"hello");
115/// assert_eq!(fp.len(), 64); // 256-bit hex
116/// ```
117#[must_use]
118pub fn fingerprint(input: &[u8]) -> String {
119    let mut hasher = Sha256::new();
120    hasher.update(input);
121    hex::encode(hasher.finalize())
122}
123
124/// Compute a SHA-256 fingerprint of a UTF-8 string.
125#[must_use]
126pub fn fingerprint_str(input: &str) -> String {
127    fingerprint(input.as_bytes())
128}
129
130/// Decode `encoded` and verify its fingerprint matches `original_fingerprint`.
131///
132/// Returns [`VerifyResult`] on success, or [`UniversalError::IntegrityViolation`]
133/// if the data was mutated.
134pub fn verify(encoded: &str, original_fingerprint: &str) -> Result<VerifyResult, UniversalError> {
135    let decoded = decode(encoded)?;
136    let actual_fingerprint = fingerprint(&decoded);
137    if actual_fingerprint == original_fingerprint {
138        Ok(VerifyResult {
139            intact: true,
140            decoded,
141            fingerprint: actual_fingerprint,
142        })
143    } else {
144        Err(UniversalError::IntegrityViolation {
145            expected: original_fingerprint.to_string(),
146            actual: actual_fingerprint,
147        })
148    }
149}
150
151// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
152//  Tests
153// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::thread::sleep;
159    use std::time::Duration;
160
161    // ── Core ─────────────────────────────────────────────
162
163    #[test]
164    fn round_trip_special_chars() {
165        let original = r#"hello "world" it's \fine\ with 日本語 and 🔥"#;
166        assert_eq!(original, decode_str(&encode_str(original)).unwrap());
167    }
168
169    // ── Envelope modes ────────────────────────────────────
170
171    #[test]
172    fn envelope_standard() {
173        let data = r#"{"token":"abc\"def","user":"john's"}"#;
174        let env = Envelope::wrap(data);
175        assert_eq!(data, env.unwrap_verified().unwrap());
176    }
177
178    #[test]
179    fn envelope_url_safe() {
180        let data = "race_token: abc\"123\"\nspecial chars & stuff";
181        let env = Envelope::wrap_url_safe(data);
182        // URL safe chars only
183        assert!(env
184            .d
185            .chars()
186            .all(|c| c.is_alphanumeric() || c == '-' || c == '_'));
187        assert_eq!(data, env.unwrap_verified().unwrap());
188    }
189
190    #[cfg(feature = "compression")]
191    #[test]
192    fn envelope_compressed() {
193        // Repeated data compresses well
194        let data = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".repeat(100);
195        let env = Envelope::wrap_compressed(&data).unwrap();
196        // Compressed + Base64 should be smaller than raw
197        assert!(env.d.len() < data.len());
198        assert_eq!(data, env.unwrap_verified().unwrap());
199    }
200
201    #[test]
202    fn envelope_ttl_valid() {
203        let env = Envelope::wrap_with_ttl("fresh data", 60);
204        assert!(!env.is_expired());
205        assert_eq!("fresh data", env.unwrap_verified().unwrap());
206    }
207
208    #[test]
209    fn envelope_ttl_expired() {
210        let env = Envelope::wrap_with_ttl("stale data", 0);
211        sleep(Duration::from_millis(10));
212        assert!(env.is_expired());
213        assert!(env.unwrap_verified().is_err());
214    }
215
216    #[test]
217    fn envelope_detects_mutation() {
218        let env = Envelope::wrap("original");
219        let mut json = env.to_json().unwrap();
220        // Flip a character in the encoded data
221        let idx = json.find('"').unwrap() + 5;
222        json.replace_range(idx..idx + 1, "X");
223        let tampered = Envelope::from_json(&json);
224        let result = tampered.and_then(|e| e.unwrap_verified());
225        assert!(result.is_err());
226    }
227
228    // ── Chain ─────────────────────────────────────────────
229
230    #[test]
231    fn chain_builds_and_verifies() {
232        let mut chain = Chain::new("genesis: race started");
233        chain.append("link 2: user_a joined");
234        chain.append("link 3: user_b joined");
235        chain.append("link 4: winner = user_a");
236
237        let result = chain.verify();
238        assert!(result.valid);
239        assert_eq!(result.total_links, 4);
240    }
241
242    #[test]
243    fn chain_detects_tampering() {
244        let mut chain = Chain::new("genesis");
245        chain.append("link 2");
246        chain.append("link 3");
247
248        // Tamper with link 2's data
249        let mut tampered = chain.clone();
250        tampered.links[1].d = encode_str("TAMPERED");
251
252        let result = tampered.verify();
253        assert!(!result.valid);
254        assert_eq!(result.broken_at, Some(2));
255    }
256
257    #[test]
258    fn chain_serialises_round_trip() {
259        let mut chain = Chain::new("start");
260        chain.append("middle");
261        chain.append("end");
262
263        let json = chain.to_json().unwrap();
264        let restored = Chain::from_json(&json).unwrap();
265        assert!(restored.verify().valid);
266    }
267
268    // ── UniversalStruct ───────────────────────────────────
269
270    #[test]
271    fn struct_wraps_all_fields() {
272        let wrapped = UniversalStruct::wrap_fields(&[
273            ("token", "000001739850123456-abc\"def"),
274            ("user_id", "john's account"),
275            ("amount", "99.99"),
276        ]);
277
278        let result = wrapped.verify_all();
279        assert!(result.all_intact);
280        assert_eq!(wrapped.get("token").unwrap(), "000001739850123456-abc\"def");
281        assert_eq!(wrapped.get("user_id").unwrap(), "john's account");
282        assert_eq!(wrapped.get("amount").unwrap(), "99.99");
283    }
284
285    #[test]
286    fn struct_detects_field_mutation() {
287        let mut wrapped = UniversalStruct::wrap_fields(&[
288            ("token", "abc123"),
289            ("user_id", "john"),
290            ("amount", "99.99"),
291        ]);
292
293        // Mutate just the amount field
294        wrapped.fields[2].d = encode_str("999999.99");
295
296        let result = wrapped.verify_all();
297        assert!(!result.all_intact);
298        assert!(result.violations.contains(&"amount".to_string()));
299        // Other fields still intact
300        assert!(result.fields[0].intact);
301        assert!(result.fields[1].intact);
302        assert!(!result.fields[2].intact);
303    }
304
305    #[test]
306    fn struct_to_map() {
307        let wrapped = UniversalStruct::wrap_fields(&[("a", "hello"), ("b", "world")]);
308        let map = wrapped.to_map().unwrap();
309        assert_eq!(map["a"], "hello");
310        assert_eq!(map["b"], "world");
311    }
312
313    #[test]
314    fn struct_serialises_round_trip() {
315        let wrapped =
316            UniversalStruct::wrap_fields(&[("token", r#"abc"def\ghi"#), ("user", "john")]);
317        let json = wrapped.to_json().unwrap();
318        let restored = UniversalStruct::from_json(&json).unwrap();
319        restored.assert_intact();
320        assert_eq!(restored.get("token").unwrap(), r#"abc"def\ghi"#);
321    }
322
323    // ── Guardian ──────────────────────────────────────────
324
325    #[test]
326    fn guardian_clean_pipeline() {
327        let mut g = Guardian::new("clean data 🔥");
328        let encoded = g.encoded().to_string();
329        g.checkpoint("http", &encoded);
330        g.checkpoint("redis", &encoded);
331        g.checkpoint("postgres", &encoded);
332        g.assert_intact();
333    }
334
335    #[test]
336    fn guardian_finds_violation() {
337        let mut g = Guardian::new("original");
338        let clean = g.encoded().to_string();
339        g.checkpoint("http", &clean);
340        g.checkpoint("redis", &encode_str("mangled"));
341        assert_eq!(g.first_violation().unwrap().layer, "redis");
342    }
343}