Skip to main content

chub_core/
util.rs

1//! Shared utility functions used across the crate.
2
3use crate::error::{Error, Result};
4
5/// Convert days since Unix epoch to (year, month, day).
6/// Algorithm from <http://howardhinnant.github.io/date_algorithms.html>.
7pub fn days_to_date(days: u64) -> (u64, u64, u64) {
8    let z = days + 719468;
9    let era = z / 146097;
10    let doe = z - era * 146097;
11    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
12    let y = yoe + era * 400;
13    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
14    let mp = (5 * doy + 2) / 153;
15    let d = doy - (153 * mp + 2) / 5 + 1;
16    let m = if mp < 10 { mp + 3 } else { mp - 9 };
17    let y = if m <= 2 { y + 1 } else { y };
18    (y, m, d)
19}
20
21/// Get current time as ISO 8601 string (e.g. `2026-03-21T14:30:00.000Z`).
22pub fn now_iso8601() -> String {
23    let secs = std::time::SystemTime::now()
24        .duration_since(std::time::UNIX_EPOCH)
25        .unwrap_or_default()
26        .as_secs();
27    let days = secs / 86400;
28    let tod = secs % 86400;
29    let (y, m, d) = days_to_date(days);
30    format!(
31        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
32        y,
33        m,
34        d,
35        tod / 3600,
36        (tod % 3600) / 60,
37        tod % 60
38    )
39}
40
41/// Get current date as ISO 8601 date string (e.g. `2026-03-21`).
42pub fn today_date() -> String {
43    let secs = std::time::SystemTime::now()
44        .duration_since(std::time::UNIX_EPOCH)
45        .unwrap_or_default()
46        .as_secs();
47    let days = secs / 86400;
48    let (y, m, d) = days_to_date(days);
49    format!("{:04}-{:02}-{:02}", y, m, d)
50}
51
52/// Sanitize an entry ID for use in filenames.
53/// Replaces `/` and `\` with `--` and strips `..` to prevent path traversal.
54pub fn sanitize_entry_id(entry_id: &str) -> String {
55    entry_id.replace(['/', '\\'], "--").replace("..", "")
56}
57
58/// Validate that a name is safe for use as a filename (no path traversal).
59pub fn validate_filename(name: &str, kind: &str) -> Result<()> {
60    if name.is_empty()
61        || name.contains('/')
62        || name.contains('\\')
63        || name.contains("..")
64        || name.starts_with('.')
65    {
66        return Err(Error::Config(format!(
67            "Invalid {} name \"{}\": must not contain path separators or \"..\"",
68            kind, name
69        )));
70    }
71    Ok(())
72}
73
74/// Validate that a URL uses an allowed scheme (https, or http for localhost/127.0.0.1 in dev).
75/// Returns the validated URL or an error.
76pub fn validate_url(url: &str, context: &str) -> Result<String> {
77    let trimmed = url.trim().trim_end_matches('/');
78    if trimmed.is_empty() {
79        return Err(Error::Config(format!("{}: URL must not be empty", context)));
80    }
81
82    // Parse scheme
83    let lower = trimmed.to_lowercase();
84    if lower.starts_with("https://") {
85        return Ok(trimmed.to_string());
86    }
87
88    // Allow http:// only for localhost / 127.0.0.1 (local dev servers)
89    if let Some(host_part) = lower.strip_prefix("http://") {
90        let host = host_part.split('/').next().unwrap_or("");
91        let host_no_port = host.split(':').next().unwrap_or("");
92        if host_no_port == "localhost" || host_no_port == "127.0.0.1" || host_no_port == "[::1]" {
93            return Ok(trimmed.to_string());
94        }
95        return Err(Error::Config(format!(
96            "{}: HTTP URLs are only allowed for localhost. Use HTTPS for remote servers: \"{}\"",
97            context, trimmed
98        )));
99    }
100
101    Err(Error::Config(format!(
102        "{}: URL must use HTTPS (got \"{}\")",
103        context, trimmed
104    )))
105}
106
107/// Validate that a file path stays within a given base directory (no path traversal).
108/// Returns the canonicalized path on success.
109pub fn validate_path_within(
110    base: &std::path::Path,
111    target: &std::path::Path,
112    context: &str,
113) -> Result<std::path::PathBuf> {
114    // Check for obvious traversal patterns in the raw path
115    let target_str = target.to_string_lossy();
116    if target_str.contains("..") {
117        return Err(Error::Config(format!(
118            "{}: path traversal not allowed: \"{}\"",
119            context, target_str
120        )));
121    }
122
123    // Resolve the path and verify containment
124    // Use the canonical base if available, otherwise normalize manually
125    let resolved_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
126    let resolved = if target.exists() {
127        target
128            .canonicalize()
129            .unwrap_or_else(|_| base.join(target.file_name().unwrap_or_default()))
130    } else {
131        // For non-existent files, just join and normalize
132        resolved_base.join(
133            target.strip_prefix(&resolved_base).unwrap_or(
134                target
135                    .file_name()
136                    .map(std::path::Path::new)
137                    .unwrap_or(target),
138            ),
139        )
140    };
141
142    if !resolved.starts_with(&resolved_base) {
143        return Err(Error::Config(format!(
144            "{}: path escapes allowed directory: \"{}\"",
145            context, target_str
146        )));
147    }
148
149    Ok(resolved)
150}
151
152/// Write data to a file atomically using a temp file + rename.
153/// On failure, falls back to a direct write.
154pub fn atomic_write(
155    path: &std::path::Path,
156    data: &[u8],
157) -> std::result::Result<(), std::io::Error> {
158    let parent = path.parent().unwrap_or(std::path::Path::new("."));
159    let _ = std::fs::create_dir_all(parent);
160
161    // Write to a temp file in the same directory, then rename
162    let tmp_name = format!(
163        ".{}.tmp.{}",
164        path.file_name().unwrap_or_default().to_string_lossy(),
165        std::process::id()
166    );
167    let tmp_path = parent.join(&tmp_name);
168
169    std::fs::write(&tmp_path, data)?;
170
171    // Rename is atomic on most filesystems
172    if std::fs::rename(&tmp_path, path).is_err() {
173        // Fallback: on Windows, rename can fail if target exists
174        let _ = std::fs::remove_file(path);
175        if let Err(_e) = std::fs::rename(&tmp_path, path) {
176            // Last resort: clean up temp and do direct write
177            let _ = std::fs::remove_file(&tmp_path);
178            return std::fs::write(path, data);
179        }
180    }
181
182    Ok(())
183}