diff --git a/crates/acton-config/schemas/acton.schema.json b/crates/acton-config/schemas/acton.schema.json index 5baa1c61d3..0701c1f22d 100644 --- a/crates/acton-config/schemas/acton.schema.json +++ b/crates/acton-config/schemas/acton.schema.json @@ -119,19 +119,19 @@ "type": "object", "properties": { "gen-dir": { - "description": "Directory where generated dependency files are saved", + "description": "Project-relative directory where generated dependency files are saved", "type": "string" }, "out-dir": { - "description": "Directory where JSON build artifacts are saved", + "description": "Project-relative directory where JSON build artifacts are saved", "type": "string" }, "output-abi": { - "description": "Directory where per-contract ABI JSON files are saved", + "description": "Project-relative directory where per-contract ABI JSON files are saved", "type": "string" }, "output-fift": { - "description": "Directory where per-contract compiled Fift files are saved", + "description": "Project-relative directory where per-contract compiled Fift files are saved", "type": "string" } } @@ -195,7 +195,7 @@ "type": "string" }, "output": { - "description": "Path where the compiled `.boc` should be saved", + "description": "Project-relative path where the compiled `.boc` should be saved", "type": "string" }, "src": { @@ -236,7 +236,7 @@ "type": "string" }, "path": { - "description": "Custom output path for the generated code file", + "description": "Project-relative custom output path for the generated code file", "type": "string" } } @@ -842,11 +842,11 @@ "type": "boolean" }, "output-dir": { - "description": "Directory where `acton wrapper` writes generated Tolk wrappers by default", + "description": "Project-relative directory where `acton wrapper` writes generated Tolk wrappers by default", "type": "string" }, "test-output-dir": { - "description": "Directory where generated Tolk test stubs are written by default", + "description": "Project-relative directory where generated Tolk test stubs are written by default", "type": "string" } } @@ -866,7 +866,7 @@ "type": "object", "properties": { "output-dir": { - "description": "Directory where `acton wrapper --ts` writes generated TypeScript wrappers by default", + "description": "Project-relative directory where `acton wrapper --ts` writes generated TypeScript wrappers by default", "type": "string" } } diff --git a/crates/acton-config/src/config.rs b/crates/acton-config/src/config.rs index d4ae02534d..8958232fc7 100644 --- a/crates/acton-config/src/config.rs +++ b/crates/acton-config/src/config.rs @@ -106,7 +106,7 @@ pub enum ContractDependency { kind: DependencyKind, /// Custom name for the generated code function function: Option, - /// Custom output path for the generated code file + /// Project-relative custom output path for the generated code file path: Option, }, } @@ -437,13 +437,13 @@ const fn default_fmt_separate_import_groups() -> Option { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "kebab-case")] pub struct BuildSettings { - /// Directory where JSON build artifacts are saved + /// Project-relative directory where JSON build artifacts are saved pub out_dir: Option, - /// Directory where generated dependency files are saved + /// Project-relative directory where generated dependency files are saved pub gen_dir: Option, - /// Directory where per-contract ABI JSON files are saved + /// Project-relative directory where per-contract ABI JSON files are saved pub output_abi: Option, - /// Directory where per-contract compiled Fift files are saved + /// Project-relative directory where per-contract compiled Fift files are saved pub output_fift: Option, } @@ -460,11 +460,11 @@ pub struct WrappersConfig { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "kebab-case")] pub struct TolkWrapperSettings { - /// Directory where `acton wrapper` writes generated Tolk wrappers by default + /// Project-relative directory where `acton wrapper` writes generated Tolk wrappers by default pub output_dir: Option, /// Generate a Tolk test stub by default for `acton wrapper` pub generate_test: Option, - /// Directory where generated Tolk test stubs are written by default + /// Project-relative directory where generated Tolk test stubs are written by default pub test_output_dir: Option, } @@ -472,7 +472,7 @@ pub struct TolkWrapperSettings { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "kebab-case")] pub struct TypescriptWrapperSettings { - /// Directory where `acton wrapper --ts` writes generated TypeScript wrappers by default + /// Project-relative directory where `acton wrapper --ts` writes generated TypeScript wrappers by default pub output_dir: Option, } @@ -605,7 +605,7 @@ pub struct ContractConfig { pub src: String, /// Dependencies of this contract pub depends: Option>, - /// Path where the compiled `.boc` should be saved + /// Project-relative path where the compiled `.boc` should be saved pub output: Option, } diff --git a/crates/ton-ls/tests/self_contained/languages/toml/hover/mod.rs b/crates/ton-ls/tests/self_contained/languages/toml/hover/mod.rs index 150e535637..429a97b551 100644 --- a/crates/ton-ls/tests/self_contained/languages/toml/hover/mod.rs +++ b/crates/ton-ls/tests/self_contained/languages/toml/hover/mod.rs @@ -165,7 +165,7 @@ fn test_hover_contracts_depends_nested_objects() { contracts.wallet.depends[0].path ``` - Custom output path for the generated code file + Project-relative custom output path for the generated code file - Type: `string` diff --git a/docs/content/docs/acton-toml.mdx b/docs/content/docs/acton-toml.mdx index 7e2409f696..400978db18 100644 --- a/docs/content/docs/acton-toml.mdx +++ b/docs/content/docs/acton-toml.mdx @@ -22,6 +22,7 @@ This matters for all relative paths from config sections, e.g., `[contracts].src Relative CLI path flags are a separate rule: - Relative paths written inside `Acton.toml` are resolved from project root. +- Manifest-configured output paths for build and wrapper generation must stay inside the project root. - Relative CLI path flags stay relative to the current working directory unless they are absolute. - `--manifest-path` chooses which manifest to load, but it does not rebase unrelated CLI path flags to the manifest directory. @@ -100,7 +101,7 @@ This section is a map where each key is a contract name and the value is a contr | `display-name` | `string` | No | Human-readable name of the contract. Defaults to the contract name. | | `src` | `string` | **Yes** | Path to the contract source file (`.tolk`) or a precompiled `.boc`. | | `depends` | `array` | No | List of contract dependencies. Can be simple names or detailed objects. | -| `output` | `string` | No | Custom path where the compiled `.boc` should be saved. | +| `output` | `string` | No | Project-relative path where the compiled `.boc` should be saved. | ### Contract dependencies @@ -115,9 +116,9 @@ Dependencies can be specified in two ways: - `name`: Name of the contract to depend on. - `kind`: Dependency type. Either `embed_code` (default) or `library_ref`. - `function`: Custom name for the generated code function. - - `path`: Custom output path for the generated code file. + - `path`: Project-relative custom output path for the generated code file. -Dependency helper naming uses the contract key from `[contracts.]`, not `display-name`. Relative dependency `path` values are resolved from the project root. +Dependency helper naming uses the contract key from `[contracts.]`, not `display-name`. Dependency `path` values are resolved from the project root and must stay inside it. Example: @@ -137,10 +138,10 @@ Configures defaults for `acton build`. | Field | Type | Required | Description | |:--------------|:---------|:---------|:-----------------------------------------------------------------------------------------| -| `out-dir` | `string` | No | Directory where build JSON artifacts are written (default: `build`). | -| `gen-dir` | `string` | No | Directory where generated dependency files are written (default: `gen`). | -| `output-abi` | `string` | No | Directory where contract ABI JSON files are written (default: `build/abi`). | -| `output-fift` | `string` | No | Directory where compiled Fift files are written (`.fif` per `.tolk` contract). | +| `out-dir` | `string` | No | Project-relative directory where build JSON artifacts are written (default: `build`). | +| `gen-dir` | `string` | No | Project-relative directory where generated dependency files are written (default: `gen`). | +| `output-abi` | `string` | No | Project-relative directory where contract ABI JSON files are written (default: `build/abi`). | +| `output-fift` | `string` | No | Project-relative directory where compiled Fift files are written (`.fif` per `.tolk` contract). | Example: @@ -152,7 +153,7 @@ output-abi = "build/abi" output-fift = "build/fift" ``` -`acton build --out-dir `, `acton build --gen-dir `, `acton build --output-abi `, and `acton build --output-fift ` override these values for a single run. Config values here are project-root-relative. The CLI flags stay relative to the current working directory unless absolute paths are passed. Arbitrary `.boc` contracts are skipped in ABI and Fift output. +`acton build --out-dir `, `acton build --gen-dir `, `acton build --output-abi `, and `acton build --output-fift ` override these values for a single run. Config values here are project-root-relative and must stay inside the project root. The CLI flags stay relative to the current working directory unless absolute paths are passed. Arbitrary `.boc` contracts are skipped in ABI and Fift output. ## `[wrappers]` @@ -162,15 +163,15 @@ Configures default output locations for `acton wrapper`. | Field | Type | Default | Description | |:------------------|:----------|:--------|:----------------------------------------------------------------------| -| `output-dir` | `string` | - | Default directory for generated Tolk wrappers. | +| `output-dir` | `string` | - | Project-relative default directory for generated Tolk wrappers. | | `generate-test` | `boolean` | `false` | Generate a Tolk test stub by default for non-TypeScript wrapper runs. | -| `test-output-dir` | `string` | - | Default directory for generated Tolk test stubs. | +| `test-output-dir` | `string` | - | Project-relative default directory for generated Tolk test stubs. | ### `[wrappers.typescript]` | Field | Type | Default | Description | |:-------------|:---------|:--------|:-----------------------------------------------------| -| `output-dir` | `string` | - | Default directory for generated TypeScript wrappers. | +| `output-dir` | `string` | - | Project-relative default directory for generated TypeScript wrappers. | Example: @@ -191,7 +192,7 @@ Override rules: - For Tolk test stubs, `--test-output` has highest priority, then `--test-output-dir`, then `[wrappers.tolk].test-output-dir`, then the wrapper command's built-in default test location. - `[wrappers.tolk].generate-test` affects only non-TypeScript wrapper generation. `acton wrapper --ts` still cannot be combined with test-stub generation. -Configured wrapper paths are resolved from project root. CLI paths such as `--output`, `--output-dir`, `--test-output`, and `--test-output-dir` stay relative to the current working directory unless absolute. +Configured wrapper paths are resolved from project root and must stay inside it. CLI paths such as `--output`, `--output-dir`, `--test-output`, and `--test-output-dir` stay relative to the current working directory unless absolute. For full CLI behavior, see [`acton wrapper`](/docs/commands/wrapper). diff --git a/docs/content/docs/building/reference.mdx b/docs/content/docs/building/reference.mdx index 9d3d9015fb..d7ea6299c8 100644 --- a/docs/content/docs/building/reference.mdx +++ b/docs/content/docs/building/reference.mdx @@ -10,6 +10,7 @@ This page provides a complete reference for all configuration options available Acton uses two different path-resolution models: - Relative paths from `Acton.toml` are resolved from project root. +- Manifest-configured output paths for build and wrapper generation must stay inside the project root. - Relative CLI path flags stay relative to the current working directory unless absolute. - `--manifest-path` only chooses which manifest to load. It does not rebase unrelated CLI path flags to the manifest directory. @@ -103,7 +104,7 @@ acton build --output-abi artifacts/abi acton build --output-fift artifacts/fift ``` -Config values in `[build]` are project-root-relative. CLI paths such as `--out-dir`, `--gen-dir`, `--output-abi`, `--output-fift`, and `--graph` remain cwd-relative unless absolute. +Config values in `[build]` are project-root-relative and must stay inside the project root. CLI paths such as `--out-dir`, `--gen-dir`, `--output-abi`, `--output-fift`, and `--graph` remain cwd-relative unless absolute. ### Wrappers section @@ -123,10 +124,10 @@ Available fields: | Field | Type | Default | Description | |------------------------------------|---------|---------|----------------------------------------------------------------------| -| `[wrappers.tolk].output-dir` | string | - | Default directory for generated Tolk wrappers | +| `[wrappers.tolk].output-dir` | string | - | Project-relative default directory for generated Tolk wrappers | | `[wrappers.tolk].generate-test` | boolean | `false` | Generate a Tolk test stub by default for non-TypeScript wrapper runs | -| `[wrappers.tolk].test-output-dir` | string | - | Default directory for generated Tolk test stubs | -| `[wrappers.typescript].output-dir` | string | - | Default directory for generated TypeScript wrappers | +| `[wrappers.tolk].test-output-dir` | string | - | Project-relative default directory for generated Tolk test stubs | +| `[wrappers.typescript].output-dir` | string | - | Project-relative default directory for generated TypeScript wrappers | Override rules: @@ -135,7 +136,7 @@ Override rules: - Test stub path: `--test-output` -> `--test-output-dir` -> `[wrappers.tolk].test-output-dir` -> wrapper command built-in default. - `[wrappers.tolk].generate-test` only affects non-TypeScript runs. `acton wrapper --ts` still rejects test-stub options. -Configured wrapper paths are project-root-relative. CLI paths such as `--output`, `--output-dir`, `--test-output`, and `--test-output-dir` remain cwd-relative unless absolute. +Configured wrapper paths are project-root-relative and must stay inside the project root. CLI paths such as `--output`, `--output-dir`, `--test-output`, and `--test-output-dir` remain cwd-relative unless absolute. ### Contracts section @@ -155,7 +156,7 @@ depends = [] |----------------|--------|----------|--------------------------------------------------------------------------------------------------------------------| | `display-name` | string | No | Overrides full name (e.g. `Wallet` instead of `JettonWallet`) in console messages and UI. | | `src` | string | Yes | Path to the contract source file. Can be a `.tolk` file for compilation or a `.boc` file for precompiled contracts | -| `output` | string | No | Path where the compiled `.boc` file should be saved. If omitted, the compiled contract is only cached | +| `output` | string | No | Project-relative path where the compiled `.boc` file should be saved. If omitted, the compiled contract is only cached | | `depends` | array | No | Array of contract dependencies. Can be simple strings or detailed dependency objects | #### Contract names @@ -200,7 +201,7 @@ depends = [ | `name` | string | Yes | - | Contract name to depend on. Must match a `[contracts.*]` name | | `kind` | string | No | `"embed_code"` | Dependency type: `"embed_code"` or `"library_ref"`. Omitting it in the detailed form still means `"embed_code"` | | `function` | string | No | Auto-generated | Custom name for the generated function. If omitted, Acton derives it from the dependency key using the rules below | -| `path` | string | No | `{gen-dir}/{name}.code.tolk` | Custom output path for the generated file. Relative manifest paths are resolved from the project root | +| `path` | string | No | `{gen-dir}/{name}.code.tolk` | Project-relative custom output path for the generated file | #### Dependency helper generation rules @@ -217,7 +218,7 @@ Helper file placement uses this precedence: Path resolution follows the normal config-vs-CLI rules: -- `depends[].path` and `[build].gen-dir` are project-root-relative unless absolute +- `depends[].path` and `[build].gen-dir` are project-root-relative and must stay inside the project root - `--gen-dir` is cwd-relative unless absolute - if `depends[].path` is set, it fully replaces the default helper location diff --git a/docs/content/docs/commands/build.mdx b/docs/content/docs/commands/build.mdx index c797f9002a..8f5dc82399 100644 --- a/docs/content/docs/commands/build.mdx +++ b/docs/content/docs/commands/build.mdx @@ -251,6 +251,8 @@ output-fift = "build/fift" CLI flags override config values for the current invocation. For dependency helpers, a per-dependency `depends[].path` still overrides the resolved `gen-dir` for that helper file. +Manifest-configured output paths are project-relative and must stay inside the +project root; CLI output flags remain explicit paths for the current run. ## Outputs @@ -289,9 +291,11 @@ contract and its transitive dependencies. ## Side Effects -`acton build` writes artifacts, cache entries, and optional graph output under -the resolved project root. If one contract fails after earlier contracts were -built successfully, the successful artifacts remain on disk. +`acton build` writes cache entries under the resolved project root. Manifest +output paths are confined to that root, while explicit CLI output flags and the +optional graph output may point elsewhere for the current run. If one contract +fails after earlier contracts were built successfully, the successful artifacts +remain on disk. Before compiling, `acton build` normally refreshes the bundled standard library under `.acton/`. Set `ACTON_DISABLE_AUTO_STDLIB=1` to skip that automatic diff --git a/docs/content/docs/commands/wrapper.mdx b/docs/content/docs/commands/wrapper.mdx index 719af0b189..48cd8f5af6 100644 --- a/docs/content/docs/commands/wrapper.mdx +++ b/docs/content/docs/commands/wrapper.mdx @@ -277,6 +277,9 @@ output-dir = "wrappers-ts" ``` CLI flags override config values for the current invocation. +Manifest-configured wrapper output paths are project-relative and must stay +inside the project root; CLI output flags remain explicit paths for the current +run. ## Exit Status diff --git a/docs/content/docs/projects.mdx b/docs/content/docs/projects.mdx index 8d1a2400e1..1be78004b6 100644 --- a/docs/content/docs/projects.mdx +++ b/docs/content/docs/projects.mdx @@ -243,6 +243,8 @@ output-fift = "artifacts/fift" # omitted by default This is useful when CI expects artifacts at a fixed path or when Fift output must be saved on disk. By default, `out-dir` is `build/` and `gen-dir` is `gen/`. +Manifest-configured build output paths are project-relative and must stay inside the project root. Use CLI output flags for one-off explicit paths outside the project. + Note that `gen` in [`[import-mappings]`](#import-mappings) and `gen-dir` in `[build]` are independent settings — keep them aligned when both are configured. ### Configure wrapper output @@ -261,6 +263,8 @@ output-dir = "wrappers-ts" The `@wrappers` in `import "@wrappers/..."` always resolves to the Tolk wrapper directory, regardless of existence of non-Tolk wrappers. +Manifest-configured wrapper output paths are project-relative and must stay inside the project root. Use CLI output flags for one-off explicit paths outside the project. + See the [`acton wrapper` command reference](/docs/commands/wrapper) for more. ### Define project scripts @@ -415,7 +419,7 @@ depends = [ ### Set a per-contract BoC output path -Set `output` to place a compiled binary `.boc` file with the contract's code at an exact path: +Set `output` to place a compiled binary `.boc` file with the contract's code at a project-relative path: ```toml [contracts.Counter] diff --git a/src/commands/build/mod.rs b/src/commands/build/mod.rs index 285574eadd..f6994301c4 100644 --- a/src/commands/build/mod.rs +++ b/src/commands/build/mod.rs @@ -1,5 +1,6 @@ use crate::commands::common::error_fmt; use crate::file_build_cache::FileBuildCache; +use crate::paths::resolve_manifest_write_path; use crate::stdlib; use acton_config::color::OwoColorize; use acton_config::config::{ @@ -67,7 +68,7 @@ pub fn build_cmd(options: BuildCommandOptions) -> anyhow::Result<()> { println!(" {} contracts", "Compiling".green().bold()); let config = ActonConfig::load()?; - let out_dir = resolve_build_output_dir( + let out_dir = resolve_build_output_dir_with_field( out_dir, config .build @@ -75,8 +76,9 @@ pub fn build_cmd(options: BuildCommandOptions) -> anyhow::Result<()> { .and_then(|build| non_empty_path(build.out_dir.clone())), "build", project_root, - ); - let gen_dir = resolve_build_output_dir( + "[build].out-dir", + )?; + let gen_dir = resolve_build_output_dir_with_field( gen_dir, config .build @@ -84,8 +86,9 @@ pub fn build_cmd(options: BuildCommandOptions) -> anyhow::Result<()> { .and_then(|build| non_empty_path(build.gen_dir.clone())), "gen", project_root, - ); - let output_abi_dir = resolve_build_output_dir( + "[build].gen-dir", + )?; + let output_abi_dir = resolve_build_output_dir_with_field( output_abi, config .build @@ -93,7 +96,8 @@ pub fn build_cmd(options: BuildCommandOptions) -> anyhow::Result<()> { .and_then(|build| non_empty_path(build.output_abi.clone())), "build/abi", project_root, - ); + "[build].output-abi", + )?; let output_fift_dir = resolve_optional_build_output_dir( output_fift, config @@ -101,7 +105,8 @@ pub fn build_cmd(options: BuildCommandOptions) -> anyhow::Result<()> { .as_ref() .and_then(|build| non_empty_path(build.output_fift.clone())), project_root, - ); + "[build].output-fift", + )?; if !out_dir.exists() { fs::create_dir_all(&out_dir)?; @@ -464,7 +469,11 @@ fn save_boc_file( .as_deref() .filter(|path| !path.is_empty()) { - let output_path = resolve_project_config_path(project_root, config_output_path); + let output_path = resolve_manifest_write_path( + project_root, + config_output_path, + "[contracts.].output", + )?; let display_parent_dir = Path::new(config_output_path) .parent() .or_else(|| output_path.parent()); @@ -707,7 +716,7 @@ fn generate_single_dependency_file( ); let output_path = if let Some(output_path) = dependency.compiled_code_out_path() { - resolve_project_config_path(project_root, output_path) + resolve_manifest_write_path(project_root, output_path, "depends[].path")? } else { gen_dir.join(format!("{dependency_contract}.code.tolk")) }; @@ -755,34 +764,49 @@ pub(crate) fn resolve_build_output_dir( config_path: Option, default_dir: &str, project_root: &Path, -) -> PathBuf { +) -> anyhow::Result { + resolve_build_output_dir_with_field( + cli_path, + config_path, + default_dir, + project_root, + "build output path", + ) +} + +pub(crate) fn resolve_build_output_dir_with_field( + cli_path: Option, + config_path: Option, + default_dir: &str, + project_root: &Path, + field_name: &str, +) -> anyhow::Result { if let Some(cli_path) = non_empty_path(cli_path) { - return PathBuf::from(cli_path); + return Ok(PathBuf::from(cli_path)); } if let Some(config_path) = non_empty_path(config_path) { - return resolve_project_config_path(project_root, &config_path); + return resolve_manifest_write_path(project_root, &config_path, field_name); } - project_root.join(default_dir) + Ok(project_root.join(default_dir)) } fn resolve_optional_build_output_dir( cli_path: Option, config_path: Option, project_root: &Path, -) -> Option { + field_name: &str, +) -> anyhow::Result> { if let Some(cli_path) = non_empty_path(cli_path) { - return Some(PathBuf::from(cli_path)); + return Ok(Some(PathBuf::from(cli_path))); } - non_empty_path(config_path) - .map(|config_path| resolve_project_config_path(project_root, &config_path)) -} - -fn resolve_project_config_path(project_root: &Path, path: &str) -> PathBuf { - let path = Path::new(path); - if path.is_absolute() { - path.to_path_buf() + if let Some(config_path) = non_empty_path(config_path) { + Ok(Some(resolve_manifest_write_path( + project_root, + &config_path, + field_name, + )?)) } else { - project_root.join(path) + Ok(None) } } diff --git a/src/commands/test/mutation/mod.rs b/src/commands/test/mutation/mod.rs index af5451ebd9..806495263a 100644 --- a/src/commands/test/mutation/mod.rs +++ b/src/commands/test/mutation/mod.rs @@ -354,7 +354,10 @@ fn dependent_override_order( dependents } -fn build_artifacts_dir(acton_config: &ActonConfig, project_root: &Path) -> PathBuf { +fn build_artifacts_dir( + acton_config: &ActonConfig, + project_root: &Path, +) -> anyhow::Result { let out_dir = acton_config .build .as_ref() @@ -363,7 +366,10 @@ fn build_artifacts_dir(acton_config: &ActonConfig, project_root: &Path) -> PathB resolve_build_output_dir(None, out_dir, "build", project_root) } -fn generated_dependencies_dir(acton_config: &ActonConfig, project_root: &Path) -> PathBuf { +fn generated_dependencies_dir( + acton_config: &ActonConfig, + project_root: &Path, +) -> anyhow::Result { let gen_dir = acton_config .build .as_ref() @@ -377,7 +383,7 @@ fn load_original_contract_bocs( project_root: &Path, compilation_order: &[String], ) -> anyhow::Result> { - let build_dir = build_artifacts_dir(acton_config, project_root); + let build_dir = build_artifacts_dir(acton_config, project_root)?; let mut compiled_contracts = HashMap::new(); for contract_id in compilation_order { @@ -587,7 +593,13 @@ fn mutation_worker_loop( } }; - let gen_dir = generated_dependencies_dir(context.acton_config, workspace.path()); + let gen_dir = match generated_dependencies_dir(context.acton_config, workspace.path()) { + Ok(gen_dir) => gen_dir, + Err(err) => { + let _ = result_tx.send(Err(err)); + return Ok(()); + } + }; while let Ok(mutation) = job_rx.recv() { let execution = match run_single_mutation(workspace.path(), &gen_dir, &mutation, &context) { diff --git a/src/commands/wrapper/mod.rs b/src/commands/wrapper/mod.rs index 0d1dae18df..df19a361e8 100644 --- a/src/commands/wrapper/mod.rs +++ b/src/commands/wrapper/mod.rs @@ -1,4 +1,5 @@ use crate::commands::common::error_fmt; +use crate::paths::resolve_manifest_write_path; use acton_config::color::OwoColorize; use acton_config::config::{ActonConfig, project_root}; use anyhow::{Context, anyhow}; @@ -116,9 +117,15 @@ fn build_model( .map(ToOwned::to_owned); let configured_tolk_test_output_dir = config.tolk_wrapper_test_output_dir().map(ToOwned::to_owned); - let mapped_wrapper_output_dir = mappings + let mapped_wrapper_output_dir = config + .mappings .as_ref() - .and_then(|mappings| mappings.get("@wrappers").cloned()); + .and_then(|mappings| { + mappings + .get("wrappers") + .or_else(|| mappings.get("@wrappers")) + .cloned() + }); let storage = abi.resolve_storage_struct()?; let incoming_messages = abi.resolve_incoming_message_structs()?; let incoming_external_messages = abi.resolve_incoming_external_message_structs()?; @@ -140,14 +147,14 @@ fn build_model( configured_typescript_output_dir, mapped_wrapper_output_dir, generate_typescript, - ); + )?; let test_path = resolve_test_path( &project_root, contract_id, test_output, test_output_dir, configured_tolk_test_output_dir, - ); + )?; let message_paths = message_paths.into_iter().collect(); @@ -348,39 +355,51 @@ fn resolve_wrapper_path( configured_ts_output_dir: Option, mapped_wrapper_output_dir: Option, generate_typescript: bool, -) -> PathBuf { +) -> anyhow::Result { if let Some(wrapper_output) = non_empty_path(wrapper_output) { - return PathBuf::from(wrapper_output); + return Ok(PathBuf::from(wrapper_output)); } let file_name = wrapper_file_name(contract_name, generate_typescript); if let Some(wrapper_output_dir) = non_empty_path(wrapper_output_dir) { - return PathBuf::from(wrapper_output_dir).join(&file_name); + return Ok(PathBuf::from(wrapper_output_dir).join(&file_name)); } if generate_typescript { if let Some(configured_ts_output_dir) = non_empty_path(configured_ts_output_dir) { - return resolve_project_config_path(project_root, &configured_ts_output_dir) - .join(&file_name); + return Ok(resolve_manifest_write_path( + project_root, + &configured_ts_output_dir, + "[wrappers.typescript].output-dir", + )? + .join(&file_name)); } - return project_root + return Ok(project_root .join(DEFAULT_TYPESCRIPT_WRAPPER_DIR) - .join(&file_name); + .join(&file_name)); } if let Some(configured_tolk_output_dir) = non_empty_path(configured_tolk_output_dir) { - return resolve_project_config_path(project_root, &configured_tolk_output_dir) - .join(&file_name); + return Ok(resolve_manifest_write_path( + project_root, + &configured_tolk_output_dir, + "[wrappers.tolk].output-dir", + )? + .join(&file_name)); } if let Some(mapped_wrapper_output_dir) = non_empty_path(mapped_wrapper_output_dir) { - return resolve_project_config_path(project_root, &mapped_wrapper_output_dir) - .join(&file_name); + return Ok(resolve_manifest_write_path( + project_root, + &mapped_wrapper_output_dir, + "[import-mappings].wrappers", + )? + .join(&file_name)); } - project_root.join(DEFAULT_TOLK_WRAPPER_DIR).join(&file_name) + Ok(project_root.join(DEFAULT_TOLK_WRAPPER_DIR).join(&file_name)) } fn resolve_test_path( @@ -389,23 +408,27 @@ fn resolve_test_path( test_output: Option, test_output_dir: Option, configured_tolk_test_output_dir: Option, -) -> PathBuf { +) -> anyhow::Result { if let Some(test_output) = non_empty_path(test_output) { - return PathBuf::from(test_output); + return Ok(PathBuf::from(test_output)); } let file_name = format!("{contract_id}.test.tolk"); if let Some(test_output_dir) = non_empty_path(test_output_dir) { - return PathBuf::from(test_output_dir).join(&file_name); + return Ok(PathBuf::from(test_output_dir).join(&file_name)); } if let Some(configured_tolk_test_output_dir) = non_empty_path(configured_tolk_test_output_dir) { - return resolve_project_config_path(project_root, &configured_tolk_test_output_dir) - .join(&file_name); + return Ok(resolve_manifest_write_path( + project_root, + &configured_tolk_test_output_dir, + "[wrappers.tolk].test-output-dir", + )? + .join(&file_name)); } - project_root.join("tests").join(&file_name) + Ok(project_root.join("tests").join(&file_name)) } fn wrapper_file_name(contract_name: &str, generate_typescript: bool) -> String { @@ -431,15 +454,6 @@ fn has_non_empty_path(path: Option<&str>) -> bool { path.is_some_and(|path| !path.trim().is_empty()) } -fn resolve_project_config_path(project_root: &Path, path: &str) -> PathBuf { - let path = Path::new(path); - if path.is_absolute() { - path.to_path_buf() - } else { - project_root.join(path) - } -} - fn generate_typescript_wrapper(model: &WrapperModel) -> anyhow::Result { let abi_json = serialize_typescript_abi(model)?; let npm_cache_dir = diff --git a/src/paths.rs b/src/paths.rs index 71537f74bb..8bb901f7a9 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use anyhow::{Context, anyhow}; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; pub const DEFAULT_BUILD_CACHE_DIR: &str = "build/cache"; pub const DEFAULT_BUILD_TRACES_DIR: &str = "build/traces"; @@ -35,3 +38,219 @@ pub fn build_logs_dir(project_root: &Path) -> PathBuf { pub fn language_server_log_path(project_root: &Path) -> PathBuf { build_logs_dir(project_root).join("tolk-language-server.log") } + +pub(crate) fn resolve_manifest_write_path( + project_root: &Path, + raw_path: &str, + field_name: &str, +) -> anyhow::Result { + let raw_path = raw_path.trim(); + if raw_path.is_empty() + || has_windows_prefix(raw_path) + || raw_path.starts_with('\\') + || raw_path.starts_with("//") + { + return Err(manifest_write_path_error(field_name)); + } + + let mut relative = PathBuf::new(); + for component in Path::new(raw_path).components() { + match component { + Component::Normal(value) => relative.push(value), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(manifest_write_path_error(field_name)); + } + } + } + + let target = project_root.join(&relative); + let canonical_project_root = project_root.canonicalize().with_context(|| { + format!( + "failed to canonicalize project root {}", + project_root.display() + ) + })?; + validate_existing_manifest_components( + project_root, + &relative, + &canonical_project_root, + field_name, + )?; + let existing_ancestor = nearest_existing_ancestor(&target).ok_or_else(|| { + anyhow!( + "failed to find an existing ancestor for manifest path {}", + target.display() + ) + })?; + let canonical_ancestor = existing_ancestor.canonicalize().with_context(|| { + format!( + "failed to canonicalize existing path {}", + existing_ancestor.display() + ) + })?; + + if !canonical_ancestor.starts_with(&canonical_project_root) { + return Err(manifest_write_path_error(field_name)); + } + + Ok(target) +} + +fn has_windows_prefix(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn manifest_write_path_error(field_name: &str) -> anyhow::Error { + anyhow!( + "{field_name} must be a project-relative path inside the project root; use an explicit CLI output flag for paths outside the project" + ) +} + +fn validate_existing_manifest_components( + project_root: &Path, + relative_path: &Path, + canonical_project_root: &Path, + field_name: &str, +) -> anyhow::Result<()> { + let mut current = project_root.to_path_buf(); + for component in relative_path.components() { + current.push(component.as_os_str()); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => break, + Err(err) => { + return Err(anyhow!( + "failed to inspect manifest path {}: {}", + current.display(), + err + )); + } + }; + + if metadata.file_type().is_symlink() { + let Ok(canonical_path) = current.canonicalize() else { + return Err(manifest_write_path_error(field_name)); + }; + if !canonical_path.starts_with(canonical_project_root) { + return Err(manifest_write_path_error(field_name)); + } + } + } + + Ok(()) +} + +fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { + let mut cursor = Some(path); + while let Some(path) = cursor { + if path.exists() { + return Some(path); + } + cursor = path.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::resolve_manifest_write_path; + + #[test] + fn manifest_write_path_accepts_project_relative_paths() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_root = temp_dir.path(); + + assert_eq!( + resolve_manifest_write_path(project_root, "artifacts", "[build].out-dir").unwrap(), + project_root.join("artifacts") + ); + assert_eq!( + resolve_manifest_write_path(project_root, "./artifacts/gen", "[build].gen-dir") + .unwrap(), + project_root.join("artifacts").join("gen") + ); + assert_eq!( + resolve_manifest_write_path( + project_root, + "wrappers-ts", + "[wrappers.typescript].output-dir" + ) + .unwrap(), + project_root.join("wrappers-ts") + ); + } + + #[test] + fn manifest_write_path_rejects_paths_outside_project_root() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_root = temp_dir.path(); + + for path in [ + "/tmp/out", + r"\tmp\out", + r"C:\tmp\out", + "C:tmp", + r"\\server\share\out", + "../out", + "artifacts/../../out", + ] { + let err = resolve_manifest_write_path(project_root, path, "[build].out-dir") + .expect_err("path should be rejected"); + assert!( + err.to_string() + .contains("must be a project-relative path inside the project root"), + "unexpected error for {path}: {err}" + ); + } + } + + #[cfg(unix)] + #[test] + fn manifest_write_path_rejects_existing_symlink_parent_escape() { + use std::os::unix::fs::symlink; + + let temp_dir = tempfile::tempdir().unwrap(); + let outside_dir = tempfile::tempdir().unwrap(); + let project_root = temp_dir.path(); + symlink(outside_dir.path(), project_root.join("linked")).unwrap(); + + let err = resolve_manifest_write_path( + project_root, + "linked/generated.tolk", + "[wrappers.tolk].output-dir", + ) + .expect_err("symlinked parent should be rejected"); + + assert!( + err.to_string() + .contains("must be a project-relative path inside the project root"), + "unexpected error: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn manifest_write_path_rejects_dangling_symlink_target() { + use std::os::unix::fs::symlink; + + let temp_dir = tempfile::tempdir().unwrap(); + let outside_dir = tempfile::tempdir().unwrap(); + let project_root = temp_dir.path(); + symlink( + outside_dir.path().join("missing-output"), + project_root.join("dangling"), + ) + .unwrap(); + + let err = resolve_manifest_write_path(project_root, "dangling", "[build].out-dir") + .expect_err("dangling symlink target should be rejected"); + + assert!( + err.to_string() + .contains("must be a project-relative path inside the project root"), + "unexpected error: {err}" + ); + } +} diff --git a/tests/integration/build_tests.rs b/tests/integration/build_tests.rs index 3a192219f1..ac5e22ffe9 100644 --- a/tests/integration/build_tests.rs +++ b/tests/integration/build_tests.rs @@ -13,6 +13,10 @@ fun onInternalMessage(in: InMessage) {} fun onBouncedMessage(_: InMessageBounced) {} "; +fn toml_string(value: &Path) -> String { + format!("\"{}\"", value.display().to_string().replace('\\', "\\\\")) +} + #[test] fn test_build_simple_contract() { let project = ProjectBuilder::new("build-simple") @@ -1657,6 +1661,139 @@ fn test_build_with_out_dir_cli_overrides_config() { ); } +#[test] +fn test_build_rejects_config_out_dir_outside_project_root() { + let project = ProjectBuilder::new("build-config-out-dir-outside") + .contract("simple", SIMPLE_CONTRACT) + .build(); + + let outside_dir = project.path().parent().unwrap().join("outside-build"); + let acton_toml_path = project.path().join("Acton.toml"); + let mut acton_toml = fs::read_to_string(&acton_toml_path).expect("Should read Acton.toml"); + acton_toml.push_str("\n[build]\nout-dir = \"../outside-build\"\n"); + fs::write(&acton_toml_path, acton_toml).expect("Should write Acton.toml"); + + project + .acton() + .build() + .run() + .failure() + .assert_stderr_contains( + "[build].out-dir must be a project-relative path inside the project root", + ); + + assert!( + !outside_dir.exists(), + "config out-dir must not create directories outside the project" + ); +} + +#[test] +fn test_build_rejects_absolute_config_out_dir() { + let project = ProjectBuilder::new("build-config-out-dir-absolute") + .contract("simple", SIMPLE_CONTRACT) + .build(); + + let outside_dir = project.path().parent().unwrap().join("absolute-build"); + let acton_toml_path = project.path().join("Acton.toml"); + let mut acton_toml = fs::read_to_string(&acton_toml_path).expect("Should read Acton.toml"); + acton_toml.push_str(&format!( + "\n[build]\nout-dir = {}\n", + toml_string(&outside_dir) + )); + fs::write(&acton_toml_path, acton_toml).expect("Should write Acton.toml"); + + project + .acton() + .build() + .run() + .failure() + .assert_stderr_contains( + "[build].out-dir must be a project-relative path inside the project root", + ); + + assert!( + !outside_dir.exists(), + "absolute config out-dir must not create directories outside the project" + ); +} + +#[test] +fn test_build_rejects_contract_output_outside_project_root() { + let project = ProjectBuilder::new("build-contract-output-outside") + .contract_with_output("simple", SIMPLE_CONTRACT, "../outside-simple.boc") + .build(); + + let outside_file = project.path().parent().unwrap().join("outside-simple.boc"); + + project + .acton() + .build() + .run() + .failure() + .assert_stderr_contains( + "[contracts.].output must be a project-relative path inside the project root", + ); + + assert!( + !outside_file.exists(), + "contract output must not write outside the project" + ); +} + +#[test] +fn test_build_rejects_dependency_output_path_outside_project_root() { + let project = ProjectBuilder::new("build-dependency-output-outside") + .contract("child", SIMPLE_CONTRACT) + .contract_with_detailed_deps( + "parent", + SIMPLE_CONTRACT, + vec![("child", None, None, Some("../outside-child.code.tolk"))], + ) + .build(); + + let outside_file = project + .path() + .parent() + .unwrap() + .join("outside-child.code.tolk"); + + project + .acton() + .build() + .run() + .failure() + .assert_stderr_contains( + "depends[].path must be a project-relative path inside the project root", + ); + + assert!( + !outside_file.exists(), + "dependency output path must not write outside the project" + ); +} + +#[test] +fn test_build_cli_out_dir_can_write_outside_project_root() { + let project = ProjectBuilder::new("build-cli-out-dir-outside") + .contract("simple", SIMPLE_CONTRACT) + .build(); + + let outside_dir = project.path().parent().unwrap().join("explicit-build"); + + project + .acton() + .build() + .with_out_dir("../explicit-build") + .run() + .success(); + + assert!( + outside_dir.join("simple.json").exists(), + "explicit CLI out-dir should still be allowed outside the project" + ); +} + #[test] fn test_build_without_output_fift_does_not_emit_fift_by_default() { let project = ProjectBuilder::new("build-output-fift-default-off") diff --git a/tests/integration/wrapper_tests.rs b/tests/integration/wrapper_tests.rs index 8f8f7321f4..8027cdbe9a 100644 --- a/tests/integration/wrapper_tests.rs +++ b/tests/integration/wrapper_tests.rs @@ -154,6 +154,108 @@ fn test_wrapper_generation_uses_tolk_config_defaults() { ); } +#[test] +fn test_wrapper_generation_rejects_config_output_dir_outside_project_root() { + let project = ProjectBuilder::new("wrapper_config_output_outside") + .contract("my_contract", SIMPLE_CONTRACT) + .with_wrappers_tolk_output_dir("../outside-wrappers") + .build(); + + let outside_dir = project.path().parent().unwrap().join("outside-wrappers"); + + project + .acton() + .wrapper("my_contract") + .run() + .failure() + .assert_stderr_contains( + "[wrappers.tolk].output-dir must be a project-relative path inside the project root", + ); + + assert!( + !outside_dir.exists(), + "configured wrapper output dir must not be created outside the project" + ); +} + +#[test] +fn test_wrapper_generation_rejects_config_test_output_dir_outside_project_root() { + let project = ProjectBuilder::new("wrapper_config_test_output_outside") + .contract("my_contract", SIMPLE_CONTRACT) + .with_wrappers_tolk_generate_test(true) + .with_wrappers_tolk_test_output_dir("../outside-wrapper-tests") + .build(); + + let outside_dir = project + .path() + .parent() + .unwrap() + .join("outside-wrapper-tests"); + + project + .acton() + .wrapper("my_contract") + .run() + .failure() + .assert_stderr_contains( + "[wrappers.tolk].test-output-dir must be a project-relative path inside the project root", + ); + + assert!( + !outside_dir.exists(), + "configured test output dir must not be created outside the project" + ); +} + +#[test] +fn test_wrapper_generation_rejects_wrappers_mapping_outside_project_root() { + let project = ProjectBuilder::new("wrapper_mapping_output_outside") + .contract("my_contract", SIMPLE_CONTRACT) + .mapping("wrappers", "../outside-mapped-wrappers") + .build(); + + let outside_dir = project + .path() + .parent() + .unwrap() + .join("outside-mapped-wrappers"); + + project + .acton() + .wrapper("my_contract") + .run() + .failure() + .assert_stderr_contains( + "[import-mappings].wrappers must be a project-relative path inside the project root", + ); + + assert!( + !outside_dir.exists(), + "configured @wrappers mapping must not create output outside the project" + ); +} + +#[test] +fn test_wrapper_output_dir_flag_can_write_outside_project_root() { + let project = ProjectBuilder::new("wrapper_cli_output_outside") + .contract("my_contract", SIMPLE_CONTRACT) + .build(); + + let outside_dir = project.path().parent().unwrap().join("explicit-wrappers"); + + project + .acton() + .wrapper("my_contract") + .wrapper_output_dir("../explicit-wrappers") + .run() + .success(); + + assert!( + outside_dir.join("MyContract.gen.tolk").exists(), + "explicit CLI output dir should still be allowed outside the project" + ); +} + #[test] fn test_wrapper_generation_test_output_dir_flag() { let project = ProjectBuilder::new("wrapper_test_output_dir_flag")