Skip to main content

durable_streams_server/protocol/
headers.rs

1use crate::protocol::error::{Error, Result};
2use chrono::{DateTime, Utc};
3
4/// Protocol header names
5pub mod names {
6    pub const STREAM_TTL: &str = "Stream-TTL";
7    pub const STREAM_EXPIRES_AT: &str = "Stream-Expires-At";
8    pub const STREAM_CLOSED: &str = "Stream-Closed";
9    pub const STREAM_NEXT_OFFSET: &str = "Stream-Next-Offset";
10    pub const STREAM_UP_TO_DATE: &str = "Stream-Up-To-Date";
11    pub const STREAM_CURSOR: &str = "Stream-Cursor";
12    pub const STREAM_SEQ: &str = "Stream-Seq";
13    pub const PRODUCER_ID: &str = "Producer-Id";
14    pub const PRODUCER_EPOCH: &str = "Producer-Epoch";
15    pub const PRODUCER_SEQ: &str = "Producer-Seq";
16    pub const PRODUCER_EXPECTED_SEQ: &str = "Producer-Expected-Seq";
17    pub const PRODUCER_RECEIVED_SEQ: &str = "Producer-Received-Seq";
18}
19
20/// Parse TTL header value
21///
22/// Validates that the TTL is a valid unsigned integer with no leading zeros,
23/// no decimal points, and no scientific notation.
24///
25/// # Errors
26///
27/// Returns `Error::InvalidTtl` if the value has leading zeros, decimals,
28/// scientific notation, is negative, or is not a valid integer.
29pub fn parse_ttl(value: &str) -> Result<u64> {
30    let trimmed = value.trim();
31
32    // Reject empty
33    if trimmed.is_empty() {
34        return Err(Error::InvalidTtl("empty value".to_string()));
35    }
36
37    // Reject leading zeros (except "0" itself)
38    if trimmed.len() > 1 && trimmed.starts_with('0') {
39        return Err(Error::InvalidTtl(format!(
40            "leading zeros not allowed: '{trimmed}'"
41        )));
42    }
43
44    // Reject decimals
45    if trimmed.contains('.') {
46        return Err(Error::InvalidTtl(format!(
47            "decimal values not allowed: '{trimmed}'"
48        )));
49    }
50
51    // Reject scientific notation
52    if trimmed.contains('e') || trimmed.contains('E') {
53        return Err(Error::InvalidTtl(format!(
54            "scientific notation not allowed: '{trimmed}'"
55        )));
56    }
57
58    // Reject negative
59    if trimmed.starts_with('-') {
60        return Err(Error::InvalidTtl(format!(
61            "negative values not allowed: '{trimmed}'"
62        )));
63    }
64
65    // Reject leading plus sign (Rust's u64::parse accepts "+123")
66    if trimmed.starts_with('+') {
67        return Err(Error::InvalidTtl(format!(
68            "leading plus sign not allowed: '{trimmed}'"
69        )));
70    }
71
72    // Parse as u64
73    trimmed
74        .parse::<u64>()
75        .map_err(|e| Error::InvalidTtl(format!("invalid integer '{trimmed}': {e}")))
76}
77
78/// Parse Expires-At header value (ISO 8601 timestamp)
79///
80/// # Errors
81///
82/// Returns `Error::InvalidHeader` if the value is not a valid ISO 8601 timestamp.
83pub fn parse_expires_at(value: &str) -> Result<DateTime<Utc>> {
84    value
85        .parse::<DateTime<Utc>>()
86        .map_err(|e| Error::InvalidHeader {
87            header: names::STREAM_EXPIRES_AT.to_string(),
88            reason: format!("invalid ISO 8601 timestamp: {e}"),
89        })
90}
91
92/// Parse boolean header value
93///
94/// Accepts "true" (case-insensitive) as true, anything else as false.
95#[must_use]
96pub fn parse_bool(value: &str) -> bool {
97    value.trim().eq_ignore_ascii_case("true")
98}
99
100/// Normalize content type for storage and comparison
101///
102/// Lowercases the content type and strips charset parameter.
103/// Example: "text/plain; charset=utf-8" → "text/plain"
104#[must_use]
105pub fn normalize_content_type(content_type: &str) -> String {
106    content_type
107        .split(';')
108        .next()
109        .unwrap_or(content_type)
110        .trim()
111        .to_lowercase()
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_parse_ttl_valid() {
120        assert_eq!(parse_ttl("0").unwrap(), 0);
121        assert_eq!(parse_ttl("1").unwrap(), 1);
122        assert_eq!(parse_ttl("3600").unwrap(), 3600);
123        assert_eq!(parse_ttl("86400").unwrap(), 86400);
124        assert_eq!(parse_ttl("  100  ").unwrap(), 100); // whitespace ok
125    }
126
127    #[test]
128    fn test_parse_ttl_invalid() {
129        // Leading zeros
130        assert!(parse_ttl("01").is_err());
131        assert!(parse_ttl("00").is_err());
132        assert!(parse_ttl("0123").is_err());
133
134        // Decimals
135        assert!(parse_ttl("1.5").is_err());
136        assert!(parse_ttl("3600.0").is_err());
137
138        // Scientific notation
139        assert!(parse_ttl("1e3").is_err());
140        assert!(parse_ttl("1E3").is_err());
141
142        // Negative
143        assert!(parse_ttl("-1").is_err());
144
145        // Leading plus
146        assert!(parse_ttl("+1").is_err());
147        assert!(parse_ttl("+123").is_err());
148
149        // Empty
150        assert!(parse_ttl("").is_err());
151        assert!(parse_ttl("  ").is_err());
152
153        // Non-numeric
154        assert!(parse_ttl("abc").is_err());
155    }
156
157    #[test]
158    fn test_parse_bool() {
159        assert!(parse_bool("true"));
160        assert!(parse_bool("TRUE"));
161        assert!(parse_bool("True"));
162        assert!(parse_bool("  true  "));
163
164        assert!(!parse_bool("false"));
165        assert!(!parse_bool("1"));
166        assert!(!parse_bool(""));
167        assert!(!parse_bool("yes"));
168    }
169
170    #[test]
171    fn test_normalize_content_type() {
172        assert_eq!(normalize_content_type("text/plain"), "text/plain");
173        assert_eq!(normalize_content_type("TEXT/PLAIN"), "text/plain");
174        assert_eq!(
175            normalize_content_type("text/plain; charset=utf-8"),
176            "text/plain"
177        );
178        assert_eq!(
179            normalize_content_type("application/json;charset=utf-8"),
180            "application/json"
181        );
182        assert_eq!(normalize_content_type("  TEXT/PLAIN  "), "text/plain");
183    }
184}