Skip to main content

aptos_sdk/codegen/
build_helper.rs

1//! Build script helper for code generation.
2//!
3//! This module provides utilities for generating code at compile time via `build.rs`.
4//!
5//! # Example
6//!
7//! Add to your `build.rs`:
8//!
9//! ```rust,ignore
10//! use aptos_sdk::codegen::build_helper;
11//!
12//! fn main() {
13//!     // Generate from a local ABI file
14//!     build_helper::generate_from_abi(
15//!         "abi/my_module.json",
16//!         "src/generated/",
17//!     ).expect("code generation failed");
18//!
19//!     // Generate from multiple modules
20//!     build_helper::generate_from_abis(&[
21//!         "abi/coin.json",
22//!         "abi/token.json",
23//!     ], "src/generated/").expect("code generation failed");
24//!
25//!     // Rerun if ABI files change
26//!     println!("cargo:rerun-if-changed=abi/");
27//! }
28//! ```
29//!
30//! # Directory Structure
31//!
32//! ```text
33//! my_project/
34//! ├── build.rs
35//! ├── abi/
36//! │   ├── my_module.json
37//! │   └── another_module.json
38//! └── src/
39//!     └── generated/
40//!         ├── mod.rs          (auto-generated)
41//!         ├── my_module.rs
42//!         └── another_module.rs
43//! ```
44
45use crate::api::response::MoveModuleABI;
46use crate::codegen::{GeneratorConfig, ModuleGenerator, MoveSourceParser};
47use crate::error::{AptosError, AptosResult};
48use std::fs;
49use std::path::Path;
50
51/// Returns true if `name` is a Rust keyword that cannot be used as a module name.
52fn is_rust_keyword(name: &str) -> bool {
53    matches!(
54        name,
55        "as" | "break"
56            | "const"
57            | "continue"
58            | "crate"
59            | "else"
60            | "enum"
61            | "extern"
62            | "false"
63            | "fn"
64            | "for"
65            | "if"
66            | "impl"
67            | "in"
68            | "let"
69            | "loop"
70            | "match"
71            | "mod"
72            | "move"
73            | "mut"
74            | "pub"
75            | "ref"
76            | "return"
77            | "self"
78            | "Self"
79            | "static"
80            | "struct"
81            | "super"
82            | "trait"
83            | "true"
84            | "type"
85            | "unsafe"
86            | "use"
87            | "where"
88            | "while"
89            | "async"
90            | "await"
91            | "dyn"
92    )
93}
94
95/// Validates that a module name is a safe Rust identifier (no path traversal, injection, or keywords).
96///
97/// # Security
98///
99/// This prevents:
100/// - Path traversal attacks via names like `../../../tmp/evil`
101/// - Invalid `pub mod` declarations in generated mod.rs (e.g., `pub mod fn;`)
102fn validate_module_name(name: &str) -> AptosResult<()> {
103    if name.is_empty() {
104        return Err(AptosError::Config(
105            "module name cannot be empty".to_string(),
106        ));
107    }
108
109    // Must be a valid Rust identifier: starts with letter or underscore,
110    // contains only alphanumeric or underscore characters
111    let mut chars = name.chars();
112    let first = chars.next().unwrap(); // safe: name is non-empty
113    if !first.is_ascii_alphabetic() && first != '_' {
114        return Err(AptosError::Config(format!(
115            "invalid module name '{name}': must start with a letter or underscore"
116        )));
117    }
118
119    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
120        return Err(AptosError::Config(format!(
121            "invalid module name '{name}': must contain only ASCII alphanumeric characters or underscores"
122        )));
123    }
124
125    if is_rust_keyword(name) {
126        return Err(AptosError::Config(format!(
127            "invalid module name '{name}': Rust keywords cannot be used as module names"
128        )));
129    }
130
131    Ok(())
132}
133
134/// Configuration for build-time code generation.
135#[derive(Debug, Clone)]
136pub struct BuildConfig {
137    /// Generator configuration.
138    pub generator_config: GeneratorConfig,
139    /// Whether to generate a `mod.rs` file.
140    pub generate_mod_file: bool,
141    /// Whether to print build instructions to cargo.
142    pub print_cargo_instructions: bool,
143}
144
145impl Default for BuildConfig {
146    fn default() -> Self {
147        Self {
148            generator_config: GeneratorConfig::default(),
149            generate_mod_file: true,
150            print_cargo_instructions: true,
151        }
152    }
153}
154
155impl BuildConfig {
156    /// Creates a new build configuration.
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Sets whether to generate a mod.rs file.
163    #[must_use]
164    pub fn with_mod_file(mut self, enabled: bool) -> Self {
165        self.generate_mod_file = enabled;
166        self
167    }
168
169    /// Sets the generator configuration.
170    #[must_use]
171    pub fn with_generator_config(mut self, config: GeneratorConfig) -> Self {
172        self.generator_config = config;
173        self
174    }
175
176    /// Sets whether to print cargo instructions.
177    #[must_use]
178    pub fn with_cargo_instructions(mut self, enabled: bool) -> Self {
179        self.print_cargo_instructions = enabled;
180        self
181    }
182}
183
184/// Generates Rust code from a single ABI file.
185///
186/// # Arguments
187///
188/// * `abi_path` - Path to the ABI JSON file
189/// * `output_dir` - Directory where generated code will be written
190///
191/// # Errors
192///
193/// Returns an error if:
194/// * The ABI file cannot be read
195/// * The ABI JSON cannot be parsed
196/// * Code generation fails
197/// * The output directory cannot be created
198/// * The output file cannot be written
199///
200/// # Example
201///
202/// ```rust,ignore
203/// build_helper::generate_from_abi("abi/coin.json", "src/generated/")?;
204/// ```
205pub fn generate_from_abi(
206    abi_path: impl AsRef<Path>,
207    output_dir: impl AsRef<Path>,
208) -> AptosResult<()> {
209    generate_from_abi_with_config(abi_path, output_dir, BuildConfig::default())
210}
211
212/// Generates Rust code from a single ABI file with custom configuration.
213///
214/// # Errors
215///
216/// Returns an error if:
217/// * The ABI file cannot be read
218/// * The ABI JSON cannot be parsed
219/// * Code generation fails
220/// * The output directory cannot be created
221/// * The output file cannot be written
222pub fn generate_from_abi_with_config(
223    abi_path: impl AsRef<Path>,
224    output_dir: impl AsRef<Path>,
225    config: BuildConfig,
226) -> AptosResult<()> {
227    let abi_path = abi_path.as_ref();
228    let output_dir = output_dir.as_ref();
229
230    // Read and parse ABI
231    let abi_content = fs::read_to_string(abi_path).map_err(|e| {
232        AptosError::Config(format!(
233            "Failed to read ABI file {}: {}",
234            abi_path.display(),
235            e
236        ))
237    })?;
238
239    let abi: MoveModuleABI = serde_json::from_str(&abi_content)
240        .map_err(|e| AptosError::Config(format!("Failed to parse ABI JSON: {e}")))?;
241
242    // SECURITY: Validate module name to prevent path traversal
243    validate_module_name(&abi.name)?;
244
245    // Generate code
246    let generator = ModuleGenerator::new(&abi, config.generator_config);
247    let code = generator.generate()?;
248
249    // Create output directory
250    fs::create_dir_all(output_dir)
251        .map_err(|e| AptosError::Config(format!("Failed to create output directory: {e}")))?;
252
253    // Write output file
254    let output_filename = format!("{}.rs", abi.name);
255    let output_path = output_dir.join(&output_filename);
256
257    fs::write(&output_path, &code)
258        .map_err(|e| AptosError::Config(format!("Failed to write output file: {e}")))?;
259
260    if config.print_cargo_instructions {
261        println!("cargo:rerun-if-changed={}", abi_path.display());
262    }
263
264    Ok(())
265}
266
267/// Generates Rust code from multiple ABI files.
268///
269/// Also generates a `mod.rs` file that re-exports all generated modules.
270///
271/// # Arguments
272///
273/// * `abi_paths` - Paths to ABI JSON files
274/// * `output_dir` - Directory where generated code will be written
275///
276/// # Errors
277///
278/// Returns an error if:
279/// * Any ABI file cannot be read
280/// * Any ABI JSON cannot be parsed
281/// * Code generation fails for any module
282/// * The output directory cannot be created
283/// * Any output file cannot be written
284/// * The `mod.rs` file cannot be written
285///
286/// # Example
287///
288/// ```rust,ignore
289/// build_helper::generate_from_abis(&[
290///     "abi/coin.json",
291///     "abi/token.json",
292/// ], "src/generated/")?;
293/// ```
294pub fn generate_from_abis(
295    abi_paths: &[impl AsRef<Path>],
296    output_dir: impl AsRef<Path>,
297) -> AptosResult<()> {
298    generate_from_abis_with_config(abi_paths, output_dir, &BuildConfig::default())
299}
300
301/// Generates Rust code from multiple ABI files with custom configuration.
302///
303/// # Errors
304///
305/// Returns an error if:
306/// * Any ABI file cannot be read
307/// * Any ABI JSON cannot be parsed
308/// * Code generation fails for any module
309/// * The output directory cannot be created
310/// * Any output file cannot be written
311/// * The `mod.rs` file cannot be written (if enabled)
312pub fn generate_from_abis_with_config(
313    abi_paths: &[impl AsRef<Path>],
314    output_dir: impl AsRef<Path>,
315    config: &BuildConfig,
316) -> AptosResult<()> {
317    let output_dir = output_dir.as_ref();
318    let mut module_names = Vec::new();
319
320    // Generate code for each ABI
321    for abi_path in abi_paths {
322        let abi_path = abi_path.as_ref();
323
324        let abi_content = fs::read_to_string(abi_path).map_err(|e| {
325            AptosError::Config(format!(
326                "Failed to read ABI file {}: {}",
327                abi_path.display(),
328                e
329            ))
330        })?;
331
332        let abi: MoveModuleABI = serde_json::from_str(&abi_content).map_err(|e| {
333            AptosError::Config(format!(
334                "Failed to parse ABI JSON from {}: {}",
335                abi_path.display(),
336                e
337            ))
338        })?;
339
340        // SECURITY: Validate module name to prevent path traversal
341        validate_module_name(&abi.name)?;
342
343        let generator = ModuleGenerator::new(&abi, config.generator_config.clone());
344        let code = generator.generate()?;
345
346        // Create output directory
347        fs::create_dir_all(output_dir)
348            .map_err(|e| AptosError::Config(format!("Failed to create output directory: {e}")))?;
349
350        // Write output file
351        let output_filename = format!("{}.rs", abi.name);
352        let output_path = output_dir.join(&output_filename);
353
354        fs::write(&output_path, &code)
355            .map_err(|e| AptosError::Config(format!("Failed to write output file: {e}")))?;
356
357        module_names.push(abi.name);
358
359        if config.print_cargo_instructions {
360            println!("cargo:rerun-if-changed={}", abi_path.display());
361        }
362    }
363
364    // Generate mod.rs
365    if config.generate_mod_file && !module_names.is_empty() {
366        let mod_content = generate_mod_file(&module_names);
367        let mod_path = output_dir.join("mod.rs");
368
369        fs::write(&mod_path, mod_content)
370            .map_err(|e| AptosError::Config(format!("Failed to write mod.rs: {e}")))?;
371    }
372
373    Ok(())
374}
375
376/// Generates Rust code from an ABI file with Move source for better names.
377///
378/// # Arguments
379///
380/// * `abi_path` - Path to the ABI JSON file
381/// * `source_path` - Path to the Move source file
382/// * `output_dir` - Directory where generated code will be written
383///
384/// # Errors
385///
386/// Returns an error if:
387/// * The ABI file cannot be read
388/// * The ABI JSON cannot be parsed
389/// * The Move source file cannot be read
390/// * Code generation fails
391/// * The output directory cannot be created
392/// * The output file cannot be written
393pub fn generate_from_abi_with_source(
394    abi_path: impl AsRef<Path>,
395    source_path: impl AsRef<Path>,
396    output_dir: impl AsRef<Path>,
397) -> AptosResult<()> {
398    let abi_path = abi_path.as_ref();
399    let source_path = source_path.as_ref();
400    let output_dir = output_dir.as_ref();
401
402    // Read and parse ABI
403    let abi_content = fs::read_to_string(abi_path)
404        .map_err(|e| AptosError::Config(format!("Failed to read ABI file: {e}")))?;
405
406    let abi: MoveModuleABI = serde_json::from_str(&abi_content)
407        .map_err(|e| AptosError::Config(format!("Failed to parse ABI JSON: {e}")))?;
408
409    // Read and parse Move source
410    let source_content = fs::read_to_string(source_path)
411        .map_err(|e| AptosError::Config(format!("Failed to read Move source: {e}")))?;
412
413    let source_info = MoveSourceParser::parse(&source_content);
414
415    // SECURITY: Validate module name to prevent path traversal
416    validate_module_name(&abi.name)?;
417
418    // Generate code
419    let generator =
420        ModuleGenerator::new(&abi, GeneratorConfig::default()).with_source_info(source_info);
421    let code = generator.generate()?;
422
423    // Create output directory
424    fs::create_dir_all(output_dir)
425        .map_err(|e| AptosError::Config(format!("Failed to create output directory: {e}")))?;
426
427    // Write output file
428    let output_filename = format!("{}.rs", abi.name);
429    let output_path = output_dir.join(&output_filename);
430
431    fs::write(&output_path, &code)
432        .map_err(|e| AptosError::Config(format!("Failed to write output file: {e}")))?;
433
434    println!("cargo:rerun-if-changed={}", abi_path.display());
435    println!("cargo:rerun-if-changed={}", source_path.display());
436
437    Ok(())
438}
439
440/// Generates a mod.rs file for the given module names.
441fn generate_mod_file(module_names: &[String]) -> String {
442    use std::fmt::Write as _;
443    let mut content = String::new();
444    let _ = writeln!(&mut content, "//! Auto-generated module exports.");
445    let _ = writeln!(&mut content, "//!");
446    let _ = writeln!(
447        &mut content,
448        "//! This file was auto-generated by aptos-sdk codegen."
449    );
450    let _ = writeln!(&mut content, "//! Do not edit manually.");
451    let _ = writeln!(&mut content);
452
453    for name in module_names {
454        // SECURITY: Module names are validated by validate_module_name() before reaching here,
455        // but double-check they are safe identifiers to prevent code injection in mod.rs
456        debug_assert!(
457            !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
458            "module name should have been validated"
459        );
460        let _ = writeln!(&mut content, "pub mod {name};");
461    }
462    let _ = writeln!(&mut content);
463
464    // Re-export all modules
465    let _ = writeln!(&mut content, "// Re-exports for convenience");
466    for name in module_names {
467        let _ = writeln!(&mut content, "pub use {name}::*;");
468    }
469
470    content
471}
472
473/// Scans a directory for ABI files and generates code for all of them.
474///
475/// # Arguments
476///
477/// * `abi_dir` - Directory containing ABI JSON files
478/// * `output_dir` - Directory where generated code will be written
479///
480/// # Errors
481///
482/// Returns an error if:
483/// * The directory cannot be read
484/// * No JSON files are found in the directory
485/// * Any ABI file cannot be read or parsed
486/// * Code generation fails for any module
487/// * The output directory cannot be created
488/// * Any output file cannot be written
489///
490/// # Example
491///
492/// ```rust,ignore
493/// build_helper::generate_from_directory("abi/", "src/generated/")?;
494/// ```
495pub fn generate_from_directory(
496    abi_dir: impl AsRef<Path>,
497    output_dir: impl AsRef<Path>,
498) -> AptosResult<()> {
499    let abi_dir = abi_dir.as_ref();
500
501    let entries = fs::read_dir(abi_dir)
502        .map_err(|e| AptosError::Config(format!("Failed to read ABI directory: {e}")))?;
503
504    let abi_paths: Vec<_> = entries
505        .filter_map(Result::ok)
506        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
507        .map(|e| e.path())
508        .collect();
509
510    if abi_paths.is_empty() {
511        return Err(AptosError::Config(format!(
512            "No JSON files found in {}",
513            abi_dir.display()
514        )));
515    }
516
517    // Convert PathBuf to Path references for the function
518    let path_refs: Vec<&Path> = abi_paths.iter().map(std::path::PathBuf::as_path).collect();
519    generate_from_abis(&path_refs, output_dir)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::io::Write;
526    use tempfile::TempDir;
527
528    fn sample_abi_json() -> &'static str {
529        r#"{
530            "address": "0x1",
531            "name": "coin",
532            "exposed_functions": [
533                {
534                    "name": "transfer",
535                    "visibility": "public",
536                    "is_entry": true,
537                    "is_view": false,
538                    "generic_type_params": [{"constraints": []}],
539                    "params": ["&signer", "address", "u64"],
540                    "return": []
541                }
542            ],
543            "structs": []
544        }"#
545    }
546
547    #[test]
548    fn test_generate_from_abi() {
549        let temp_dir = TempDir::new().unwrap();
550        let abi_path = temp_dir.path().join("coin.json");
551        let output_dir = temp_dir.path().join("generated");
552
553        // Write sample ABI
554        let mut file = fs::File::create(&abi_path).unwrap();
555        file.write_all(sample_abi_json().as_bytes()).unwrap();
556
557        // Generate
558        let config = BuildConfig::new().with_cargo_instructions(false);
559        generate_from_abi_with_config(&abi_path, &output_dir, config).unwrap();
560
561        // Verify output exists
562        let output_path = output_dir.join("coin.rs");
563        assert!(output_path.exists());
564
565        // Verify content
566        let content = fs::read_to_string(&output_path).unwrap();
567        assert!(content.contains("Generated Rust bindings"));
568        assert!(content.contains("pub fn transfer"));
569    }
570
571    #[test]
572    fn test_generate_mod_file() {
573        let modules = vec!["coin".to_string(), "token".to_string()];
574        let mod_content = generate_mod_file(&modules);
575
576        assert!(mod_content.contains("pub mod coin;"));
577        assert!(mod_content.contains("pub mod token;"));
578        assert!(mod_content.contains("pub use coin::*;"));
579        assert!(mod_content.contains("pub use token::*;"));
580    }
581
582    #[test]
583    fn test_build_config() {
584        let config = BuildConfig::new()
585            .with_mod_file(false)
586            .with_cargo_instructions(false);
587
588        assert!(!config.generate_mod_file);
589        assert!(!config.print_cargo_instructions);
590    }
591}