use serde::Deserialize;
use std::io::Read;
use std::path::Path;
use std::sync::OnceLock;
use super::download::{self, Error, Result};
pub const MANIFEST_JSON: &str = include_str!("../../data/manifest.json");
pub const MIRROR_ENV: &str = "SATKIT_DATA_URL";
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub manifest_version: u32,
pub data_version: String,
pub release_base: String,
pub files: Vec<ManifestEntry>,
#[serde(default)]
pub refresh: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ManifestEntry {
pub name: String,
pub size: u64,
pub sha256: String,
pub urls: Vec<String>,
#[serde(default)]
pub source: String,
#[serde(default)]
pub license: String,
#[serde(default)]
pub tier: String,
#[serde(default = "default_true")]
pub default: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verified {
Cached,
Hashed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FetchOutcome {
AlreadyPresent,
Downloaded { url: String },
}
impl Manifest {
pub fn parse(json: &str) -> Result<Self> {
let m: Self = serde_json::from_str(json)?;
m.validate()?;
Ok(m)
}
pub fn validate(&self) -> Result<()> {
let invalid = |msg: String| Error::ManifestInvalid { reason: msg };
if self.manifest_version != 2 {
return Err(invalid(format!(
"unsupported manifest_version {}",
self.manifest_version
)));
}
if !self.release_base.starts_with("https://") {
return Err(invalid("release_base must be https://".into()));
}
let mut seen = std::collections::HashSet::new();
for e in &self.files {
validate_file_name(&e.name)?;
if !seen.insert(e.name.as_str()) {
return Err(invalid(format!("duplicate file name {:?}", e.name)));
}
if e.size == 0 {
return Err(invalid(format!("{}: size must be positive", e.name)));
}
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(invalid(format!("{}: sha256 must be 64 hex chars", e.name)));
}
if e.sha256.chars().any(|c| c.is_ascii_uppercase()) {
return Err(invalid(format!("{}: sha256 must be lower-case", e.name)));
}
if e.urls.is_empty() {
return Err(invalid(format!("{}: needs at least one URL", e.name)));
}
for u in &e.urls {
if !u.starts_with("https://") {
return Err(invalid(format!("{}: URL {u:?} must be https://", e.name)));
}
}
}
for u in &self.refresh {
if !u.starts_with("https://") {
return Err(invalid(format!("refresh URL {u:?} must be https://")));
}
}
Ok(())
}
pub fn entry(&self, name: &str) -> Option<&ManifestEntry> {
self.files.iter().find(|e| e.name == name)
}
pub fn default_files(&self) -> impl Iterator<Item = &ManifestEntry> {
self.files.iter().filter(|e| e.default)
}
}
pub fn validate_file_name(name: &str) -> Result<()> {
let bad = name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
|| Path::new(name).is_absolute();
if bad {
return Err(Error::InvalidFileName {
path: name.to_string(),
});
}
Ok(())
}
pub fn embedded() -> &'static Manifest {
static M: OnceLock<Manifest> = OnceLock::new();
M.get_or_init(|| {
Manifest::parse(MANIFEST_JSON).expect("embedded data/manifest.json is invalid")
})
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
hex(&h.finalize())
}
pub fn sha256_file(path: &Path) -> std::io::Result<String> {
use sha2::{Digest, Sha256};
let mut f = std::fs::File::open(path)?;
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(hex(&h.finalize()))
}
fn mtime_parts(md: &std::fs::Metadata) -> (u64, u32) {
md.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| (d.as_secs(), d.subsec_nanos()))
.unwrap_or((0, 0))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn mirror_base() -> Option<String> {
std::env::var(MIRROR_ENV)
.ok()
.map(|s| s.trim().trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())
}
impl ManifestEntry {
pub fn candidate_urls(&self) -> Vec<String> {
let mut v = Vec::with_capacity(self.urls.len() + 1);
if let Some(base) = mirror_base() {
v.push(format!("{base}/{}", self.name));
}
v.extend(self.urls.iter().cloned());
v
}
pub fn verified_marker_path(path: &Path) -> std::path::PathBuf {
let mut p = path.as_os_str().to_owned();
p.push(".sha256-verified");
std::path::PathBuf::from(p)
}
pub fn write_verified_marker(&self, path: &Path) -> std::io::Result<()> {
let md = std::fs::metadata(path)?;
let (secs, nanos) = mtime_parts(&md);
std::fs::write(
Self::verified_marker_path(path),
format!("{} {} {secs} {nanos}\n", self.sha256, md.len()),
)
}
pub fn ensure_verified(&self, path: &Path) -> Result<Verified> {
let md = std::fs::metadata(path)?;
let corrupt = |what: &'static str, expected: String, actual: String| Error::CorruptFile {
name: self.name.clone(),
path: path.display().to_string(),
what,
values: Box::new((expected, actual)),
};
if !md.is_file() || md.len() != self.size {
return Err(corrupt(
"size",
format!("{} bytes", self.size),
format!("{} bytes", md.len()),
));
}
let (secs, nanos) = mtime_parts(&md);
if let Ok(marker) = std::fs::read_to_string(Self::verified_marker_path(path)) {
let f: Vec<&str> = marker.split_whitespace().collect();
if f.len() == 4
&& f[0] == self.sha256
&& f[1] == md.len().to_string()
&& f[2] == secs.to_string()
&& f[3] == nanos.to_string()
{
return Ok(Verified::Cached);
}
}
let actual = sha256_file(path)?;
if actual != self.sha256 {
return Err(corrupt("sha256", self.sha256.clone(), actual));
}
let _ = self.write_verified_marker(path);
Ok(Verified::Hashed)
}
pub fn verify(&self, path: &Path) -> std::io::Result<bool> {
let md = match std::fs::metadata(path) {
Ok(md) => md,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e),
};
if !md.is_file() || md.len() != self.size {
return Ok(false);
}
Ok(sha256_file(path)? == self.sha256)
}
}
#[cfg(feature = "download")]
pub fn fetch_static_file(
entry: &ManifestEntry,
dest_dir: &Path,
force: bool,
) -> Result<FetchOutcome> {
validate_file_name(&entry.name)?;
let dest = dest_dir.join(&entry.name);
if !force && dest.is_file() {
match entry.ensure_verified(&dest) {
Ok(_) => return Ok(FetchOutcome::AlreadyPresent),
Err(Error::CorruptFile { .. }) => eprintln!(
"Warning: {} exists but does not match the manifest (size/sha256); re-downloading",
dest.display()
),
Err(e) => return Err(e),
}
}
if download::is_offline() {
return Err(Error::Offline {
name: entry.name.clone(),
reason: "SATKIT_OFFLINE is set",
urls: entry.candidate_urls(),
});
}
if !dest_dir.is_dir() {
std::fs::create_dir_all(dest_dir)?;
}
let mut attempts: Vec<String> = Vec::new();
for url in entry.candidate_urls() {
match download_verified(&url, entry, &dest) {
Ok(()) => return Ok(FetchOutcome::Downloaded { url }),
Err(e) => attempts.push(format!("{url}: {e}")),
}
}
Err(Error::AllSourcesFailed {
name: entry.name.clone(),
attempts,
})
}
#[cfg(not(feature = "download"))]
pub fn fetch_static_file(
entry: &ManifestEntry,
dest_dir: &Path,
force: bool,
) -> Result<FetchOutcome> {
validate_file_name(&entry.name)?;
let dest = dest_dir.join(&entry.name);
if !force && dest.is_file() {
match entry.ensure_verified(&dest) {
Ok(_) => return Ok(FetchOutcome::AlreadyPresent),
Err(e @ Error::CorruptFile { .. }) => return Err(e),
Err(e) => return Err(e),
}
}
Err(download::offline_error(
&entry.name,
"satkit was built without the `download` feature",
))
}
#[cfg(feature = "download")]
fn download_verified(url: &str, entry: &ManifestEntry, dest: &Path) -> Result<()> {
let agent = ureq::Agent::new_with_defaults();
let mut resp = agent.get(url).call()?;
let mut reader = resp.body_mut().as_reader();
download::write_atomic_verified(&mut reader, dest, entry, url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_manifest_is_valid() {
let m = Manifest::parse(MANIFEST_JSON).expect("embedded manifest parses and validates");
assert_eq!(m.manifest_version, 2);
assert!(m.entry("linux_p1550p2650.440").is_some());
assert!(m.entry("tab5.2a.txt").is_some());
assert!(m.entry("EGM96.gfc").is_some());
assert!(
m.entry("msis21.parm").is_none(),
"NRLMSIS 2.1 licence forbids redistribution"
);
assert!(m.entry("EOP-All.csv").is_none());
assert!(m.entry("SW-All.csv").is_none());
assert!(m.refresh.iter().any(|u| u.ends_with("EOP-All.csv")));
for e in &m.files {
assert!(
e.urls[0].starts_with(&m.release_base),
"{}: first URL should be the release asset",
e.name
);
}
assert!(m.entry("linux_p1550p2650.440").unwrap().default);
assert!(!m.entry("lnxp1900p2053.421").unwrap().default);
assert_eq!(embedded().data_version, m.data_version);
}
#[test]
fn validation_rejects_bad_entries() {
let base = r#"{"manifest_version":2,"data_version":"x","release_base":"https://h/r","files":[FILES],"refresh":[]}"#;
let ok = r#"{"name":"a.bin","size":1,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","urls":["https://h/r/a.bin"]}"#;
assert!(Manifest::parse(&base.replace("FILES", ok)).is_ok());
for (bad, why) in [
(ok.replace("\"size\":1", "\"size\":0"), "zero size"),
(ok.replace("aaaa", "AAAA"), "upper-case hash"),
(ok.replace("aaaaaaaa", "aaaaaaa"), "short hash"),
(ok.replace("https://", "http://"), "http url"),
(ok.replace("\"a.bin\"", "\"../a.bin\""), "path escape"),
(ok.replace("\"a.bin\"", "\"/a.bin\""), "absolute"),
(ok.replace("[\"https://h/r/a.bin\"]", "[]"), "no urls"),
(format!("{ok},{ok}"), "duplicate"),
] {
assert!(
Manifest::parse(&base.replace("FILES", &bad)).is_err(),
"{why}"
);
}
assert!(Manifest::parse(
&base
.replace("FILES", ok)
.replace("\"manifest_version\":2", "\"manifest_version\":1")
)
.is_err());
}
#[test]
fn file_name_validation() {
for ok in ["EGM96.gfc", "sw-data_v2", "a.b.c"] {
assert!(validate_file_name(ok).is_ok(), "{ok:?}");
}
for bad in [
"",
".",
"..",
"../x",
"/etc/passwd",
"a/b",
"a\\b",
"x/../y",
"C:\\x",
"a\0b",
] {
assert!(
matches!(validate_file_name(bad), Err(Error::InvalidFileName { .. })),
"{bad:?} should be rejected"
);
}
}
#[test]
fn sha256_matches_known_vector() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
let dir = std::env::temp_dir().join(format!("satkit_sha_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("abc.txt");
std::fs::write(&p, b"abc").unwrap();
assert_eq!(sha256_file(&p).unwrap(), sha256_hex(b"abc"));
let entry = ManifestEntry {
name: "abc.txt".into(),
size: 3,
sha256: sha256_hex(b"abc"),
urls: vec!["https://example.invalid/abc.txt".into()],
source: String::new(),
license: String::new(),
tier: String::new(),
default: true,
};
assert!(entry.verify(&p).unwrap());
std::fs::write(&p, b"abd").unwrap();
assert!(!entry.verify(&p).unwrap(), "same size, different bytes");
std::fs::write(&p, b"abcd").unwrap();
assert!(!entry.verify(&p).unwrap(), "different size");
assert!(!entry.verify(&dir.join("missing")).unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "needs the real DE440 file in a search directory"]
fn time_de440_hash() {
let Some(path) = crate::utils::find_data_file("linux_p1550p2650.440") else {
eprintln!("DE440 not present; skipping");
return;
};
let entry = embedded().entry("linux_p1550p2650.440").unwrap();
let marker = ManifestEntry::verified_marker_path(&path);
let _ = std::fs::remove_file(&marker);
let t = std::time::Instant::now();
let first = entry.ensure_verified(&path).unwrap();
let hashed = t.elapsed();
let t = std::time::Instant::now();
let second = entry.ensure_verified(&path).unwrap();
let cached = t.elapsed();
println!("DE440 verify: first {first:?} in {hashed:?}; second {second:?} in {cached:?}");
assert_eq!(second, Verified::Cached);
}
#[test]
fn mirror_env_is_tried_first() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let entry = embedded().entry("tab5.2d.txt").unwrap().clone();
std::env::set_var(MIRROR_ENV, "http://mirror.local/data/");
let urls = entry.candidate_urls();
std::env::remove_var(MIRROR_ENV);
assert_eq!(urls[0], "http://mirror.local/data/tab5.2d.txt");
assert_eq!(&urls[1..], &entry.urls[..]);
assert_eq!(entry.candidate_urls(), entry.urls);
}
}