1pub fn quote_ident(name: &str) -> String {
14 format!("\"{}\"", name.replace('"', "\"\""))
15}
16
17pub 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
33pub 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
86pub fn is_leap(y: i64) -> bool {
88 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
89}
90
91pub fn iso_to_epoch(s: &str) -> Result<u64, String> {
103 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 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 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
196pub 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 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}