use clap::{Args, Subcommand};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct OtaArgs {
#[command(subcommand)]
pub command: OtaCommand,
}
#[derive(Subcommand)]
pub enum OtaCommand {
Keygen {
#[arg(long, default_value = ".")]
out_dir: PathBuf,
},
Pack {
dir: PathBuf,
#[arg(long)]
key: PathBuf,
#[arg(long, default_value = "1.0.0")]
version: String,
#[arg(long)]
out_dir: Option<PathBuf>,
},
Verify {
manifest: PathBuf,
sig: PathBuf,
#[arg(long)]
public_key: PathBuf,
},
}
pub fn run(args: OtaArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
OtaCommand::Keygen { out_dir } => keygen(&out_dir),
OtaCommand::Pack {
dir,
key,
version,
out_dir,
} => pack(&dir, &key, &version, out_dir.as_deref()),
OtaCommand::Verify {
manifest,
sig,
public_key,
} => verify(&manifest, &sig, &public_key),
}
}
pub const MANIFEST_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestFile {
pub path: String,
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema: u32,
pub version: String,
pub created_at: String,
pub files: Vec<ManifestFile>,
}
impl Manifest {
pub fn to_canonical_bytes(&self) -> serde_json::Result<Vec<u8>> {
let mut buf = Vec::new();
let formatter = serde_json::ser::CompactFormatter;
let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
serde::Serialize::serialize(self, &mut ser)?;
Ok(buf)
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_encode(&hasher.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn read_key_bytes(path: &Path) -> Result<[u8; 32], String> {
let raw = fs::read(path).map_err(|e| format!("failed to read key {}: {e}", path.display()))?;
if raw.len() == 32 {
let mut out = [0u8; 32];
out.copy_from_slice(&raw);
return Ok(out);
}
use base64::Engine as _;
let trimmed = String::from_utf8_lossy(&raw).trim().to_string();
base64::engine::general_purpose::STANDARD
.decode(trimmed)
.ok()
.filter(|v| v.len() == 32)
.map(|v| {
let mut out = [0u8; 32];
out.copy_from_slice(&v);
out
})
.ok_or_else(|| {
format!(
"key {} must be 32 raw bytes or base64 of 32 bytes (got {} raw)",
path.display(),
raw.len()
)
})
}
fn utc_now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let day = secs / 86_400;
let sod = secs % 86_400;
let (h, m, s) = (sod / 3600, (sod / 60) % 60, sod % 60);
let (year, month, dom) = civil_from_days(day as i64);
format!("{year:04}-{month:02}-{dom:02}T{h:02}:{m:02}:{s:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
fn keygen(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
fs::create_dir_all(out_dir)?;
let mut rng = rand::rngs::OsRng;
let signing = SigningKey::generate(&mut rng);
let verifying = signing.verifying_key();
let priv_path = out_dir.join("nativ-ota.key");
let pub_path = out_dir.join("nativ-ota.pub");
fs::write(&priv_path, signing.to_bytes())?;
use base64::Engine as _;
let pub_b64 = base64::engine::general_purpose::STANDARD.encode(verifying.to_bytes());
fs::write(&pub_path, &pub_b64)?;
println!(
"Generated Ed25519 keypair:\n private: {}\n public: {}\n\n\
Add this to nativ.toml when OTA is enabled:\n [ota]\n enabled = true\n \
base_url = \"https://your-update-server.example\"\n public_key = \"{pub_b64}\"",
priv_path.display(),
pub_path.display()
);
println!("\nKeep nativ-ota.key secret. It signs every Live Update bundle for this app.");
Ok(())
}
fn pack(
dir: &Path,
key: &Path,
version: &str,
out_dir: Option<&Path>,
) -> Result<(), Box<dyn std::error::Error>> {
if !dir.is_dir() {
return Err(format!("bundle dir {} does not exist", dir.display()).into());
}
let key_bytes = read_key_bytes(key).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
let signing = SigningKey::from_bytes(&key_bytes);
let mut entries: Vec<(String, PathBuf)> = Vec::new();
collect_files(dir, dir, &mut entries)?;
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut files = Vec::with_capacity(entries.len());
for (rel, abs) in &entries {
let bytes = fs::read(abs)?;
files.push(ManifestFile {
path: rel.clone(),
sha256: sha256_hex(&bytes),
size: bytes.len() as u64,
});
}
let manifest = Manifest {
schema: MANIFEST_SCHEMA,
version: version.to_string(),
created_at: utc_now_rfc3339(),
files,
};
let manifest_bytes = manifest.to_canonical_bytes()?;
let signature: Signature = signing.sign(&manifest_bytes);
let out = out_dir.unwrap_or(dir);
fs::create_dir_all(out)?;
let manifest_path = out.join("manifest.json");
let sig_path = out.join("manifest.sig");
fs::write(&manifest_path, &manifest_bytes)?;
fs::write(&sig_path, signature.to_bytes())?;
println!(
"Packed {} file(s) into {} (v{}, schema v{})\n {}\n {}",
entries.len(),
dir.display(),
version,
MANIFEST_SCHEMA,
manifest_path.display(),
sig_path.display()
);
Ok(())
}
fn collect_files(root: &Path, cur: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> {
for entry in fs::read_dir(cur)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
if name == ".git" || name == "node_modules" {
continue;
}
collect_files(root, &path, out)?;
} else if path.is_file() {
let rel = path
.strip_prefix(root)
.map_err(|e| std::io::Error::other(e.to_string()))?;
let rel_str = rel.to_string_lossy().replace('\\', "/");
if rel_str == "manifest.json" || rel_str == "manifest.sig" {
continue;
}
out.push((rel_str, path));
}
}
Ok(())
}
fn verify(
manifest: &Path,
sig: &Path,
public_key: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let manifest_bytes = fs::read(manifest)?;
let sig_bytes = fs::read(sig)?;
let key_bytes =
read_key_bytes(public_key).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
if sig_bytes.len() != 64 {
return Err(format!(
"signature must be 64 raw bytes (got {}); did you pass a base64 file?",
sig_bytes.len()
)
.into());
}
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let signature = Signature::from_bytes(&sig_arr);
let verifying = VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| format!("invalid Ed25519 public key: {e}"))?;
verifying
.verify(&manifest_bytes, &signature)
.map_err(|e| format!("signature verification failed: {e}"))?;
let manifest: Manifest = serde_json::from_slice(&manifest_bytes)
.map_err(|e| format!("manifest is not valid JSON: {e}"))?;
println!(
"OK — manifest v{} (schema v{}), {} file(s), created {}",
manifest.version,
manifest.schema,
manifest.files.len(),
manifest.created_at
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn keygen_pack_verify_roundtrip() {
let tmp = tempdir().unwrap();
let key_dir = tmp.path().join("keys");
let bundle_dir = tmp.path().join("bundle");
fs::create_dir_all(&bundle_dir).unwrap();
fs::write(bundle_dir.join("en.json"), b"{\"hello\":\"world\"}").unwrap();
fs::write(bundle_dir.join("tr.json"), b"{\"hello\":\"dunya\"}").unwrap();
keygen(&key_dir).unwrap();
let priv_key = key_dir.join("nativ-ota.key");
let pub_key = key_dir.join("nativ-ota.pub");
assert!(priv_key.exists());
assert!(pub_key.exists());
pack(&bundle_dir, &priv_key, "1.2.3", None).unwrap();
let manifest_path = bundle_dir.join("manifest.json");
let sig_path = bundle_dir.join("manifest.sig");
assert!(manifest_path.exists());
assert!(sig_path.exists());
assert_eq!(fs::read(&sig_path).unwrap().len(), 64);
verify(&manifest_path, &sig_path, &pub_key).unwrap();
let manifest_bytes = fs::read(&manifest_path).unwrap();
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).unwrap();
assert_eq!(manifest.schema, MANIFEST_SCHEMA);
assert_eq!(manifest.version, "1.2.3");
assert_eq!(manifest.files.len(), 2);
assert!(manifest.files.iter().any(|f| f.path == "en.json"));
assert!(manifest.files.iter().any(|f| f.path == "tr.json"));
let en = manifest.files.iter().find(|f| f.path == "en.json").unwrap();
assert_eq!(en.size, 17);
assert_eq!(en.sha256, sha256_hex(b"{\"hello\":\"world\"}"));
}
#[test]
fn verify_rejects_tampered_manifest() {
let tmp = tempdir().unwrap();
let key_dir = tmp.path().join("keys");
let bundle_dir = tmp.path().join("bundle");
fs::create_dir_all(&bundle_dir).unwrap();
fs::write(bundle_dir.join("en.json"), b"original").unwrap();
keygen(&key_dir).unwrap();
pack(&bundle_dir, &key_dir.join("nativ-ota.key"), "1.0.0", None).unwrap();
let manifest_path = bundle_dir.join("manifest.json");
let mut manifest_bytes = fs::read(&manifest_path).unwrap();
manifest_bytes.push(b' ');
fs::write(&manifest_path, manifest_bytes).unwrap();
let err = verify(
&manifest_path,
&bundle_dir.join("manifest.sig"),
&key_dir.join("nativ-ota.pub"),
)
.unwrap_err();
assert!(err.to_string().contains("verification failed"));
}
#[test]
fn verify_rejects_wrong_public_key() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("bundle");
let signer_dir = tmp.path().join("signer");
let attacker_dir = tmp.path().join("attacker");
fs::create_dir_all(&bundle_dir).unwrap();
fs::write(bundle_dir.join("en.json"), b"hi").unwrap();
keygen(&signer_dir).unwrap();
keygen(&attacker_dir).unwrap();
pack(
&bundle_dir,
&signer_dir.join("nativ-ota.key"),
"1.0.0",
None,
)
.unwrap();
let err = verify(
&bundle_dir.join("manifest.json"),
&bundle_dir.join("manifest.sig"),
&attacker_dir.join("nativ-ota.pub"),
)
.unwrap_err();
assert!(err.to_string().contains("verification failed"));
}
#[test]
fn pack_skips_existing_manifest_outputs() {
let tmp = tempdir().unwrap();
let key_dir = tmp.path().join("keys");
let bundle_dir = tmp.path().join("bundle");
fs::create_dir_all(&bundle_dir).unwrap();
fs::write(bundle_dir.join("en.json"), b"x").unwrap();
fs::write(bundle_dir.join("manifest.json"), b"{}").unwrap();
fs::write(bundle_dir.join("manifest.sig"), [0u8; 64]).unwrap();
keygen(&key_dir).unwrap();
pack(&bundle_dir, &key_dir.join("nativ-ota.key"), "1.0.0", None).unwrap();
let manifest: Manifest =
serde_json::from_slice(&fs::read(bundle_dir.join("manifest.json")).unwrap()).unwrap();
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "en.json");
}
#[test]
fn pack_uses_forward_slash_paths() {
let tmp = tempdir().unwrap();
let key_dir = tmp.path().join("keys");
let bundle_dir = tmp.path().join("bundle");
let sub = bundle_dir.join("i18n");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("en.json"), b"x").unwrap();
keygen(&key_dir).unwrap();
pack(&bundle_dir, &key_dir.join("nativ-ota.key"), "1.0.0", None).unwrap();
let manifest: Manifest =
serde_json::from_slice(&fs::read(bundle_dir.join("manifest.json")).unwrap()).unwrap();
assert_eq!(manifest.files[0].path, "i18n/en.json");
}
#[test]
fn manifest_round_trips_through_json() {
let m = Manifest {
schema: MANIFEST_SCHEMA,
version: "2.0.0".into(),
created_at: "2026-01-01T00:00:00Z".into(),
files: vec![ManifestFile {
path: "i18n/en.json".into(),
sha256: "a".repeat(64),
size: 42,
}],
};
let bytes = m.to_canonical_bytes().unwrap();
let back: Manifest = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back.schema, m.schema);
assert_eq!(back.version, m.version);
assert_eq!(back.files.len(), 1);
}
#[test]
fn read_key_accepts_raw_and_base64() {
let tmp = tempdir().unwrap();
let raw_path = tmp.path().join("raw.key");
let b64_path = tmp.path().join("b64.key");
fs::write(&raw_path, [7u8; 32]).unwrap();
use base64::Engine as _;
fs::write(
&b64_path,
base64::engine::general_purpose::STANDARD.encode([7u8; 32]),
)
.unwrap();
assert_eq!(read_key_bytes(&raw_path).unwrap(), [7u8; 32]);
assert_eq!(read_key_bytes(&b64_path).unwrap(), [7u8; 32]);
}
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
let (y, m, d) = civil_from_days(20_629);
assert_eq!((y, m, d), (2026, 6, 25));
let (y, m, d) = civil_from_days(22_704);
assert_eq!((y, m, d), (2032, 2, 29));
}
}