Skip to main content

entrouter_universal/
envelope.rs

1// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2//  Entrouter Universal - Envelope v3
3//
4//  Four wrap modes:
5//  1. wrap()             - standard Base64
6//  2. wrap_url_safe()    - URL-safe Base64 (- and _ instead of + and /)
7//  3. wrap_compressed()  - gzip then Base64 (smaller wire size)
8//  4. wrap_with_ttl()    - standard Base64 + expiry timestamp
9//
10//  All modes carry a SHA-256 fingerprint.
11//  All modes unwrap via unwrap_verified().
12// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13
14use crate::{fingerprint_str, UniversalError};
15use base64::{
16    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
17    Engine,
18};
19use serde::{Deserialize, Serialize};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22#[cfg(feature = "compression")]
23use crate::compress::{compress, decompress};
24
25/// The encoding mode used to create an [`Envelope`].
26#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
27pub enum EnvelopeMode {
28    /// Standard Base64 encoding.
29    Standard,
30    /// URL-safe Base64 (`-` and `_` instead of `+` and `/`, no padding).
31    UrlSafe,
32    /// Gzip compressed then Base64 encoded.
33    Compressed,
34    /// Standard Base64 with a Unix-timestamp expiry.
35    Ttl,
36}
37
38/// A sealed envelope that carries data, its SHA-256 fingerprint, and an
39/// encoding mode.
40///
41/// # Example
42///
43/// ```
44/// use entrouter_universal::Envelope;
45///
46/// let env = Envelope::wrap("secret payload");
47/// assert_eq!(env.unwrap_verified().unwrap(), "secret payload");
48/// ```
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Envelope {
51    /// Encoded data - opaque to every layer
52    pub d: String,
53    /// SHA-256 fingerprint of the ORIGINAL raw input (before compression)
54    pub f: String,
55    /// Encoding mode
56    pub m: EnvelopeMode,
57    /// Optional expiry as Unix timestamp (seconds)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub e: Option<u64>,
60    /// Version
61    pub v: u8,
62}
63
64impl Envelope {
65    // ── Constructors ──────────────────────────────────────
66
67    /// Standard Base64 wrap.
68    #[must_use]
69    pub fn wrap(input: &str) -> Self {
70        Self {
71            d: STANDARD.encode(input.as_bytes()),
72            f: fingerprint_str(input),
73            m: EnvelopeMode::Standard,
74            e: None,
75            v: 3,
76        }
77    }
78
79    /// URL-safe Base64 wrap.
80    /// Use when passing through URLs, query params, or HTTP headers.
81    /// Uses `-` and `_` instead of `+` and `/`. No padding.
82    #[must_use]
83    pub fn wrap_url_safe(input: &str) -> Self {
84        Self {
85            d: URL_SAFE_NO_PAD.encode(input.as_bytes()),
86            f: fingerprint_str(input),
87            m: EnvelopeMode::UrlSafe,
88            e: None,
89            v: 3,
90        }
91    }
92
93    /// Compressed wrap - gzip then Base64.
94    /// Use for large payloads. Transparent to consumer - unwrap_verified()
95    /// returns the original uncompressed string.
96    #[cfg(feature = "compression")]
97    pub fn wrap_compressed(input: &str) -> Result<Self, UniversalError> {
98        let compressed = compress(input.as_bytes())?;
99        Ok(Self {
100            d: STANDARD.encode(&compressed),
101            f: fingerprint_str(input),
102            m: EnvelopeMode::Compressed,
103            e: None,
104            v: 3,
105        })
106    }
107
108    /// TTL wrap - standard Base64 with an expiry time.
109    /// unwrap_verified() returns Err if the envelope has expired.
110    #[must_use]
111    pub fn wrap_with_ttl(input: &str, ttl_secs: u64) -> Self {
112        let now = SystemTime::now()
113            .duration_since(UNIX_EPOCH)
114            .unwrap_or_default()
115            .as_secs();
116        Self {
117            d: STANDARD.encode(input.as_bytes()),
118            f: fingerprint_str(input),
119            m: EnvelopeMode::Ttl,
120            e: Some(now + ttl_secs),
121            v: 3,
122        }
123    }
124
125    // ── Unwrap ────────────────────────────────────────────
126
127    /// Decode and verify integrity at the exit point.
128    /// Works for all modes. Returns Err on:
129    /// - Integrity violation (data mutated in transit)
130    /// - Expired TTL
131    /// - Decode/decompress failure
132    pub fn unwrap_verified(&self) -> Result<String, UniversalError> {
133        // TTL check first
134        if let Some(expiry) = self.e {
135            let now = SystemTime::now()
136                .duration_since(UNIX_EPOCH)
137                .unwrap_or_default()
138                .as_secs();
139            if now >= expiry {
140                return Err(UniversalError::Expired {
141                    expired_at: expiry,
142                    now,
143                });
144            }
145        }
146
147        // Decode
148        let bytes = match self.m {
149            EnvelopeMode::Standard | EnvelopeMode::Ttl => STANDARD
150                .decode(&self.d)
151                .map_err(|e| UniversalError::DecodeError(e.to_string()))?,
152            EnvelopeMode::UrlSafe => URL_SAFE_NO_PAD
153                .decode(&self.d)
154                .map_err(|e| UniversalError::DecodeError(e.to_string()))?,
155            #[cfg(feature = "compression")]
156            EnvelopeMode::Compressed => {
157                let compressed = STANDARD
158                    .decode(&self.d)
159                    .map_err(|e| UniversalError::DecodeError(e.to_string()))?;
160                decompress(&compressed)?
161            }
162            #[cfg(not(feature = "compression"))]
163            EnvelopeMode::Compressed => {
164                return Err(UniversalError::DecodeError(
165                    "compression feature not enabled".to_string(),
166                ))
167            }
168        };
169
170        let decoded =
171            String::from_utf8(bytes).map_err(|e| UniversalError::DecodeError(e.to_string()))?;
172
173        // Verify fingerprint
174        let actual_fp = fingerprint_str(&decoded);
175        if actual_fp != self.f {
176            return Err(UniversalError::IntegrityViolation {
177                expected: self.f.clone(),
178                actual: actual_fp,
179            });
180        }
181
182        Ok(decoded)
183    }
184
185    /// Decode without verification - use when you trust the source.
186    pub fn unwrap_raw(&self) -> Result<String, UniversalError> {
187        let bytes = match self.m {
188            EnvelopeMode::Standard | EnvelopeMode::Ttl => STANDARD
189                .decode(&self.d)
190                .map_err(|e| UniversalError::DecodeError(e.to_string()))?,
191            EnvelopeMode::UrlSafe => URL_SAFE_NO_PAD
192                .decode(&self.d)
193                .map_err(|e| UniversalError::DecodeError(e.to_string()))?,
194            #[cfg(feature = "compression")]
195            EnvelopeMode::Compressed => {
196                let compressed = STANDARD
197                    .decode(&self.d)
198                    .map_err(|e| UniversalError::DecodeError(e.to_string()))?;
199                decompress(&compressed)?
200            }
201            #[cfg(not(feature = "compression"))]
202            EnvelopeMode::Compressed => {
203                return Err(UniversalError::DecodeError(
204                    "compression feature not enabled".to_string(),
205                ))
206            }
207        };
208        String::from_utf8(bytes).map_err(|e| UniversalError::DecodeError(e.to_string()))
209    }
210
211    /// Returns `true` if this envelope has expired (TTL mode only).
212    pub fn is_expired(&self) -> bool {
213        if let Some(expiry) = self.e {
214            let now = SystemTime::now()
215                .duration_since(UNIX_EPOCH)
216                .unwrap_or_default()
217                .as_secs();
218            return now >= expiry;
219        }
220        false
221    }
222
223    /// Seconds remaining until expiry. None if no TTL set.
224    pub fn ttl_remaining(&self) -> Option<u64> {
225        let expiry = self.e?;
226        let now = SystemTime::now()
227            .duration_since(UNIX_EPOCH)
228            .unwrap_or_default()
229            .as_secs();
230        Some(expiry.saturating_sub(now))
231    }
232
233    /// Returns `true` if the data passes integrity verification.
234    ///
235    /// Convenience wrapper around [`Envelope::unwrap_verified`].
236    pub fn is_intact(&self) -> bool {
237        self.unwrap_verified().is_ok()
238    }
239
240    /// Returns the SHA-256 fingerprint of the original data.
241    pub fn fingerprint(&self) -> &str {
242        &self.f
243    }
244
245    /// Returns the [`EnvelopeMode`] used to create this envelope.
246    pub fn mode(&self) -> EnvelopeMode {
247        self.m
248    }
249
250    /// Serialize this envelope to a JSON string.
251    pub fn to_json(&self) -> Result<String, UniversalError> {
252        serde_json::to_string(self).map_err(|e| UniversalError::SerializationError(e.to_string()))
253    }
254
255    /// Deserialize an envelope from a JSON string.
256    pub fn from_json(s: &str) -> Result<Self, UniversalError> {
257        serde_json::from_str(s).map_err(|e| UniversalError::SerializationError(e.to_string()))
258    }
259}