use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{Error, Result};
use crate::http::{self, HttpOptions, Metadata};
use crate::verify;
pub(super) fn fetch_signed(
url: &str,
signature_url: &str,
keys: &[String],
http: &HttpOptions,
) -> Result<String> {
let body = http::get_string(url, &[], Metadata::Document, http)?;
let signature = http::get_string(signature_url, &[], Metadata::Sidecar, http)?;
let keys: Vec<&str> = keys.iter().map(String::as_str).collect();
verify::verify_minisign_bytes(body.as_bytes(), &keys, &signature)?;
Ok(body)
}
pub(super) fn check_expiry(expires: &str, now: SystemTime) -> Result<()> {
let deadline = unix_seconds(expires)
.ok_or_else(|| Error::Parse(format!("`expires` is not an RFC 3339 time: {expires}")))?;
let now = now
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
if now < deadline {
Ok(())
} else {
Err(Error::ManifestExpired {
expires: expires.to_string(),
})
}
}
pub(super) fn unix_seconds(text: &str) -> Option<i64> {
let (date, rest) = text.trim().split_once(['T', 't', ' '])?;
let (clock, offset) = split_offset(rest)?;
let [year, month, day] = fields(date, '-')?;
let [hour, minute, second] = fields(clock.split('.').next()?, ':')?;
let valid = (1..=12).contains(&month)
&& (1..=31).contains(&day)
&& hour < 24
&& minute < 60
&& second <= 60;
valid.then(|| {
days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second - offset
})
}
fn split_offset(rest: &str) -> Option<(&str, i64)> {
if let Some(clock) = rest.strip_suffix(['Z', 'z']) {
return Some((clock, 0));
}
let at = rest.rfind(['+', '-'])?;
let (clock, offset) = rest.split_at(at);
let sign = if offset.starts_with('-') { -1 } else { 1 };
let (hours, minutes) = offset.get(1..)?.split_once(':')?;
let seconds = hours.parse::<i64>().ok()? * 3_600 + minutes.parse::<i64>().ok()? * 60;
Some((clock, sign * seconds))
}
fn fields(text: &str, sep: char) -> Option<[i64; 3]> {
let mut parts = text.split(sep).map(|p| p.parse::<i64>().ok());
let fields = [parts.next()??, parts.next()??, parts.next()??];
parts.next().is_none().then_some(fields)
}
pub(super) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let year = if month <= 2 { year - 1 } else { year };
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let month_from_march = if month > 2 { month - 3 } else { month + 9 };
let day_of_year = (153 * month_from_march + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn parses_rfc3339_times() {
assert_eq!(unix_seconds("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(unix_seconds("2000-03-01T00:00:00Z"), Some(951_868_800));
assert_eq!(
unix_seconds("2026-10-11T12:34:56.789Z"),
Some(1_791_722_096)
);
assert_eq!(
unix_seconds("2026-10-11T20:34:56+08:00"),
unix_seconds("2026-10-11T12:34:56Z")
);
assert_eq!(
unix_seconds("2026-10-11T07:34:56-05:00"),
unix_seconds("2026-10-11T12:34:56Z")
);
for bad in [
"2026-10-11",
"2026-13-01T00:00:00Z",
"2026-10-11T25:00:00Z",
"soon",
] {
assert_eq!(unix_seconds(bad), None, "{bad}");
}
}
#[test]
fn rejects_an_expired_manifest() {
let at = |s: u64| UNIX_EPOCH + Duration::from_secs(s);
let expires = "2026-10-11T12:34:56Z";
assert!(check_expiry(expires, at(1_791_722_095)).is_ok());
assert!(matches!(
check_expiry(expires, at(1_791_722_096)),
Err(Error::ManifestExpired { .. })
));
assert!(matches!(
check_expiry("next week", at(0)),
Err(Error::Parse(_))
));
}
}