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// ---------------------------------------------------------------------------
92// File ID validation (defense-in-depth against path traversal)
93// ---------------------------------------------------------------------------
94
95/// Returns true if a user-provided file ID is safe to use as a path component.
96/// Rejects empty strings, `..`, slashes, and dotfiles.
97pub fn is_safe_file_id(id: &str) -> bool {
98    !id.is_empty()
99        && !id.contains("..")
100        && !id.contains('/')
101        && !id.contains('\\')
102        && !id.starts_with('.')
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn quote_ident_basic() {
111        assert_eq!(quote_ident("users"), "\"users\"");
112    }
113
114    #[test]
115    fn quote_ident_escapes_embedded_quote() {
116        assert_eq!(quote_ident("weird\"name"), "\"weird\"\"name\"");
117    }
118
119    #[test]
120    fn now_iso_format() {
121        let s = now_iso();
122        assert_eq!(s.len(), 20);
123        assert!(s.ends_with('Z'));
124        assert_eq!(s.chars().nth(4), Some('-'));
125        assert_eq!(s.chars().nth(10), Some('T'));
126    }
127
128    #[test]
129    fn epoch_to_iso_zero() {
130        assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
131    }
132
133    #[test]
134    fn epoch_to_iso_known() {
135        // 2024-01-01T00:00:00Z = 1704067200
136        assert_eq!(epoch_to_iso(1704067200), "2024-01-01T00:00:00Z");
137    }
138
139    #[test]
140    fn leap_year_detection() {
141        assert!(is_leap(2000));
142        assert!(is_leap(2024));
143        assert!(!is_leap(1900));
144        assert!(!is_leap(2023));
145    }
146
147    #[test]
148    fn safe_file_id_accepts_normal() {
149        assert!(is_safe_file_id("file_abc123"));
150    }
151
152    #[test]
153    fn safe_file_id_rejects_traversal() {
154        assert!(!is_safe_file_id(""));
155        assert!(!is_safe_file_id(".."));
156        assert!(!is_safe_file_id("../etc/passwd"));
157        assert!(!is_safe_file_id("a/b"));
158        assert!(!is_safe_file_id(".hidden"));
159    }
160}