From bb1a9c38cf852afaeb059609a377104b4e30f81b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 21:14:43 +0000 Subject: [PATCH 1/3] test(asn1): reproduce docs.rs read-only generate_schema panic Add a failing integration test that runs the keetanetwork-asn1 build under DOCS_RS=1 with a chmod a-w crate source tree, matching the docs.rs sandbox. generate_schema() still writes asn1/iso20022.asn and panics with Permission denied (os error 13), the chmod equivalent of Read-only file system (os error 30). No production fix. Co-authored-by: Tanveer Wahid --- keetanetwork-asn1/tests/docsrs_readonly.rs | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 keetanetwork-asn1/tests/docsrs_readonly.rs diff --git a/keetanetwork-asn1/tests/docsrs_readonly.rs b/keetanetwork-asn1/tests/docsrs_readonly.rs new file mode 100644 index 0000000..41738c5 --- /dev/null +++ b/keetanetwork-asn1/tests/docsrs_readonly.rs @@ -0,0 +1,138 @@ +//! docs.rs sets `DOCS_RS=1` and mounts crate source read-only. +//! +//! `generate_schema()` still writes `asn1/iso20022.asn` under the crate root, so +//! rustdoc fails before any crate docs are emitted. This test drives that path. + +#![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); + } +} From 12b519a4278f883ee645041e2ade84b356aa84ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 21:26:20 +0000 Subject: [PATCH 2/3] fix(asn1): skip source writes on docs.rs and read-only trees generate_schema() no longer panics when asn1/iso20022.asn is not writable. Rendered schema and other .asn inputs are staged under OUT_DIR for compile. src/generated.rs stays a committed include! stub; build.rs writes the helper generated.rs only under OUT_DIR. Co-authored-by: Tanveer Wahid --- keetanetwork-asn1/build.rs | 103 ++++++++++++++++++++++++----- keetanetwork-asn1/src/generated.rs | 3 +- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/keetanetwork-asn1/build.rs b/keetanetwork-asn1/build.rs index 5efbfd1..9284343 100644 --- a/keetanetwork-asn1/build.rs +++ b/keetanetwork-asn1/build.rs @@ -1,12 +1,17 @@ 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 +23,26 @@ fn main() { .to_str() .expect("OUT_DIR path must be valid UTF-8"); - // Generate OID schema tokens - generate_schema(); - // Generate OIDs from JSON + // ISO 20022 schema refresh is optional on RO/docs.rs. Rust outputs stay + // under OUT_DIR. src/generated.rs is a committed include! stub and is + // not rewritten. + 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 +126,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 +153,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 tree so RO crate source cannot break rasn. +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` only when that tree is writable. +/// docs.rs and PermissionDenied keep the committed input and do not panic. +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..3266e42 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. +//! Committed `include!` stub. Module bodies are generated under `OUT_DIR`. +//! `build.rs` does not rewrite this file. #[allow(unused_imports, unused_variables, dead_code, non_camel_case_types, clippy::too_many_arguments)] mod algorithm_identifier_definitions { From b5390b1fdffc5faa7e4146cb3192c7c63fb04b74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 21:34:11 +0000 Subject: [PATCH 3/3] docs(asn1): restate docs.rs RO comments as positive contracts Reword rustdocs on the docs.rs read-only path to STE positive contracts. No behavior change. Co-authored-by: Tanveer Wahid --- keetanetwork-asn1/build.rs | 15 ++++++++------- keetanetwork-asn1/src/generated.rs | 4 ++-- keetanetwork-asn1/tests/docsrs_readonly.rs | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/keetanetwork-asn1/build.rs b/keetanetwork-asn1/build.rs index 9284343..f65d994 100644 --- a/keetanetwork-asn1/build.rs +++ b/keetanetwork-asn1/build.rs @@ -1,3 +1,7 @@ +//! 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::io::{self, ErrorKind}; @@ -23,9 +27,6 @@ fn main() { .to_str() .expect("OUT_DIR path must be valid UTF-8"); - // ISO 20022 schema refresh is optional on RO/docs.rs. Rust outputs stay - // under OUT_DIR. src/generated.rs is a committed include! stub and is - // not rewritten. 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 @@ -157,8 +158,8 @@ fn render_iso20022_schema() -> String { schema_content } -/// Copy `asn1/*.asn` into OUT_DIR and overlay the rendered ISO 20022 schema. -/// Compile reads this writable tree so RO crate source cannot break rasn. +/// 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"); @@ -192,8 +193,8 @@ fn is_source_ro_error(err: &io::Error) -> bool { matches!(err.kind(), ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) } -/// Refresh crate-source `asn1/iso20022.asn` only when that tree is writable. -/// docs.rs and PermissionDenied keep the committed input and do not panic. +/// 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; diff --git a/keetanetwork-asn1/src/generated.rs b/keetanetwork-asn1/src/generated.rs index 3266e42..ccc0bbd 100644 --- a/keetanetwork-asn1/src/generated.rs +++ b/keetanetwork-asn1/src/generated.rs @@ -4,8 +4,8 @@ //! This module contains all the generated ASN.1 structures and re-exports them //! for use throughout the library. //! -//! Committed `include!` stub. Module bodies are generated under `OUT_DIR`. -//! `build.rs` does not rewrite this file. +//! 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 index 41738c5..37c1961 100644 --- a/keetanetwork-asn1/tests/docsrs_readonly.rs +++ b/keetanetwork-asn1/tests/docsrs_readonly.rs @@ -1,7 +1,7 @@ -//! docs.rs sets `DOCS_RS=1` and mounts crate source read-only. -//! -//! `generate_schema()` still writes `asn1/iso20022.asn` under the crate root, so -//! rustdoc fails before any crate docs are emitted. This test drives that path. +//! 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"))]