Skip to main content

pylon_kernel/
util.rs

1//! Shared utilities used across multiple crates.
2//!
3//! These live in `pylon-kernel` because `core` has no I/O dependencies
4//! and is already a dependency of every other crate.
5
6// ---------------------------------------------------------------------------
7// SQL identifier quoting
8// ---------------------------------------------------------------------------
9
10/// Quote a SQL identifier with double quotes to prevent injection.
11/// Embedded double quotes are escaped by doubling them (SQL standard,
12/// works in SQLite and Postgres).
13pub fn quote_ident(name: &str) -> String {
14    format!("\"{}\"", name.replace('"', "\"\""))
15}
16
17// ---------------------------------------------------------------------------
18// ISO-8601 timestamps
19// ---------------------------------------------------------------------------
20
21/// Current UTC time as an ISO-8601 string (second precision).
22///
23/// Uses only `std::time::SystemTime` — no external date library required.
24pub fn now_iso() -> String {
25    use std::time::{SystemTime, UNIX_EPOCH};
26    let secs = SystemTime::now()
27        .duration_since(UNIX_EPOCH)
28        .unwrap_or_default()
29        .as_secs();
30    epoch_to_iso(secs)
31}
32
33/// Convert Unix-epoch seconds to an ISO-8601 string.
34pub fn epoch_to_iso(secs: u64) -> String {
35    let days = secs / 86400;
36    let time_of_day = secs % 86400;
37    let hours = time_of_day / 3600;
38    let minutes = (time_of_day % 3600) / 60;
39    let seconds = time_of_day % 60;
40
41    let mut y = 1970i64;
42    let mut remaining = days as i64;
43    loop {
44        let days_in_year = if is_leap(y) { 366 } else { 365 };
45        if remaining < days_in_year {
46            break;
47        }
48        remaining -= days_in_year;
49        y += 1;
50    }
51    let leap = is_leap(y);
52    let month_days: [i64; 12] = [
53        31,
54        if leap { 29 } else { 28 },
55        31,
56        30,
57        31,
58        30,
59        31,
60        31,
61        30,
62        31,
63        30,
64        31,
65    ];
66    let mut m = 0usize;
67    for (i, &md) in month_days.iter().enumerate() {
68        if remaining < md {
69            m = i;
70            break;
71        }
72        remaining -= md;
73    }
74    let d = remaining + 1;
75    format!(
76        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
77        y,
78        m + 1,
79        d,
80        hours,
81        minutes,
82        seconds
83    )
84}
85
86/// Check if a year is a leap year.
87pub fn is_leap(y: i64) -> bool {
88    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
89}
90
91/// Parse an ISO-8601 / RFC 3339 timestamp into Unix-epoch seconds.
92///
93/// Accepts the formats pylon emits (`epoch_to_iso` shape) plus the
94/// common RFC 3339 variants users send through the API:
95/// - `YYYY-MM-DDTHH:MM:SSZ`
96/// - `YYYY-MM-DDTHH:MM:SS.fffZ` (fractional seconds dropped)
97/// - `YYYY-MM-DDTHH:MM:SS+HH:MM` / `-HH:MM` (offset applied)
98///
99/// Hand-rolled to keep `pylon-kernel` std-only — no chrono dep. Used
100/// by the Postgres adapter to bind TIMESTAMPTZ columns from JSON
101/// strings; SQLite stores them as TEXT and never needed parsing.
102pub fn iso_to_epoch(s: &str) -> Result<u64, String> {
103    // Minimal length check: "YYYY-MM-DDTHH:MM:SS" = 19 chars before the
104    // tz suffix.
105    if s.len() < 20 {
106        return Err(format!("timestamp too short for ISO 8601: {s:?}"));
107    }
108    let parse_n = |slice: &str| -> Result<i64, String> {
109        slice
110            .parse::<i64>()
111            .map_err(|_| format!("non-numeric segment in {slice:?}"))
112    };
113    let y = parse_n(&s[0..4])?;
114    if &s[4..5] != "-"
115        || &s[7..8] != "-"
116        || &s[10..11] != "T"
117        || &s[13..14] != ":"
118        || &s[16..17] != ":"
119    {
120        return Err(format!("expected YYYY-MM-DDTHH:MM:SS shape, got {s:?}"));
121    }
122    let mo = parse_n(&s[5..7])?;
123    let d = parse_n(&s[8..10])?;
124    let h = parse_n(&s[11..13])?;
125    let mi = parse_n(&s[14..16])?;
126    let se = parse_n(&s[17..19])?;
127
128    // Tz suffix: `Z`, `+HH:MM`, `-HH:MM`, optionally preceded by `.fff`.
129    // We tolerate fractional seconds by skipping them — TIMESTAMPTZ
130    // round-trips fine at second precision for pylon's surface.
131    let mut tz_start = 19;
132    if s.as_bytes().get(tz_start) == Some(&b'.') {
133        tz_start += 1;
134        while let Some(&b) = s.as_bytes().get(tz_start) {
135            if b.is_ascii_digit() {
136                tz_start += 1;
137            } else {
138                break;
139            }
140        }
141    }
142    let tz = &s[tz_start..];
143    let offset_secs: i64 = match tz {
144        "Z" | "" => 0,
145        _ if tz.len() == 6 && (tz.starts_with('+') || tz.starts_with('-')) => {
146            let sign: i64 = if &tz[0..1] == "+" { 1 } else { -1 };
147            let oh = parse_n(&tz[1..3])?;
148            let om = parse_n(&tz[4..6])?;
149            sign * (oh * 3600 + om * 60)
150        }
151        other => return Err(format!("unrecognized timezone suffix: {other:?}")),
152    };
153
154    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
155        return Err(format!("month/day out of range in {s:?}"));
156    }
157
158    // Days from epoch (1970-01-01) to the start of the target year.
159    let mut days: i64 = 0;
160    if y >= 1970 {
161        for yr in 1970..y {
162            days += if is_leap(yr) { 366 } else { 365 };
163        }
164    } else {
165        for yr in y..1970 {
166            days -= if is_leap(yr) { 366 } else { 365 };
167        }
168    }
169    let leap = is_leap(y);
170    let month_days: [i64; 12] = [
171        31,
172        if leap { 29 } else { 28 },
173        31,
174        30,
175        31,
176        30,
177        31,
178        31,
179        30,
180        31,
181        30,
182        31,
183    ];
184    for i in 0..(mo as usize - 1) {
185        days += month_days[i];
186    }
187    days += d - 1;
188
189    let total = days * 86400 + h * 3600 + mi * 60 + se - offset_secs;
190    if total < 0 {
191        return Err(format!("pre-1970 timestamp not supported: {s:?}"));
192    }
193    Ok(total as u64)
194}
195
196// ---------------------------------------------------------------------------
197// File ID validation (defense-in-depth against path traversal)
198// ---------------------------------------------------------------------------
199
200/// Returns true if a user-provided file ID is safe to use as a path component.
201/// Rejects empty strings, `..`, slashes, and dotfiles.
202pub fn is_safe_file_id(id: &str) -> bool {
203    !id.is_empty()
204        && !id.contains("..")
205        && !id.contains('/')
206        && !id.contains('\\')
207        && !id.starts_with('.')
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn quote_ident_basic() {
216        assert_eq!(quote_ident("users"), "\"users\"");
217    }
218
219    #[test]
220    fn quote_ident_escapes_embedded_quote() {
221        assert_eq!(quote_ident("weird\"name"), "\"weird\"\"name\"");
222    }
223
224    #[test]
225    fn now_iso_format() {
226        let s = now_iso();
227        assert_eq!(s.len(), 20);
228        assert!(s.ends_with('Z'));
229        assert_eq!(s.chars().nth(4), Some('-'));
230        assert_eq!(s.chars().nth(10), Some('T'));
231    }
232
233    #[test]
234    fn epoch_to_iso_zero() {
235        assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
236    }
237
238    #[test]
239    fn epoch_to_iso_known() {
240        // 2024-01-01T00:00:00Z = 1704067200
241        assert_eq!(epoch_to_iso(1704067200), "2024-01-01T00:00:00Z");
242    }
243
244    #[test]
245    fn leap_year_detection() {
246        assert!(is_leap(2000));
247        assert!(is_leap(2024));
248        assert!(!is_leap(1900));
249        assert!(!is_leap(2023));
250    }
251
252    #[test]
253    fn safe_file_id_accepts_normal() {
254        assert!(is_safe_file_id("file_abc123"));
255    }
256
257    #[test]
258    fn safe_file_id_rejects_traversal() {
259        assert!(!is_safe_file_id(""));
260        assert!(!is_safe_file_id(".."));
261        assert!(!is_safe_file_id("../etc/passwd"));
262        assert!(!is_safe_file_id("a/b"));
263        assert!(!is_safe_file_id(".hidden"));
264    }
265}