//! Mandatory license acceptance and operational guidance (port of //! `legal/licenseAcceptance.js` + `guidanceDocuments.js`). A new version, build, //! license text or guidance text always requires a fresh acceptance. use crate::core::error::{CoreError, CoreResult}; use crate::core::storage::atomic_write_json; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; pub const LICENSE_NAME: &str = "CH-J Proprietary Software License"; pub const LICENSE_VERSION: &str = "1.14"; pub const GUIDANCE_FILES: [(&str, &str); 3] = [ ("cs", "provozni-bezpecnost-a-zalohovani.md"), ("de", "betriebssicherheit-und-datensicherung.md"), ("en", "operational-safety-and-backup-guidance.md"), ]; pub struct LicenseAcceptance { file: PathBuf, pub text: String, pub guidance: Map, pub identity: Map, } fn sha256_hex(data: &[u8]) -> String { hex::encode(Sha256::digest(data)) } pub fn load_guidance(docs_root: &Path) -> CoreResult<(Map, String)> { let mut documents = Map::new(); for (language, file) in GUIDANCE_FILES { let text = fs::read_to_string(docs_root.join(file)) .map_err(|_| CoreError::plain(format!("Missing or incomplete operational guidance: {file}")))?; if text.encode_utf16().count() < 1000 { return Err(CoreError::plain(format!("Missing or incomplete operational guidance: {file}"))); } documents.insert(language.to_string(), Value::String(text)); } // Same digest as 0.1.0: SHA-256 of JSON.stringify({cs, de, en}). let digest = sha256_hex(serde_json::to_string(&Value::Object(documents.clone()))?.as_bytes()); Ok((documents, digest)) } impl LicenseAcceptance { pub fn new(storage_root: &Path, license_path: &Path, docs_root: &Path, app_version: &str, build_id: &str) -> CoreResult { let text = fs::read_to_string(license_path).map_err(|_| CoreError::plain("The complete CH-J application license 1.14 is required."))?; let header_ok = regex::Regex::new(r"^CH-J\s+Proprietary Software License\s*\n\s*Version 1\.14\b") .map(|pattern| pattern.is_match(&text)) .unwrap_or(false); if !header_ok || text.encode_utf16().count() < 10000 || !text.trim_end().ends_with("End of License.") { return Err(CoreError::plain("The complete CH-J application license 1.14 is required.")); } let (guidance, guidance_sha) = load_guidance(docs_root)?; let mut identity = Map::new(); identity.insert("license".into(), json!(LICENSE_NAME)); identity.insert("licenseVersion".into(), json!(LICENSE_VERSION)); identity.insert("sha256".into(), json!(sha256_hex(text.as_bytes()))); identity.insert("appVersion".into(), json!(app_version)); identity.insert("buildId".into(), json!(build_id)); identity.insert("guidanceSha256".into(), json!(guidance_sha)); Ok(Self { file: storage_root.join("legal").join("license-acceptance.json"), text, guidance, identity }) } pub fn identity_value(&self, key: &str) -> &str { self.identity.get(key).and_then(Value::as_str).unwrap_or("") } pub fn is_accepted(&self) -> bool { let Ok(raw) = fs::read_to_string(&self.file) else { return false }; let Ok(record) = serde_json::from_str::(&raw) else { return false }; record.get("schemaVersion").and_then(Value::as_f64) == Some(1.0) && record.get("accepted") == Some(&Value::Bool(true)) && record.get("guidanceDisplayed") == Some(&Value::Bool(true)) && record.get("acceptedAt").and_then(Value::as_str).map(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()).unwrap_or(false) && self.identity.iter().all(|(key, value)| record.get(key) == Some(value)) } pub fn accept(&self, payload: &Value, guidance_shown: bool) -> CoreResult<()> { if payload.get("accepted") != Some(&Value::Bool(true)) || payload.get("sha256").and_then(Value::as_str) != Some(self.identity_value("sha256")) || !guidance_shown || payload.get("guidanceSha256").and_then(Value::as_str) != Some(self.identity_value("guidanceSha256")) { return Err(CoreError::plain("Explicit acceptance of the displayed license is required.")); } let mut record = Map::new(); record.insert("schemaVersion".into(), json!(1)); for (key, value) in &self.identity { record.insert(key.clone(), value.clone()); } record.insert("accepted".into(), json!(true)); record.insert("guidanceDisplayed".into(), json!(true)); record.insert("acceptedAt".into(), json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true))); atomic_write_json(&self.file, &Value::Object(record)) } } #[cfg(test)] mod tests { use super::*; fn fixture(dir: &Path) -> (PathBuf, PathBuf) { let license = dir.join("LICENSE"); let mut text = String::from("CH-J Proprietary Software License\nVersion 1.14\n\n"); text.push_str(&"x".repeat(10050)); text.push_str("\nEnd of License.\n"); fs::write(&license, text).unwrap(); let docs = dir.join("docs"); fs::create_dir_all(&docs).unwrap(); for (_, file) in GUIDANCE_FILES { fs::write(docs.join(file), "g".repeat(1200)).unwrap(); } (license, docs) } #[test] fn acceptance_is_bound_to_identity() { let dir = tempfile::tempdir().unwrap(); let (license, docs) = fixture(dir.path()); let acceptance = LicenseAcceptance::new(dir.path(), &license, &docs, "0.1.1", "build-a").unwrap(); assert!(!acceptance.is_accepted()); let payload = json!({"accepted": true, "sha256": acceptance.identity_value("sha256"), "guidanceSha256": acceptance.identity_value("guidanceSha256")}); assert!(acceptance.accept(&payload, false).is_err()); acceptance.accept(&payload, true).unwrap(); assert!(acceptance.is_accepted()); let next_build = LicenseAcceptance::new(dir.path(), &license, &docs, "0.1.1", "build-b").unwrap(); assert!(!next_build.is_accepted()); } #[test] fn incomplete_license_is_rejected() { let dir = tempfile::tempdir().unwrap(); let (_, docs) = fixture(dir.path()); let short = dir.path().join("SHORT"); fs::write(&short, "CH-J Proprietary Software License\nVersion 1.14\nEnd of License.").unwrap(); assert!(LicenseAcceptance::new(dir.path(), &short, &docs, "0.1.1", "b").is_err()); } }