Skip to main content

entrouter_universal/
lib.rs

1// Copyright 2026 John A Keeney - Entrouter
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! # Entrouter Universal
16//!
17//! Pipeline integrity guardian. What goes in, comes out identical.
18//!
19//! This crate provides Base64 encoding, SHA-256 fingerprinting, and integrity
20//! verification primitives that can be composed into higher-level constructs:
21//!
22//! - [`Envelope`] -- wrap data in one of four modes (standard, URL-safe, compressed, TTL)
23//! - [`Guardian`] -- track data through a multi-layer pipeline and detect where mutations occur
24//! - [`Chain`] -- build a cryptographic audit trail where each link references the previous
25//! - [`UniversalStruct`] -- wrap individual struct fields so you know *which* field was tampered with
26//!
27//! # Quick start
28//!
29//! ```rust
30//! use entrouter_universal::{encode_str, decode_str, fingerprint_str, verify};
31//!
32//! let encoded = encode_str("hello world");
33//! let fp = fingerprint_str("hello world");
34//! let result = verify(&encoded, &fp).unwrap();
35//! assert!(result.intact);
36//! ```
37
38use 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// ── Errors ────────────────────────────────────────────────
61
62/// Errors returned by Entrouter Universal operations.
63#[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// ── Core primitives ───────────────────────────────────────
89
90/// Base64-encode raw bytes.
91///
92/// ```
93/// let b64 = entrouter_universal::encode(b"hello");
94/// assert_eq!(b64, "aGVsbG8=");
95/// ```
96#[must_use]
97pub fn encode(input: &[u8]) -> String {
98    STANDARD.encode(input)
99}
100
101/// Decode a Base64 string back to raw bytes.
102pub 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/// Base64-encode a UTF-8 string.
109///
110/// ```
111/// let b64 = entrouter_universal::encode_str("hello");
112/// assert_eq!(entrouter_universal::decode_str(&b64).unwrap(), "hello");
113/// ```
114#[must_use]
115pub fn encode_str(input: &str) -> String {
116    encode(input.as_bytes())
117}
118
119/// Decode a Base64 string back to a UTF-8 [`String`].
120pub 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/// Compute a SHA-256 fingerprint of raw bytes, returned as a hex string.
126///
127/// ```
128/// let fp = entrouter_universal::fingerprint(b"hello");
129/// assert_eq!(fp.len(), 64); // 256-bit hex
130/// ```
131#[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/// Compute a SHA-256 fingerprint of a UTF-8 string.
139#[must_use]
140pub fn fingerprint_str(input: &str) -> String {
141    fingerprint(input.as_bytes())
142}
143
144/// Decode `encoded` and verify its fingerprint matches `original_fingerprint`.
145///
146/// Returns [`VerifyResult`] on success, or [`UniversalError::IntegrityViolation`]
147/// if the data was mutated.
148pub 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// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
166//  Tests
167// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::thread::sleep;
173    use std::time::Duration;
174
175    // ── Core ─────────────────────────────────────────────
176
177    #[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    // ── Envelope modes ────────────────────────────────────
184
185    #[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        // URL safe chars only
197        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        // Repeated data compresses well
208        let data = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".repeat(100);
209        let env = Envelope::wrap_compressed(&data).unwrap();
210        // Compressed + Base64 should be smaller than raw
211        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        // Flip a character in the encoded data
235        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    // ── Chain ─────────────────────────────────────────────
243
244    #[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        // Tamper with link 2's data
263        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    // ── UniversalStruct ───────────────────────────────────
283
284    #[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        // Mutate just the amount field
308        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        // Other fields still intact
314        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    // ── Guardian ──────────────────────────────────────────
338
339    #[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}