Files
RustProxy/build.rs
T
2026-10-07 22:22:54 +02:00

137 lines
5.7 KiB
Rust

//! Build extensible language packs and pinned, authenticated Swagger UI assets.
use serde_json::{json, Value};
use std::{collections::HashSet, env, fs, path::PathBuf, process::Command};
// Prepare the actual embedded bytes here, not in a dependency's earlier build.
// Cargo config discovery is deliberately irrelevant to the pinned default.
fn swagger_assets(
root: &std::path::Path,
out: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
for name in [
"SWAGGER_UI_DOWNLOAD_URL",
"SWAGGER_UI_ARCHIVE_SHA256",
"RUST_PROXY_PYTHON",
"CARGO_NET_OFFLINE",
] {
println!("cargo:rerun-if-env-changed={name}");
}
let helper = root.join("scripts/build/swagger_assets.py");
println!("cargo:rerun-if-changed={}", helper.display());
let interpreters = match env::var_os("RUST_PROXY_PYTHON") {
Some(value) if !value.is_empty() => vec![value],
_ => vec!["python3".into(), "python".into()],
};
for interpreter in interpreters {
match Command::new(&interpreter).arg(&helper).arg("--out-dir").arg(out).status() {
Ok(status) if status.success() => return Ok(()),
Ok(_) => return Err("Swagger UI asset preparation failed; see the diagnostic above and docs/API_ACCESS.md".into()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
}
Err("Python 3.10+ is required for build assets; install python3 or set RUST_PROXY_PYTHON to its executable".into())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let root = PathBuf::from(
env::var_os("CARGO_MANIFEST_DIR").ok_or("Cargo did not supply CARGO_MANIFEST_DIR")?,
);
let out = PathBuf::from(env::var_os("OUT_DIR").ok_or("Cargo did not supply OUT_DIR")?);
swagger_assets(&root, &out)?;
let langs = root.join("langs");
println!("cargo:rerun-if-changed={}", langs.display());
let mut paths: Vec<_> = fs::read_dir(&langs)
.expect("langs directory is required")
.map(|entry| entry.expect("read language entry").path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect();
paths.sort();
assert!(
!paths.is_empty() && paths.len() <= 128,
"expected 1..128 language packs"
);
let mut languages = Vec::new();
let mut locales = HashSet::new();
for path in paths {
println!("cargo:rerun-if-changed={}", path.display());
let id = path
.file_stem()
.unwrap()
.to_str()
.expect("language filename must be UTF-8");
assert!(
id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'),
"invalid language filename: {}",
path.display()
);
let content = fs::read(&path).expect("read language JSON");
assert!(content.len() <= 262_144, "language pack exceeds 256 KiB");
let pack: Value =
serde_json::from_slice(&content).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
let meta = pack["meta"]
.as_object()
.expect("language meta object is required");
for key in ["locale", "name", "native_name", "direction"] {
assert!(
meta.get(key)
.and_then(Value::as_str)
.is_some_and(|v| !v.is_empty() && v.len() <= 128),
"{id}: missing/invalid {key}"
);
}
assert!(
matches!(meta["direction"].as_str(), Some("ltr" | "rtl")),
"{id}: invalid direction"
);
let locale = meta["locale"].as_str().unwrap();
assert!(
locale.split('-').all(|part| !part.is_empty()
&& part.len() <= 8
&& part.bytes().all(|b| b.is_ascii_alphanumeric())),
"{id}: use a BCP-47 style locale"
);
assert!(
locales.insert(locale.to_ascii_lowercase()),
"duplicate locale {locale}"
);
let translations = pack["translations"]
.as_object()
.expect("translations object is required");
assert!(
translations.len() <= 4096
&& translations.iter().all(|(k, v)| !k.is_empty()
&& k.len() <= 128
&& v.as_str().is_some_and(|s| s.len() <= 16_384)),
"{id}: translations must be bounded strings"
);
languages.push(json!({"id":id,"meta":meta,"translations":translations}));
}
assert!(
languages.iter().any(|p| p["id"] == "en_US"),
"en_US.json is the required fallback pack"
);
let login_path = root.join("src/admin/web/login-keys.json");
println!("cargo:rerun-if-changed={}", login_path.display());
let login_keys: HashSet<String> =
serde_json::from_slice(&fs::read(login_path).expect("read public translation keys"))
.expect("parse public translation keys");
let mut login_languages = languages.clone();
for language in &mut login_languages {
language["translations"]
.as_object_mut()
.expect("translations object")
.retain(|key, _| login_keys.contains(key));
}
let login_bundle =
serde_json::to_vec(&json!({"default":"en_US","languages":login_languages})).unwrap();
fs::write(out.join("login-langs.json"), login_bundle).expect("write login translations");
let bundle = serde_json::to_vec(&json!({"default":"en_US","languages":languages})).unwrap();
assert!(bundle.len() <= 8_388_608, "language bundle exceeds 8 MiB");
fs::write(out.join("langs.json"), bundle).expect("write language bundle");
Ok(())
}