diff --git a/keetanetwork-asn1/build.rs b/keetanetwork-asn1/build.rs index 5efbfd1..f65d994 100644 --- a/keetanetwork-asn1/build.rs +++ b/keetanetwork-asn1/build.rs @@ -1,12 +1,21 @@ +//! ISO 20022 schema refresh is optional on RO/docs.rs. +//! Rust outputs stay under `OUT_DIR`. +//! `src/generated.rs` stays the committed `include!` stub. + use std::env; use std::fs; -use std::path::Path; +use std::io::{self, ErrorKind}; +use std::path::{Path, PathBuf}; use serde_json::Value; use keetanetwork_utils::build::{compile_asn1_directory_with_full_config, Asn1CompileConfig}; fn main() { + println!("cargo:rerun-if-env-changed=DOCS_RS"); + println!("cargo:rerun-if-changed=asn1"); + println!("cargo:rerun-if-changed=oids.json"); + // Get OUT_DIR for generated files (required by cargo for publishable crates) let out_dir = env::var("OUT_DIR").expect("OUT_DIR must be set by cargo"); let generated_dir = Path::new(&out_dir).join("generated"); @@ -18,14 +27,23 @@ fn main() { .to_str() .expect("OUT_DIR path must be valid UTF-8"); - // Generate OID schema tokens - generate_schema(); - // Generate OIDs from JSON + let schema_content = render_iso20022_schema(); + let compile_asn_dir = stage_asn1_inputs(&generated_dir, &schema_content); + let compile_asn_dir_str = compile_asn_dir + .to_str() + .expect("OUT_DIR path must be valid UTF-8"); + refresh_source_iso20022_schema(&schema_content); generate_oids_from_json(generated_dir_str); - // Use OUT_DIR includes pattern for cargo publish compatibility - let config = Asn1CompileConfig::new("asn1", generated_dir_str) + let generated_rs_path = Path::new(&out_dir).join("generated.rs"); + let generated_rs_path_str = generated_rs_path + .to_str() + .expect("OUT_DIR path must be valid UTF-8"); + + let config = Asn1CompileConfig::new(compile_asn_dir_str, generated_dir_str) .with_out_dir_includes(true) + .with_generated_rs_path(generated_rs_path_str) + .with_clippy_fixes(false) .with_strip_prebuilt_methods(true) .with_methods_to_strip("algorithm_identifier_definitions", vec!["new"]) .with_methods_to_strip("subject_public_key_info_definitions", vec!["new"]) @@ -109,9 +127,8 @@ fn generate_sequence_fields_with_context_tags( } } -fn generate_schema() { +fn render_iso20022_schema() -> String { let oids = load_oids_json(); - let dest_path = Path::new("asn1").join("iso20022.asn"); let mut schema_content = String::new(); // Add ASN.1 module header @@ -137,19 +154,76 @@ fn generate_schema() { // Add module footer schema_content.push_str("END\n"); - // Ensure the asn1 directory exists - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).expect("Failed to create asn1 directory"); + ensure_single_newline_ending(&mut schema_content); + schema_content +} + +/// Copy `asn1/*.asn` into `OUT_DIR` and overlay the rendered ISO 20022 schema. +/// Compile reads this writable `OUT_DIR` tree so rasn always sees a writable asn1 input set. +fn stage_asn1_inputs(generated_dir: &Path, iso20022_schema: &str) -> PathBuf { + let staged = generated_dir.join("asn1"); + fs::create_dir_all(&staged).expect("OUT_DIR must be writable during build"); + + let source_dir = Path::new("asn1"); + let entries = fs::read_dir(source_dir).expect("asn1 inputs must be readable"); + for entry in entries { + let entry = entry.expect("asn1 entry must be readable"); + let path = entry.path(); + let Some(name) = path.file_name() else { + continue; + }; + if path.extension().and_then(|ext| ext.to_str()) != Some("asn") { + continue; + } + if name == "iso20022.asn" { + continue; + } + fs::copy(&path, staged.join(name)).expect("OUT_DIR must be writable during build"); } - ensure_single_newline_ending(&mut schema_content); + fs::write(staged.join("iso20022.asn"), iso20022_schema).expect("OUT_DIR must be writable during build"); + staged +} + +fn docs_rs_build() -> bool { + env::var_os("DOCS_RS").is_some() +} + +fn is_source_ro_error(err: &io::Error) -> bool { + matches!(err.kind(), ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) +} - // Only write when the generated schema actually differs. +/// Refresh crate-source `asn1/iso20022.asn` when that tree is writable. +/// On docs.rs or a read-only source error, keep the committed input and return. +fn refresh_source_iso20022_schema(schema_content: &str) { + if docs_rs_build() { + return; + } + + let dest_path = Path::new("asn1").join("iso20022.asn"); let unchanged = fs::read_to_string(&dest_path) .map(|existing| existing == schema_content) .unwrap_or(false); - if !unchanged { - fs::write(&dest_path, schema_content).expect("Failed to write iso20022.asn"); + if unchanged { + return; + } + + if let Some(parent) = dest_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + if is_source_ro_error(&e) { + println!("cargo:warning=skipping iso20022.asn refresh: {e}"); + return; + } + panic!("Failed to create asn1 directory: {e}"); + } + } + + match fs::write(&dest_path, schema_content) { + Ok(()) => {} + Err(e) if is_source_ro_error(&e) => { + println!("cargo:warning=skipping iso20022.asn refresh: {e}"); + } + Err(e) => panic!("Failed to write iso20022.asn: {e}"), } } diff --git a/keetanetwork-asn1/src/generated.rs b/keetanetwork-asn1/src/generated.rs index e2e481f..ccc0bbd 100644 --- a/keetanetwork-asn1/src/generated.rs +++ b/keetanetwork-asn1/src/generated.rs @@ -4,7 +4,8 @@ //! This module contains all the generated ASN.1 structures and re-exports them //! for use throughout the library. //! -//! This file is automatically generated by build.rs - do not edit manually. +//! This file is a committed `include!` stub. +//! Module bodies are generated under `OUT_DIR`. #[allow(unused_imports, unused_variables, dead_code, non_camel_case_types, clippy::too_many_arguments)] mod algorithm_identifier_definitions { diff --git a/keetanetwork-asn1/tests/docsrs_readonly.rs b/keetanetwork-asn1/tests/docsrs_readonly.rs new file mode 100644 index 0000000..37c1961 --- /dev/null +++ b/keetanetwork-asn1/tests/docsrs_readonly.rs @@ -0,0 +1,138 @@ +//! Docs.rs sets `DOCS_RS` and mounts crate source read-only. +//! This test stages a writable `OUT_DIR` build. +//! It asserts `cargo build -p keetanetwork-asn1` succeeds because ASN.1 staging +//! and generated Rust stay under `OUT_DIR`. + +#![cfg(all(feature = "std", feature = "rasn", feature = "serde"))] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const WORKSPACE_ROOT_FILES: &[&str] = &["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "rustfmt.toml"]; + +#[test] +fn docsrs_readonly_source_allows_asn1_build() { + let Ok(crate_root) = PathBuf::from(env!("CARGO_MANIFEST_DIR")).canonicalize() else { + panic!("CARGO_MANIFEST_DIR must canonicalize"); + }; + let Some(workspace) = crate_root.parent() else { + panic!("keetanetwork-asn1 must live in the workspace"); + }; + + let scratch = Scratch::create(); + stage_workspace(workspace, &crate_root, &scratch.path); + + // Force the `fs::write` branch: unchanged content would skip the source write. + let schema = scratch.path.join("keetanetwork-asn1/asn1/iso20022.asn"); + fs::write(&schema, "stale schema to force generate_schema write\n") + .expect("scratch schema must be writable before chmod"); + + chmod_a_minus_w(scratch.path.join("keetanetwork-asn1")); + + let output = cargo_docsrs_build(&scratch.path); + let log = command_log(&output); + assert!(output.status.success(), "DOCS_RS=1 + read-only crate source must not panic in build.rs; got:\n{log}"); +} + +fn stage_workspace(workspace: &Path, crate_root: &Path, scratch: &Path) { + for name in WORKSPACE_ROOT_FILES { + fs::copy(workspace.join(name), scratch.join(name)).expect("workspace root file must copy"); + } + + let cargo_dir = scratch.join(".cargo"); + fs::create_dir_all(&cargo_dir).expect("scratch .cargo must exist"); + fs::copy(workspace.join(".cargo/config.toml"), cargo_dir.join("config.toml")) + .expect(".cargo/config.toml must copy"); + + copy_tree(crate_root, &scratch.join("keetanetwork-asn1")); + link_sibling_members(workspace, scratch); +} + +fn link_sibling_members(workspace: &Path, scratch: &Path) { + let entries = fs::read_dir(workspace).expect("workspace must be readable"); + for entry in entries { + let entry = entry.expect("workspace entry must be readable"); + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("keetanetwork-") || name == "keetanetwork-asn1" { + continue; + } + let src = entry.path(); + if !src.is_dir() { + continue; + } + std::os::unix::fs::symlink(src, scratch.join(name)).expect("sibling member must symlink"); + } +} + +fn copy_tree(from: &Path, to: &Path) { + fs::create_dir_all(to).expect("destination directory must exist"); + let entries = fs::read_dir(from).expect("source tree must be readable"); + for entry in entries { + let entry = entry.expect("source entry must be readable"); + let src = entry.path(); + let dest = to.join(entry.file_name()); + if src.is_dir() { + copy_tree(&src, &dest); + continue; + } + fs::copy(&src, &dest).expect("source file must copy"); + } +} + +fn cargo_docsrs_build(scratch: &Path) -> Output { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + Command::new(cargo) + .args(["build", "-p", "keetanetwork-asn1", "--manifest-path"]) + .arg(scratch.join("Cargo.toml")) + .env("DOCS_RS", "1") + .env("CARGO_TARGET_DIR", scratch.join("target")) + .env_remove("CARGO_BUILD_TARGET") + .output() + .expect("cargo build must spawn") +} + +fn chmod_a_minus_w(path: PathBuf) { + let status = Command::new("chmod") + .args(["-R", "a-w"]) + .arg(&path) + .status() + .expect("chmod must spawn"); + assert!(status.success(), "chmod a-w must succeed on {}", path.display()); +} + +fn command_log(output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!("status={:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", output.status.code()) +} + +struct Scratch { + path: PathBuf, +} + +impl Scratch { + fn create() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!("keetanetwork-asn1-docsrs-ro-{}-{nanos}", std::process::id())); + fs::create_dir_all(&path).expect("scratch directory must exist"); + Self { path } + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = Command::new("chmod") + .args(["-R", "u+w"]) + .arg(&self.path) + .status(); + let _ = fs::remove_dir_all(&self.path); + } +}