use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
struct AssetPin {
name: &'static str,
url: &'static str,
url_fallbacks: &'static [&'static str],
sha256: &'static str,
}
const ASSETS: &[AssetPin] = &[
AssetPin {
name: "echarts.min.js",
url: "https://cdn.jsdelivr.net/npm/echarts@6.1.0/dist/echarts.min.js",
url_fallbacks: &["https://unpkg.com/echarts@6.1.0/dist/echarts.min.js"],
sha256: "b66b25aeb4df84e33199dc21694014d336d222cbd9deb0e5a7c14bd6aa0d0fd0",
},
AssetPin {
name: "d3-hierarchy.min.js",
url: "https://cdn.jsdelivr.net/npm/d3-hierarchy@3.1.2/dist/d3-hierarchy.min.js",
url_fallbacks: &["https://unpkg.com/d3-hierarchy@3.1.2/dist/d3-hierarchy.min.js"],
sha256: "a8771380454be89ec5ffe9a6396ba7c247081e348ae740dc9cb9629abd4c0e43",
},
AssetPin {
name: "alpine.min.js",
url: "https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js",
url_fallbacks: &["https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js"],
sha256: "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
},
AssetPin {
name: "alpine-persist.min.js",
url: "https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.15.12/dist/cdn.min.js",
url_fallbacks: &["https://unpkg.com/@alpinejs/persist@3.15.12/dist/cdn.min.js"],
sha256: "e77d932c52ce616c2a5c5dc45530a0221911a31aab48a179e8680932d4d3aa47",
},
];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
windows_restart_manager_link();
if std::env::var_os("CARGO_FEATURE_SPA").is_some() {
vendor_spa_assets();
}
}
fn windows_restart_manager_link() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
println!("cargo:rustc-link-lib=dylib=Rstrtmgr");
}
}
fn vendor_spa_assets() {
let out_dir =
PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR set by cargo for build scripts"));
for asset in ASSETS {
let dest = out_dir.join(asset.name);
match cached_and_valid(&dest, asset.sha256) {
Ok(true) => {
continue;
}
Ok(false) => {
if let Err(e) = fs::remove_file(&dest)
&& e.kind() != std::io::ErrorKind::NotFound
{
panic!(
"build.rs: failed to remove stale cached {}: {e}",
dest.display()
);
}
}
Err(e) => {
eprintln!(
"cargo:warning=codelore-lib build.rs: cached \
{} unreadable ({e}); refetching",
asset.name
);
}
}
fetch_and_pin(asset, &dest);
}
}
fn cached_and_valid(dest: &Path, expected: &str) -> std::io::Result<bool> {
if !dest.exists() {
return Ok(false);
}
let bytes = fs::read(dest)?;
Ok(sha256_hex(&bytes) == expected)
}
fn fetch_and_pin(asset: &AssetPin, dest: &Path) {
eprintln!(
"cargo:warning=codelore-lib build.rs: fetching {} (one-time per release)",
asset.name
);
let mut errors: Vec<String> = Vec::new();
let mut body: Option<Vec<u8>> = None;
for (idx, url) in std::iter::once(asset.url)
.chain(asset.url_fallbacks.iter().copied())
.enumerate()
{
if idx > 0 {
eprintln!(
"cargo:warning=codelore-lib build.rs: primary CDN failed for {}, trying mirror {}",
asset.name, url
);
}
match download(url) {
Ok(b) => {
body = Some(b);
break;
}
Err(e) => errors.push(format!(" {url}: {e}")),
}
}
let body = body.unwrap_or_else(|| {
panic!(
"codelore-lib build.rs: failed to fetch {} from any of {} URL(s):\n{}\n\
\n\
The `spa` feature requires fetching pinned JS deps at build \
time. If you're building offline or behind a proxy that \
blocks every configured CDN, build without the feature \
instead: `cargo build` (default features do NOT include \
`spa`).",
asset.name,
errors.len(),
errors.join("\n")
);
});
let actual = sha256_hex(&body);
assert!(
actual == asset.sha256,
"codelore-lib build.rs: SHA-256 mismatch for {}\n \
expected: {}\n \
got: {}\n\
\n\
A CDN served bytes that don't match the pin in build.rs. \
Either the upstream npm package changed under us (unlikely \
for an immutable version) or there's tampering on the CDN \
path. Investigate before updating the pin.",
asset.name,
asset.sha256,
actual,
);
fs::write(dest, &body).unwrap_or_else(|e| {
panic!(
"codelore-lib build.rs: failed to write {} to OUT_DIR: {e}",
dest.display()
)
});
}
fn download(url: &str) -> Result<Vec<u8>, String> {
let mut resp = ureq::get(url)
.config()
.timeout_global(Some(std::time::Duration::from_mins(2)))
.build()
.call()
.map_err(|e| format!("ureq call: {e}"))?;
let buf = resp
.body_mut()
.with_config()
.limit(8 * 1024 * 1024)
.read_to_vec()
.map_err(|e| format!("read body: {e}"))?;
Ok(buf)
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
hex::encode(h.finalize())
}