diff --git a/README.md b/README.md index a774e0d..ec8066d 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ v8-runner launch mcp va --mcp-port 1550 --wait-ready `test va`, MCP `run_all_tests` с `runner=vanessa` или `launch mcp va --wait-ready`; голый `launch mcp` предназначен только для client MCP без загрузки Vanessa. +Для автоматизации `v8-runner --json-message launch ...` сохраняет поле `binary` и добавляет +canonical `platform_resolution` (path, version, source и installation root). Эта metadata +публикуется только для результата `launch`, а не для всех команд. + ### Поднимите MCP transport (MCP-транспорт) для AI-агентов: ```bash diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index 13f6ead..df5e69c 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -11,7 +11,10 @@ settings before CLI overrides. - `builder`: `DESIGNER` or `IBCMD`. - `infobase.connection`: often `File=build/ib` for local automation. - `source-set`: ordered configuration and extension sources. -- `tools.platform.path` or `tools.platform.version`: 1C platform discovery hints. +- `tools.platform.path`, `version`, and `strict`: platform discovery hints. `path` is always an + explicit-only boundary with no default-root or `PATH` fallback. Without `path`, `version` filters + normal discovery. With `path`, `version` is ignored unless `strict: true`; strict path+version + resolution rejects unknown or mismatched versions and pins sibling utilities to one canonical root. - `tools.edt_cli.path`, `version`, and `interactive-mode`: EDT CLI discovery and execution mode. - `tests.yaxunit` and `tests.va`: test runner configuration. - `tools.client_mcp`, `tools.va`, and `tools.enterprise`: launch and client-side MCP integration hints. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 6483c06..ac7b0c9 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -341,6 +341,13 @@ v8-runner launch mcp [va] [--mode ] [--wait-ready] [FLAGS] `--raw-key` не может задавать `/C`, `/Execute` или `/Out`. - Для `designer`/`thin`/`thick`/`ordinary` дополнительные typed flags: `--c`, `--execute`, `--use-privileged-mode`, `--output`, повторяемый `--raw-key`. +- Platform discovery использует `tools.platform.path` как explicit-only границу: если path задан, + default roots и `PATH` не используются. `tools.platform.version` без path фильтрует обычный + поиск; вместе с path проверяется только при `tools.platform.strict: true`, а при + `strict: false` игнорируется. +- JSON-результат именно `launch` содержит legacy `binary` и `platform_resolution` с canonical + `path`, `version` (или `null`), `source` (`explicit`, `default-root` или `path`) и + `installation_root`. Это не общий metadata contract для остальных команд. ### `mcp serve` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a6bf1ec..c359c24 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -151,6 +151,7 @@ tools: epf_path: /path/to/vanessa.epf platform: path: /opt/1cv8/x86_64 + strict: true version: 8.3.27.1859 enterprise: additional-launch-keys: @@ -508,6 +509,26 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - на каталог `bin`; - на корень установки с версиями. +Относительный путь нормализуется относительно каталога primary `v8project.yaml`. +Если `path` задан, поиск platform utilities ограничивается этим путём и не переходит к default +roots или `PATH`, независимо от `strict`. + +### `tools.platform.strict` + +- Тип: boolean +- Обязателен: нет +- По умолчанию: `false` + +`strict` управляет проверкой `tools.platform.version` внутри configured `path`. Сам `path` всегда +является explicit-only границей. При `strict: false` значение `version` для configured `path` +игнорируется. При `strict: true` найденная внутри `path` utility обязана соответствовать +`version`; неизвестная версия или несовпадение версии завершают команду ошибкой. + +Если `path` указывает на конкретный executable, поиск sibling utilities (`1cv8`, `1cv8c`, +`ibcmd`) в `strict: false` идёт рядом с указанным файлом, а в `strict: true` — рядом с его +canonical installation. При `strict: true` первая найденная platform utility фиксирует один +canonical installation root; последующие `1cv8`, `1cv8c` и `ibcmd` выбираются только из этого root. + ### `tools.platform.version` - Тип: строка @@ -520,6 +541,17 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - `8.3.20`: выбирается максимальная найденная сборка `8.3.20.*`; - `8.3`: выбирается максимальная найденная версия `8.3.*.*`. +Матрица поведения: + +| Конфигурация | Поведение | +| --- | --- | +| `version`, без `path` | Поиск по default roots и `PATH` с проверкой версии. | +| `path + version`, `strict: false` | Поиск только по `path`; `version` игнорируется. | +| `path + version`, `strict: true` | Поиск только по `path`; версия обязана совпасть. | +| `path`, без `version` | Поиск только по `path`; проверки версии нет. | +| Без `path` и без `version` | Обычный поиск по default roots и `PATH`. | +| `strict: true`, без `path` | Не создаёт boundary; с `version` работает как version-only поиск, без `version` не меняет обычный поиск. | + ## `tools.enterprise` ### `tools.enterprise.additional-launch-keys` diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 5b1023d..6bf0e42 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -405,14 +405,19 @@ "additionalProperties": false, "properties": { "path": { - "description": "Platform binary, installation `bin` directory, or platform root discovery hint.", + "description": "Platform binary, installation `bin` directory, or platform root discovery hint. When set, platform discovery uses only this path and never falls back to default roots or PATH.", "type": [ "string", "null" ] }, + "strict": { + "default": false, + "description": "Enforce `version` for a configured `path`. Without `path`, `version` is applied to normal default-root and PATH discovery.", + "type": "boolean" + }, "version": { - "description": "Platform version requirement used for discovery.", + "description": "Platform version requirement. Without `path`, it filters normal discovery; with `path`, it is ignored unless `strict` is true.", "type": [ "string", "null" diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index fc3d72b..5dc6d5a 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -287,14 +287,19 @@ "additionalProperties": false, "properties": { "path": { - "description": "Platform binary, installation `bin` directory, or platform root discovery hint.", + "description": "Platform binary, installation `bin` directory, or platform root discovery hint. When set, platform discovery uses only this path and never falls back to default roots or PATH.", "type": [ "string", "null" ] }, + "strict": { + "default": false, + "description": "Enforce `version` for a configured `path`. Without `path`, `version` is applied to normal default-root and PATH discovery.", + "type": "boolean" + }, "version": { - "description": "Platform version requirement used for discovery.", + "description": "Platform version requirement. Without `path`, it filters normal discovery; with `path`, it is ignored unless `strict` is true.", "type": [ "string", "null" diff --git a/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md b/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md new file mode 100644 index 0000000..cd43057 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md @@ -0,0 +1,31 @@ +# Strict Platform Resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add fail-closed platform pinning with coherent installation resolution and observable launch metadata. + +**Architecture:** Add a typed resolution policy at the config-to-locator boundary. Candidates carry their source; any configured platform path searches only that explicit boundary, while strict path resolution also enforces version and pins one canonical installation root for all platform utilities. + +**Tech Stack:** Rust, serde, schemars, clap integration tests, existing locator and launch contracts. + +## Task 1: Configuration and path normalization + +- [ ] Add failing model/schema/loader tests for strict, relative platform paths, and strict without path. +- [ ] Add `PlatformToolConfig.strict`, schema fields, strict-without-path acceptance, and path normalization. +- [ ] Regenerate both checked-in schemas and run focused config tests. + +## Task 2: Typed strict locator + +- [ ] Add failing locator tests for the path/version/strict contract matrix, exact/prefix mismatch, unknown version, and sibling consistency. +- [ ] Add typed policy/source/errors and source-aware candidates. +- [ ] Make configured path resolution explicit-only, bind strict path resolution to one canonical installation root, and capture PATH roots once. +- [ ] Run the complete locator and utilities suites. + +## Task 3: JSON and documentation + +- [ ] Add a failing launch JSON test for path/version/source/root metadata. +- [ ] Extend LaunchResult and mapping without removing the existing binary field. +- [ ] Update configuration, capabilities, and repo-local skill guidance. +- [ ] Run formatting, focused integration tests, all-target check, and clippy. +- [ ] Run independent tester, reviewer, and Rust expert passes; resolve or waive every finding. +- [ ] Commit, push, and create the upstream PR. diff --git a/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md b/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md new file mode 100644 index 0000000..1c00d8f --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md @@ -0,0 +1,46 @@ +# Strict Platform Resolution Design + +## Goal + +Make a configured 1C platform path an explicit search boundary and make version enforcement +predictable across path and non-path discovery. + +## Configuration contract + +Maintainer decision: `tools.platform.path` itself means "search only here". It does not fall back to +default roots or `PATH`, regardless of `strict`. `strict` is a boolean with default `false`; it does +not require `tools.platform.path`. + +Contract matrix: + +| Configuration | Behavior | +| --- | --- | +| `version`, no `path` | Search default roots and `PATH` with version filtering. | +| `path + version`, `strict: false` | Search only `path`; ignore `version`. | +| `path + version`, `strict: true` | Search only `path`; require a matching version. | +| `path`, no `version` | Search only `path`; do not check version. | +| No `path`, no `version` | Normal default-root and `PATH` discovery. | +| `strict: true`, no `path` | No boundary is created; with `version`, behaves like version-only discovery; without `version`, it is a no-op. | + +Version requirements retain the existing semantics when they are applied: four components are exact; +two or three components are prefixes and select the highest matching installation. + +## Installation consistency + +When `strict: true` is combined with `path`, the first successfully resolved platform utility binds +the locator to its canonical installation root. Later resolution of `1cv8`, `1cv8c`, or `ibcmd` +uses only a direct or `bin` sibling below that root. A sibling elsewhere is rejected. For a file +path hint, `strict: false` resolves lexical siblings next to the configured file; `strict: true` +resolves siblings from the canonical installation. + +Each location carries a typed resolution source (`explicit`, `default-root`, or `path`), an absolute canonical executable path, inferred version, and canonical installation root. + +## JSON scope + +`launch` already exposes its selected binary as a public result. It will additionally expose structured platform resolution metadata containing absolute path, version, source, and installation root. Extending every command result would require a separate shared-envelope contract migration and is outside this focused locator fix. + +## Verification + +TDD covers version-only discovery, path-only no-fallback behavior, lenient path+version ignoring, +strict path+version mismatch and unknown-version errors, versioned-root selection, sibling +consistency, relative path normalization, schema validation, and launch JSON metadata. diff --git a/src/config/loader.rs b/src/config/loader.rs index 4252661..8405273 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -209,6 +209,9 @@ fn normalize_config_paths(config: &mut AppConfig, config_dir: &Path) { if let Some(path) = config.tools.va.epf_path.as_mut() { *path = normalize_optional_path(path, config_dir); } + if let Some(path) = config.tools.platform.path.as_mut() { + *path = normalize_optional_path(path, config_dir); + } if let Some(extension) = config.tools.client_mcp.extension.as_mut() { if let Some(source) = extension.source_mut() { source.path = normalize_optional_path(&source.path, config_dir); @@ -1244,4 +1247,75 @@ mod tests { Some("1c-edt-2025.2.3") ); } + + #[test] + fn load_config_defaults_platform_strict_to_false() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let src = base.join("src"); + std::fs::create_dir_all(&src).expect("src dir"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", + work.display() + ), + ) + .expect("write config"); + + let config = load_config(config_path.to_str(), None).expect("load config"); + + assert!(!config.tools.platform.strict); + } + + #[test] + fn load_config_accepts_strict_platform_without_path() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let src = base.join("src"); + std::fs::create_dir_all(&src).expect("src dir"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n platform:\n strict: true\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", + work.display() + ), + ) + .expect("write config"); + + let config = load_config(config_path.to_str(), None).expect("strict without path"); + + assert!(config.tools.platform.strict); + assert!(config.tools.platform.path.is_none()); + } + + #[test] + fn load_config_normalizes_relative_platform_path_against_config_directory() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let src = base.join("src"); + std::fs::create_dir_all(&src).expect("src dir"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n platform:\n path: platform/bin\n strict: false\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", + work.display() + ), + ) + .expect("write config"); + + let config = load_config(config_path.to_str(), None).expect("load config"); + let config_dir = std::fs::canonicalize(dir.path()).expect("canonical config dir"); + + assert_eq!( + config.tools.platform.path.as_deref(), + Some(config_dir.join("platform/bin").as_path()) + ); + } } diff --git a/src/config/model.rs b/src/config/model.rs index 7f65d17..70bca6d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -553,6 +553,15 @@ pub struct PlatformToolConfig { /// directory, or to a platform root that contains versioned subdirectories. pub path: Option, + /// Enforce `version` for a configured `path`. + /// + /// `path` is always an explicit-only search boundary. When `strict` is `false`, + /// `version` is ignored for that path; when `strict` is `true`, the executable + /// found inside the path must match `version`. Without `path`, `version` is + /// applied to normal default-root and PATH discovery. + #[serde(default)] + pub strict: bool, + /// Platform version requirement in `major.minor`, `major.minor.patch`, or /// `major.minor.patch.build` format. /// @@ -651,3 +660,18 @@ const fn default_edt_cli_startup_timeout_ms() -> u64 { const fn default_edt_cli_command_timeout_ms() -> u64 { 300_000 } + +#[cfg(test)] +mod tests { + use super::PlatformToolConfig; + + #[test] + fn platform_strict_defaults_to_false_and_deserializes_true() { + let default = PlatformToolConfig::default(); + assert!(!default.strict); + + let configured: PlatformToolConfig = + serde_yaml::from_str("strict: true\n").expect("deserialize strict platform config"); + assert!(configured.strict); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 46b9c4d..fc1b89e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -626,10 +626,13 @@ struct PartialToolsSchema { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] struct PlatformToolSchema { - /// Platform binary, installation `bin` directory, or platform root discovery hint. + /// Platform binary, installation `bin` directory, or platform root discovery hint. When set, platform discovery uses only this path and never falls back to default roots or PATH. #[serde(default, skip_serializing_if = "Option::is_none")] path: Option, - /// Platform version requirement used for discovery. + /// Enforce `version` for a configured `path`. Without `path`, `version` is applied to normal default-root and PATH discovery. + #[serde(default)] + strict: bool, + /// Platform version requirement. Without `path`, it filters normal discovery; with `path`, it is ignored unless `strict` is true. #[serde(default, skip_serializing_if = "Option::is_none")] version: Option, } @@ -1251,6 +1254,86 @@ mod tests { assert_config_loader_ok(&config); } + #[test] + fn platform_strict_main_schema_allows_path_to_come_from_local_overlay() { + let strict_without_path = format!( + "{}tools:\n platform:\n strict: true\n", + minimal_project_config_without_base_path() + ); + let strict_with_null_path = format!( + "{}tools:\n platform:\n path: null\n strict: true\n", + minimal_project_config_without_base_path() + ); + let non_strict_without_path = format!( + "{}tools:\n platform:\n strict: false\n", + minimal_project_config_without_base_path() + ); + + assert_schema_valid(&main_config_schema_json(), &strict_without_path); + assert_schema_valid(&main_config_schema_json(), &strict_with_null_path); + assert_schema_valid(&main_config_schema_json(), &non_strict_without_path); + } + + #[test] + fn local_schema_accepts_strict_platform_override_when_main_config_supplies_path() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("xml"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "{}tools:\n platform:\n path: platform/bin\n", + minimal_project_config_without_base_path() + ), + ) + .expect("config"); + let overlay = "tools:\n platform:\n strict: true\n"; + std::fs::write(dir.path().join("v8project.local.yaml"), overlay).expect("overlay"); + + assert_schema_valid(&local_config_schema_json(), overlay); + + let config = load_config(config_path.to_str(), None).expect("load merged config"); + assert!(config.tools.platform.strict); + assert_eq!( + config.tools.platform.path.as_deref(), + Some( + std::fs::canonicalize(dir.path()) + .expect("canonical config dir") + .join("platform/bin") + .as_path() + ) + ); + } + + #[test] + fn local_schema_can_supply_path_for_strict_primary_platform_config() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("xml"); + let config_path = dir.path().join("v8project.yaml"); + let primary = format!( + "{}tools:\n platform:\n strict: true\n", + minimal_project_config_without_base_path() + ); + std::fs::write(&config_path, &primary).expect("config"); + let overlay = "tools:\n platform:\n path: platform/bin\n"; + std::fs::write(dir.path().join("v8project.local.yaml"), overlay).expect("overlay"); + + assert_schema_valid(&main_config_schema_json(), &primary); + assert_schema_valid(&local_config_schema_json(), overlay); + + let config = load_config(config_path.to_str(), None).expect("load merged config"); + assert!(config.tools.platform.strict); + assert_eq!( + config.tools.platform.path.as_deref(), + Some( + std::fs::canonicalize(dir.path()) + .expect("canonical config dir") + .join("platform/bin") + .as_path() + ) + ); + } + #[test] fn local_schema_and_loader_accept_canonical_mixed_config_keys() { let overlay = "tools:\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n edt_cli:\n startup_timeout_ms: 300000\n command_timeout_ms: 300000\n"; diff --git a/src/config/validate.rs b/src/config/validate.rs index 85ebd29..d2fa149 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -1133,6 +1133,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: None, + strict: false, version: Some("8.3.25".to_owned()), }, ..ToolsConfig::default() @@ -1170,6 +1171,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: None, + strict: false, version: Some("8.3".to_owned()), }, ..ToolsConfig::default() @@ -1207,6 +1209,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: None, + strict: false, version: Some("8".to_owned()), }, ..ToolsConfig::default() diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 91093ab..f1f1271 100644 --- a/src/domain/launch.rs +++ b/src/domain/launch.rs @@ -13,6 +13,8 @@ pub struct LaunchResult { pub pid: Option, /// Selected binary path used to spawn the process. pub binary: PathBuf, + /// Canonical platform installation metadata for the selected binary. + pub platform_resolution: PlatformResolution, /// Human-readable launch summary. pub message: Option, /// Client-side MCP endpoint readiness details when readiness was requested. @@ -34,6 +36,31 @@ pub struct ExternalEpfWaitResult { pub stderr_path: String, } +/// Canonical platform installation metadata exposed by `launch` JSON results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformResolution { + /// Absolute canonical path to the selected executable. + pub path: PathBuf, + /// Platform version inferred from the canonical installation path, when known. + pub version: Option, + /// Discovery source used for the selected executable. + pub source: PlatformResolutionSource, + /// Absolute canonical root shared by platform utilities from this installation. + pub installation_root: PathBuf, +} + +/// Typed discovery sources exposed by `launch` resolution metadata. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PlatformResolutionSource { + /// The configured utility or installation hint. + Explicit, + /// An operating-system-specific default installation root. + DefaultRoot, + /// A directory captured from `PATH` when the locator was created. + Path, +} + /// Result of probing a client-side MCP endpoint after launch. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpReadinessResult { diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 66cc806..ea21465 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -993,7 +993,9 @@ mod tests { use crate::domain::dump::{DumpMode, DumpResult}; use crate::domain::execution::{ExecutionStepKind, StepResult}; use crate::domain::issue::{Issue, IssueSeverity, ModuleIssue}; - use crate::domain::launch::{LaunchMode, LaunchResult}; + use crate::domain::launch::{ + LaunchMode, LaunchResult, PlatformResolution, PlatformResolutionSource, + }; use crate::domain::runner::RunnerKind; use crate::domain::syntax::{SyntaxCheckResult, SyntaxCheckStatus, SyntaxIssueSummary}; use crate::domain::test::{ @@ -1825,6 +1827,7 @@ mod tests { mode: result_mode, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -1856,6 +1859,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -1904,6 +1908,7 @@ mod tests { mode: LaunchMode::Thin, pid: Some(42), binary: PathBuf::from("/opt/1cv8c"), + platform_resolution: sample_platform_resolution("/opt/1cv8c"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -1945,6 +1950,7 @@ mod tests { mode: LaunchMode::Thin, pid: Some(42), binary: PathBuf::from("/opt/1cv8c"), + platform_resolution: sample_platform_resolution("/opt/1cv8c"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -1980,6 +1986,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -2020,6 +2027,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -2060,6 +2068,7 @@ mod tests { mode: LaunchMode::Designer, pid: None, binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -2101,6 +2110,7 @@ mod tests { mode: LaunchMode::Designer, pid: None, binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, external_epf_wait: None, @@ -2532,6 +2542,15 @@ mod tests { } } + fn sample_platform_resolution(path: &str) -> PlatformResolution { + PlatformResolution { + path: PathBuf::from(path), + version: Some("8.3.25.1234".to_owned()), + source: PlatformResolutionSource::Explicit, + installation_root: PathBuf::from("/opt"), + } + } + fn sample_test_result(ok: bool) -> TestRunResult { let retained = RetainedPaths { run_dir: PathBuf::from("/tmp/run"), diff --git a/src/platform/locator.rs b/src/platform/locator.rs index 907ee04..cbb2fa4 100644 --- a/src/platform/locator.rs +++ b/src/platform/locator.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use thiserror::Error; +use crate::support::path::{nearest_existing_canonical_path, normalize_windows_verbatim_path}; + /// Executable-oriented platform utility identifiers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum UtilityType { @@ -182,6 +184,42 @@ pub enum UtilityVersion { Edt(EdtVersion), } +/// Resolution behavior for configured platform installation hints. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PlatformResolutionPolicy { + /// Do not enforce `tools.platform.version` for a configured platform path. + #[default] + Lenient, + /// Enforce `tools.platform.version` inside a configured platform path boundary. + Strict, +} + +/// Inputs used to construct a locator with OS-default discovery roots. +#[derive(Debug, Clone, Default)] +pub struct LocatorOptions { + /// Configured platform executable or installation boundary. + pub platform_hint: Option, + /// Optional platform version prefix or exact build. + pub platform_version: Option, + /// Whether a configured platform path enforces `platform_version`. + pub platform_policy: PlatformResolutionPolicy, + /// Configured EDT executable or installation hint. + pub edt_hint: Option, + /// Optional EDT discovery version. + pub edt_version: Option, +} + +/// Typed origin of a resolved executable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ResolutionSource { + /// The configured utility or installation hint. + Explicit, + /// An operating-system-specific default installation root. + DefaultRoot, + /// A directory captured from `PATH` when the locator was created. + Path, +} + /// Resolved utility path together with parsed version information. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UtilityLocation { @@ -191,47 +229,105 @@ pub struct UtilityLocation { pub path: PathBuf, /// Parsed version metadata if it could be derived from the path. pub version: Option, + /// Origin used to discover the executable. + pub source: ResolutionSource, + /// Canonical root shared by sibling executables from this installation. + pub installation_root: PathBuf, } -#[derive(Debug, Error)] +#[derive(Debug, Error, PartialEq, Eq)] pub enum LocatorError { #[error("utility '{0}' was not found")] NotFound(UtilityType), + #[error("utility '{utility}' was not found inside strict platform boundary '{boundary}'")] + StrictBoundaryNotFound { + utility: UtilityType, + boundary: PathBuf, + }, + #[error( + "utility '{utility}' at '{}' has unknown platform version; required {required}", + path.display() + )] + UnknownVersion { + utility: UtilityType, + path: PathBuf, + required: PlatformVersionRequirement, + }, + #[error( + "utility '{utility}' at '{}' has platform version {found}; required {required}", + path.display() + )] + VersionMismatch { + utility: UtilityType, + path: PathBuf, + required: PlatformVersionRequirement, + found: PlatformVersion, + }, + #[error( + "utility '{utility}' is missing from pinned platform installation '{}'", + installation_root.display() + )] + MissingSibling { + utility: UtilityType, + installation_root: PathBuf, + }, } #[derive(Debug, Clone)] struct Candidate { path: PathBuf, version: Option, + source: ResolutionSource, +} + +#[derive(Debug, Clone)] +struct CanonicalCandidate { + path: PathBuf, + version: Option, + source: ResolutionSource, + installation_root: PathBuf, +} + +#[derive(Debug, Clone)] +struct PinnedPlatformInstallation { + root: PathBuf, + source: ResolutionSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileHintSiblingResolution { + Lexical, + CanonicalInstallation, } /// Stateful utility locator with per-instance cache. pub struct Locator { platform_hint: Option, platform_version: Option, + platform_policy: PlatformResolutionPolicy, edt_hint: Option, edt_version: Option, cache: HashMap<(UtilityType, Option), UtilityLocation>, platform_roots: Vec, edt_roots: Vec, + path_roots: Vec, + pinned_platform: Option, } impl Locator { /// Build a locator using default OS-specific search roots. - pub fn new( - platform_hint: Option, - platform_version: Option, - edt_hint: Option, - edt_version: Option, - ) -> Self { + pub fn new(options: LocatorOptions) -> Self { Self { - platform_hint, - platform_version, - edt_hint, - edt_version, + platform_hint: options.platform_hint, + platform_version: options.platform_version, + platform_policy: options.platform_policy, + edt_hint: options.edt_hint, + edt_version: options.edt_version, cache: HashMap::new(), platform_roots: default_platform_roots(), edt_roots: default_edt_roots(), + path_roots: captured_path_roots(), + pinned_platform: None, } } @@ -240,28 +336,83 @@ impl Locator { let cache_key = (utility, self.version_requirement_string(utility)); if let Some(cached) = self.cache.get(&cache_key).cloned() { - if is_valid_executable(&cached.path) { - return Ok(cached); + if let Some(revalidated) = self.revalidate_cached_location(utility, &cached) { + return Ok(revalidated); } self.cache.remove(&cache_key); } - if let Some(location) = self.resolve_explicit_hint(utility) { - self.cache.insert(cache_key, location.clone()); - return Ok(location); - } + let selected = if utility.is_platform() { + self.locate_platform(utility)? + } else { + self.locate_edt(utility)? + }; + self.cache.insert(cache_key, selected.clone()); + Ok(selected) + } - let hint_candidates = self.search_hint_candidates(utility); - if !hint_candidates.is_empty() { - let selected = self.select_candidate(utility, hint_candidates)?; - self.cache.insert(cache_key, selected.clone()); - return Ok(selected); + fn revalidate_cached_location( + &self, + utility: UtilityType, + cached: &UtilityLocation, + ) -> Option { + if cached.utility != utility { + return None; + } + if utility.is_platform() + && self.platform_hint.is_some() + && self.platform_policy == PlatformResolutionPolicy::Lenient + { + return None; } - let candidates = self.search_candidates(utility); - let selected = self.select_candidate(utility, candidates)?; - self.cache.insert(cache_key, selected.clone()); - Ok(selected) + let candidate = Candidate { + path: cached.path.clone(), + version: cached.version.clone(), + source: cached.source, + }; + let current = match (utility, self.platform_policy, self.pinned_platform.as_ref()) { + ( + UtilityType::V8 | UtilityType::V8C | UtilityType::Ibcmd, + PlatformResolutionPolicy::Strict, + Some(pinned), + ) => select_pinned_candidate( + utility, + vec![candidate], + self.effective_platform_version_requirement(), + &pinned.root, + ), + ( + UtilityType::V8 | UtilityType::V8C | UtilityType::Ibcmd, + PlatformResolutionPolicy::Strict, + None, + ) => { + let boundary = self.platform_hint.as_deref().map(strict_candidate_boundary); + select_candidate( + utility, + vec![candidate], + self.effective_platform_version_requirement(), + boundary.as_deref(), + ) + } + ( + UtilityType::V8 | UtilityType::V8C | UtilityType::Ibcmd, + PlatformResolutionPolicy::Lenient, + Some(_) | None, + ) => select_candidate( + utility, + vec![candidate], + self.effective_platform_version_requirement(), + None, + ), + ( + UtilityType::EdtCli, + PlatformResolutionPolicy::Lenient | PlatformResolutionPolicy::Strict, + Some(_) | None, + ) => select_edt_candidate(vec![candidate], utility, None), + }?; + + (current == *cached).then_some(current) } #[cfg(test)] @@ -272,21 +423,48 @@ impl Locator { edt_version: Option, platform_roots: Vec, edt_roots: Vec, + ) -> Self { + Self::with_search_roots( + platform_hint, + platform_version, + PlatformResolutionPolicy::Lenient, + edt_hint, + edt_version, + platform_roots, + edt_roots, + Vec::new(), + ) + } + + #[cfg(test)] + pub(crate) fn with_search_roots( + platform_hint: Option, + platform_version: Option, + platform_policy: PlatformResolutionPolicy, + edt_hint: Option, + edt_version: Option, + platform_roots: Vec, + edt_roots: Vec, + path_roots: Vec, ) -> Self { Self { platform_hint, platform_version, + platform_policy, edt_hint, edt_version, cache: HashMap::new(), platform_roots, edt_roots, + path_roots, + pinned_platform: None, } } fn version_requirement_string(&self, utility: UtilityType) -> Option { if utility.is_platform() { - self.platform_version.as_ref().map(ToString::to_string) + self.effective_platform_version_requirement() + .map(ToString::to_string) } else { self.edt_version.as_ref().map(|version| { version @@ -299,134 +477,150 @@ impl Locator { } } - fn resolve_explicit_hint(&self, utility: UtilityType) -> Option { - let hint = if utility.is_platform() { - self.platform_hint.as_deref() + fn effective_platform_version_requirement(&self) -> Option<&PlatformVersionRequirement> { + if self.platform_hint.is_some() && self.platform_policy == PlatformResolutionPolicy::Lenient + { + None } else { - self.edt_hint.as_deref() - }?; - - let candidate = resolve_from_hint(hint, utility)?; - if !is_valid_executable(&candidate) { - return None; + self.platform_version.as_ref() } - - Some(UtilityLocation { - utility, - version: infer_version(utility, &candidate), - path: candidate, - }) } - fn search_candidates(&self, utility: UtilityType) -> Vec { - let mut candidates = Vec::new(); + fn locate_platform(&mut self, utility: UtilityType) -> Result { + if self.platform_policy == PlatformResolutionPolicy::Strict { + if let Some(pinned) = self.pinned_platform.as_ref() { + return select_pinned_candidate( + utility, + pinned_platform_candidates(utility, pinned), + self.effective_platform_version_requirement(), + &pinned.root, + ) + .ok_or_else(|| LocatorError::MissingSibling { + utility, + installation_root: pinned.root.clone(), + }); + } + } - if utility.is_platform() { - if let Some(required) = &self.platform_version { - candidates.extend(platform_candidates_for_version( + let direct_explicit_candidates = self + .platform_hint + .as_deref() + .map(|hint| { + explicit_direct_candidates( + hint, utility, - required, - &self.platform_roots, - )); - } else { - candidates.extend(platform_candidates_any_version( + match self.platform_policy { + PlatformResolutionPolicy::Lenient => FileHintSiblingResolution::Lexical, + PlatformResolutionPolicy::Strict => { + FileHintSiblingResolution::CanonicalInstallation + } + }, + ) + }) + .unwrap_or_default(); + let strict_boundary = match self.platform_policy { + PlatformResolutionPolicy::Lenient => None, + PlatformResolutionPolicy::Strict => { + self.platform_hint.as_deref().map(strict_candidate_boundary) + } + }; + let versioned_explicit_candidates = self + .platform_hint + .as_deref() + .filter(|hint| hint.is_dir()) + .map(|hint| { + platform_candidates_any_version( utility, - &self.platform_roots, - )); + std::slice::from_ref(&hint.to_path_buf()), + ResolutionSource::Explicit, + ) + }) + .unwrap_or_default(); + + if self.platform_hint.is_some() { + let mut explicit_candidates = direct_explicit_candidates; + explicit_candidates.extend(versioned_explicit_candidates); + let required = self.effective_platform_version_requirement(); + if let Some(location) = select_candidate( + utility, + explicit_candidates.clone(), + required, + strict_boundary.as_deref(), + ) { + self.pin_platform(&location); + return Ok(location); } - } else { - if let Some(required) = &self.edt_version { - candidates.extend(edt_candidates_for_version( + return match self.platform_policy { + PlatformResolutionPolicy::Strict => Err(strict_resolution_error( utility, + self.platform_hint.as_deref(), + explicit_candidates, required, - &self.edt_roots, - )); - } else { - candidates.extend(edt_candidates_any_version(utility, &self.edt_roots)); - } + strict_boundary.as_deref(), + )), + PlatformResolutionPolicy::Lenient => Err(LocatorError::NotFound(utility)), + }; } - candidates.extend(path_candidates(utility)); - candidates + let mut candidates = platform_candidates_any_version( + utility, + &self.platform_roots, + ResolutionSource::DefaultRoot, + ); + candidates.extend(path_candidates(utility, &self.path_roots)); + let location = select_candidate( + utility, + candidates, + self.effective_platform_version_requirement(), + None, + ) + .ok_or(LocatorError::NotFound(utility))?; + self.pin_platform(&location); + Ok(location) } - fn search_hint_candidates(&self, utility: UtilityType) -> Vec { - if utility.is_platform() { - let Some(hint) = self.platform_hint.as_ref() else { - return Vec::new(); - }; - if !hint.is_dir() { - return Vec::new(); - } - - if let Some(required) = &self.platform_version { - platform_candidates_for_version(utility, required, std::slice::from_ref(hint)) - } else { - platform_candidates_any_version(utility, std::slice::from_ref(hint)) - } - } else { - let Some(hint) = self.edt_hint.as_ref() else { - return Vec::new(); - }; - if !hint.is_dir() { - return Vec::new(); - } - - if let Some(required) = &self.edt_version { - edt_candidates_for_version(utility, required, std::slice::from_ref(hint)) - } else { - edt_candidates_any_version(utility, std::slice::from_ref(hint)) - } + fn pin_platform(&mut self, location: &UtilityLocation) { + if self.platform_policy != PlatformResolutionPolicy::Strict || self.platform_hint.is_none() + { + return; } + self.pinned_platform = Some(PinnedPlatformInstallation { + root: location.installation_root.clone(), + source: location.source, + }); } - fn select_candidate( - &self, - utility: UtilityType, - mut candidates: Vec, - ) -> Result { - candidates.retain(|candidate| is_valid_executable(&candidate.path)); - - if candidates.is_empty() { - return Err(LocatorError::NotFound(utility)); + fn locate_edt(&self, utility: UtilityType) -> Result { + let direct_explicit = self + .edt_hint + .as_deref() + .map(|hint| { + explicit_direct_candidates(hint, utility, FileHintSiblingResolution::Lexical) + }) + .unwrap_or_default(); + if let Some(location) = select_edt_candidate(direct_explicit, utility, None) { + return Ok(location); } - let chosen = if utility.is_platform() { - if let Some(required) = &self.platform_version { - candidates - .into_iter() - .filter(|candidate| { - matches!( - candidate.version.as_ref(), - Some(UtilityVersion::Platform(version)) if required.matches(version) - ) - }) - .max_by(|left, right| { - compare_versions(left.version.as_ref(), right.version.as_ref()) - }) - .ok_or(LocatorError::NotFound(utility))? - } else { - candidates - .into_iter() - .max_by(|left, right| { - compare_versions(left.version.as_ref(), right.version.as_ref()) - }) - .ok_or(LocatorError::NotFound(utility))? - } - } else { - candidates - .into_iter() - .max_by(|left, right| { - compare_versions(left.version.as_ref(), right.version.as_ref()) - }) - .ok_or(LocatorError::NotFound(utility))? + let versioned_explicit = match self.edt_hint.as_ref() { + Some(hint) if hint.is_dir() => edt_candidates_any_version( + utility, + std::slice::from_ref(hint), + ResolutionSource::Explicit, + ), + Some(_) | None => Vec::new(), }; + if let Some(location) = + select_edt_candidate(versioned_explicit, utility, self.edt_version.as_ref()) + { + return Ok(location); + } - Ok(UtilityLocation { - utility, - path: chosen.path, - version: chosen.version, - }) + let mut candidates = + edt_candidates_any_version(utility, &self.edt_roots, ResolutionSource::DefaultRoot); + candidates.extend(path_candidates(utility, &self.path_roots)); + select_edt_candidate(candidates, utility, self.edt_version.as_ref()) + .ok_or(LocatorError::NotFound(utility)) } } @@ -437,56 +631,86 @@ fn compare_versions( match (left, right) { (Some(UtilityVersion::Platform(a)), Some(UtilityVersion::Platform(b))) => a.cmp(b), (Some(UtilityVersion::Edt(a)), Some(UtilityVersion::Edt(b))) => a.cmp(b), - (Some(_), None) => std::cmp::Ordering::Greater, - (None, Some(_)) => std::cmp::Ordering::Less, - _ => std::cmp::Ordering::Equal, + (Some(UtilityVersion::Platform(_)), Some(UtilityVersion::Edt(_))) + | (Some(UtilityVersion::Edt(_)), Some(UtilityVersion::Platform(_))) => { + std::cmp::Ordering::Equal + } + (Some(UtilityVersion::Platform(_)) | Some(UtilityVersion::Edt(_)), None) => { + std::cmp::Ordering::Greater + } + (None, Some(UtilityVersion::Platform(_)) | Some(UtilityVersion::Edt(_))) => { + std::cmp::Ordering::Less + } + (None, None) => std::cmp::Ordering::Equal, } } -fn resolve_from_hint(hint: &Path, utility: UtilityType) -> Option { +fn explicit_direct_candidates( + hint: &Path, + utility: UtilityType, + sibling_resolution: FileHintSiblingResolution, +) -> Vec { if hint.is_file() { + let canonical_hint = canonical_boundary(hint); let target_name = utility.executable_name(); let file_name_matches = hint .file_name() - .and_then(|name| name.to_str()) - .map(|name| name == target_name) + .map(|name| executable_component_matches(name, target_name)) .unwrap_or(false); - return if file_name_matches { - Some(hint.to_path_buf()) + let path = if file_name_matches { + canonical_hint } else { - hint.parent().map(|parent| parent.join(target_name)) + let sibling_base = match sibling_resolution { + FileHintSiblingResolution::Lexical => hint, + FileHintSiblingResolution::CanonicalInstallation => canonical_hint.as_path(), + }; + match sibling_base.parent() { + Some(parent) => parent.join(target_name), + None => return Vec::new(), + } }; + return vec![candidate_from_path( + path, + utility, + ResolutionSource::Explicit, + )]; } if hint.is_dir() { - let direct = hint.join(utility.executable_name()); - if direct.exists() { - return Some(direct); - } - return Some(hint.join("bin").join(utility.executable_name())); + return direct_candidates(hint, utility, ResolutionSource::Explicit); } - None + Vec::new() } -fn platform_candidates_for_version( +fn direct_candidates( + root: &Path, utility: UtilityType, - required: &PlatformVersionRequirement, - roots: &[PathBuf], + source: ResolutionSource, ) -> Vec { - platform_candidates_any_version(utility, roots) - .into_iter() - .filter(|candidate| { - matches!( - candidate.version.as_ref(), - Some(UtilityVersion::Platform(version)) if required.matches(version) - ) - }) - .collect() + [ + root.join("bin").join(utility.executable_name()), + root.join(utility.executable_name()), + ] + .into_iter() + .map(|path| candidate_from_path(path, utility, source)) + .collect() } -fn platform_candidates_any_version(utility: UtilityType, roots: &[PathBuf]) -> Vec { +fn candidate_from_path(path: PathBuf, utility: UtilityType, source: ResolutionSource) -> Candidate { + Candidate { + version: infer_version(utility, &path), + path, + source, + } +} + +fn platform_candidates_any_version( + utility: UtilityType, + roots: &[PathBuf], + source: ResolutionSource, +) -> Vec { let mut candidates = Vec::new(); for root in roots { @@ -510,10 +734,12 @@ fn platform_candidates_any_version(utility: UtilityType, roots: &[PathBuf]) -> V candidates.push(Candidate { path: path.join(utility.executable_name()), version: Some(UtilityVersion::Platform(version.clone())), + source, }); candidates.push(Candidate { path: path.join("bin").join(utility.executable_name()), version: Some(UtilityVersion::Platform(version)), + source, }); } } @@ -521,7 +747,11 @@ fn platform_candidates_any_version(utility: UtilityType, roots: &[PathBuf]) -> V candidates } -fn edt_candidates_any_version(utility: UtilityType, roots: &[PathBuf]) -> Vec { +fn edt_candidates_any_version( + utility: UtilityType, + roots: &[PathBuf], + source: ResolutionSource, +) -> Vec { let mut candidates = Vec::new(); for root in roots { @@ -544,10 +774,12 @@ fn edt_candidates_any_version(utility: UtilityType, roots: &[PathBuf]) -> Vec Vec Vec { - edt_candidates_any_version(utility, roots) - .into_iter() - .filter(|candidate| { - matches!( - candidate.version.as_ref(), - Some(UtilityVersion::Edt(version)) if edt_version_matches(required, version) - ) - }) - .collect() -} - fn edt_version_matches(required: &EdtVersion, candidate: &EdtVersion) -> bool { if required.parts.is_empty() { return false; @@ -582,43 +798,315 @@ fn edt_version_matches(required: &EdtVersion, candidate: &EdtVersion) -> bool { .any(|window| window == required.parts.as_slice()) } -fn path_candidates(utility: UtilityType) -> Vec { - std::env::var_os("PATH") - .map(|paths| { - std::env::split_paths(&paths) - .map(|dir| { - let path = dir.join(utility.executable_name()); - Candidate { - version: infer_version(utility, &path), - path, - } - }) - .collect() +fn path_candidates(utility: UtilityType, roots: &[PathBuf]) -> Vec { + roots + .iter() + .map(|dir| { + candidate_from_path( + dir.join(utility.executable_name()), + utility, + ResolutionSource::Path, + ) }) - .unwrap_or_default() + .collect() } -fn infer_version(utility: UtilityType, path: &Path) -> Option { - let component_strings: Vec = path - .ancestors() - .flat_map(|ancestor| { - ancestor - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_owned) +fn captured_path_roots() -> Vec { + let roots = std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).collect()) + .unwrap_or_default(); + match std::env::current_dir() { + Ok(current_dir) => normalize_path_roots(roots, ¤t_dir), + Err(_) => roots + .into_iter() + .filter(|root| root.is_absolute()) + .collect(), + } +} + +fn normalize_path_roots(roots: Vec, current_dir: &Path) -> Vec { + roots + .into_iter() + .filter_map(|root| { + if root.is_absolute() { + Some(root) + } else { + absolutize_relative_path_root(root, current_dir) + } }) - .collect(); + .collect() +} - if utility.is_platform() { - component_strings - .iter() - .find_map(|value| PlatformVersion::parse_strict(value)) - .map(UtilityVersion::Platform) +#[cfg(not(windows))] +fn absolutize_relative_path_root(root: PathBuf, current_dir: &Path) -> Option { + Some(current_dir.join(root)) +} + +#[cfg(windows)] +fn absolutize_relative_path_root(root: PathBuf, current_dir: &Path) -> Option { + use std::path::{Component, Prefix}; + + fn disk_prefix(path: &Path) -> Option { + match path.components().next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => { + Some(drive.to_ascii_uppercase()) + } + Prefix::Verbatim(_) + | Prefix::UNC(_, _) + | Prefix::DeviceNS(_) + | Prefix::VerbatimUNC(_, _) => None, + }, + Some(Component::RootDir | Component::CurDir | Component::ParentDir) + | Some(Component::Normal(_)) + | None => None, + } + } + + let absolute = if let Some(root_drive) = disk_prefix(&root) { + if disk_prefix(current_dir) == Some(root_drive) { + current_dir.join(root.components().skip(1).collect::()) + } else { + std::path::absolute(root).ok()? + } } else { - component_strings + current_dir.join(root) + }; + + absolute.is_absolute().then_some(absolute) +} + +fn pinned_platform_candidates( + utility: UtilityType, + pinned: &PinnedPlatformInstallation, +) -> Vec { + direct_candidates(&pinned.root, utility, pinned.source) +} + +fn select_candidate( + utility: UtilityType, + candidates: Vec, + required: Option<&PlatformVersionRequirement>, + boundary: Option<&Path>, +) -> Option { + choose_candidate( + utility, + canonical_candidates(utility, candidates, boundary), + required, + ) +} + +fn select_pinned_candidate( + utility: UtilityType, + candidates: Vec, + required: Option<&PlatformVersionRequirement>, + installation_root: &Path, +) -> Option { + choose_candidate( + utility, + canonical_candidates(utility, candidates, Some(installation_root)) + .into_iter() + .filter(|candidate| candidate.installation_root.as_path() == installation_root), + required, + ) +} + +fn choose_candidate( + utility: UtilityType, + candidates: impl IntoIterator, + required: Option<&PlatformVersionRequirement>, +) -> Option { + candidates + .into_iter() + .filter(|candidate| match (required, candidate.version.as_ref()) { + (Some(required), Some(UtilityVersion::Platform(version))) => required.matches(version), + (Some(_), Some(UtilityVersion::Edt(_)) | None) => false, + (None, Some(UtilityVersion::Platform(_)) | Some(UtilityVersion::Edt(_)) | None) => true, + }) + .max_by(|left, right| compare_versions(left.version.as_ref(), right.version.as_ref())) + .map(|chosen| UtilityLocation { + utility, + path: chosen.path, + version: chosen.version, + source: chosen.source, + installation_root: chosen.installation_root, + }) +} + +fn select_edt_candidate( + candidates: Vec, + utility: UtilityType, + required: Option<&EdtVersion>, +) -> Option { + choose_candidate( + utility, + canonical_candidates(utility, candidates, None) + .into_iter() + .filter( + |candidate| match (required, candidate.source, candidate.version.as_ref()) { + ( + None, + ResolutionSource::Explicit + | ResolutionSource::DefaultRoot + | ResolutionSource::Path, + _, + ) => true, + (Some(_), ResolutionSource::Path, _) => true, + ( + Some(required), + ResolutionSource::Explicit | ResolutionSource::DefaultRoot, + Some(UtilityVersion::Edt(version)), + ) => edt_version_matches(required, version), + ( + Some(_), + ResolutionSource::Explicit | ResolutionSource::DefaultRoot, + Some(UtilityVersion::Platform(_)) | None, + ) => false, + }, + ), + None, + ) +} + +fn canonical_candidates( + utility: UtilityType, + candidates: Vec, + boundary: Option<&Path>, +) -> Vec { + candidates + .into_iter() + .filter_map(|mut candidate| { + if !is_valid_executable(&candidate.path) { + return None; + } + let path = + normalize_windows_verbatim_path(&std::fs::canonicalize(&candidate.path).ok()?); + let installation_root = normalize_windows_verbatim_path( + &std::fs::canonicalize(installation_root_for_executable(&path)).ok()?, + ); + if boundary.is_some_and(|boundary| { + !path.starts_with(boundary) || !installation_root.starts_with(boundary) + }) { + return None; + } + candidate.version = infer_version(utility, &path); + Some(CanonicalCandidate { + path, + version: candidate.version, + source: candidate.source, + installation_root, + }) + }) + .collect() +} + +fn strict_resolution_error( + utility: UtilityType, + hint: Option<&Path>, + candidates: Vec, + required: Option<&PlatformVersionRequirement>, + boundary: Option<&Path>, +) -> LocatorError { + let candidates = canonical_candidates(utility, candidates, boundary); + if let Some(required) = required { + let mismatch = candidates + .iter() + .filter_map(|candidate| match candidate.version.as_ref() { + Some(UtilityVersion::Platform(found)) if !required.matches(found) => { + Some((candidate, found)) + } + Some(UtilityVersion::Platform(_)) | Some(UtilityVersion::Edt(_)) | None => None, + }) + .max_by(|(left, left_version), (right, right_version)| { + left_version + .cmp(right_version) + .then_with(|| left.path.cmp(&right.path)) + }); + if let Some((candidate, found)) = mismatch { + return LocatorError::VersionMismatch { + utility, + path: candidate.path.clone(), + required: required.clone(), + found: found.clone(), + }; + } + + if let Some(candidate) = candidates .iter() - .find_map(|value| EdtVersion::parse_lenient(value)) - .map(UtilityVersion::Edt) + .find(|candidate| candidate.version.is_none()) + { + return LocatorError::UnknownVersion { + utility, + path: candidate.path.clone(), + required: required.clone(), + }; + } + } + + LocatorError::StrictBoundaryNotFound { + utility, + boundary: boundary + .map(Path::to_path_buf) + .or_else(|| hint.map(canonical_boundary)) + .unwrap_or_default(), + } +} + +fn canonical_boundary(path: &Path) -> PathBuf { + nearest_existing_canonical_path(path) + .map(|canonical| normalize_windows_verbatim_path(&canonical)) + .unwrap_or_else(|_| path.to_path_buf()) +} + +fn strict_candidate_boundary(hint: &Path) -> PathBuf { + let canonical = canonical_boundary(hint); + if hint.is_file() + || canonical + .file_name() + .is_some_and(|name| executable_component_matches(name, "bin")) + { + installation_root_for_executable(&canonical) + } else { + canonical + } +} + +fn installation_root_for_executable(path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or(path); + let is_utility_directory = parent.file_name().is_some_and(|name| { + executable_component_matches(name, "bin") || executable_component_matches(name, "1cedt") + }); + if is_utility_directory { + parent.parent().unwrap_or(parent).to_path_buf() + } else { + parent.to_path_buf() + } +} + +fn executable_component_matches(actual: &std::ffi::OsStr, expected: &str) -> bool { + #[cfg(windows)] + { + actual + .to_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + } + + #[cfg(not(windows))] + { + actual == std::ffi::OsStr::new(expected) + } +} + +fn infer_version(utility: UtilityType, path: &Path) -> Option { + let installation_root = installation_root_for_executable(path); + let version_text = installation_root.file_name().and_then(|name| name.to_str()); + match utility { + UtilityType::V8 | UtilityType::V8C | UtilityType::Ibcmd => version_text + .and_then(PlatformVersion::parse_strict) + .map(UtilityVersion::Platform), + UtilityType::EdtCli => version_text + .and_then(EdtVersion::parse_lenient) + .map(UtilityVersion::Edt), } } @@ -704,8 +1192,9 @@ fn default_edt_roots() -> Vec { #[cfg(test)] mod tests { use super::{ - EdtVersion, Locator, PlatformVersion, PlatformVersionRequirement, UtilityType, - UtilityVersion, + normalize_path_roots, normalize_windows_verbatim_path, EdtVersion, Locator, LocatorError, + PlatformResolutionPolicy, PlatformVersion, PlatformVersionRequirement, ResolutionSource, + UtilityType, UtilityVersion, }; use std::fs; use std::path::{Path, PathBuf}; @@ -720,16 +1209,19 @@ mod tests { fs::set_permissions(path, perms).expect("chmod"); } - #[cfg(unix)] fn touch_executable(path: &Path) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create dirs"); } fs::write(path, "#!/bin/sh\nexit 0\n").expect("write"); + #[cfg(unix)] make_executable(path); } - #[cfg(unix)] + fn canonical(path: &Path) -> PathBuf { + normalize_windows_verbatim_path(&path.canonicalize().expect("canonical path")) + } + fn touch_versioned_platform_executable( root: &Path, version: &str, @@ -747,9 +1239,648 @@ mod tests { path } - #[test] - fn parse_strict_platform_version_requires_four_parts() { - assert!(PlatformVersion::parse_strict("8.3.25").is_none()); + fn strict_locator( + hint: PathBuf, + version: Option<&str>, + platform_roots: Vec, + path_roots: Vec, + ) -> Locator { + Locator::with_search_roots( + Some(hint), + version.and_then(PlatformVersionRequirement::parse), + PlatformResolutionPolicy::Strict, + None, + None, + platform_roots, + Vec::new(), + path_roots, + ) + } + + fn lenient_locator( + hint: Option, + version: Option<&str>, + platform_roots: Vec, + path_roots: Vec, + ) -> Locator { + Locator::with_search_roots( + hint, + version.and_then(PlatformVersionRequirement::parse), + PlatformResolutionPolicy::Lenient, + None, + None, + platform_roots, + Vec::new(), + path_roots, + ) + } + + #[test] + fn strict_resolution_does_not_fallback_to_default_or_path_roots() { + let dir = tempdir().expect("tempdir"); + let explicit = dir.path().join("explicit"); + let default_root = dir.path().join("default"); + let path_root = dir.path().join("path"); + fs::create_dir_all(&explicit).expect("explicit root"); + touch_versioned_platform_executable(&default_root, "8.3.25.1234", UtilityType::V8, true); + touch_executable(&path_root.join(UtilityType::V8.executable_name())); + + let mut locator = + strict_locator(explicit.clone(), None, vec![default_root], vec![path_root]); + + assert_eq!( + locator.locate(UtilityType::V8).expect_err("strict failure"), + LocatorError::StrictBoundaryNotFound { + utility: UtilityType::V8, + boundary: canonical(&explicit), + } + ); + } + + #[test] + fn strict_resolution_reports_exact_version_mismatch() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let binary = + touch_versioned_platform_executable(&root, "8.3.25.9999", UtilityType::V8, true); + let mut locator = strict_locator(root, Some("8.3.25.1234"), vec![], vec![]); + + assert_eq!( + locator.locate(UtilityType::V8).expect_err("mismatch"), + LocatorError::VersionMismatch { + utility: UtilityType::V8, + path: canonical(&binary), + required: PlatformVersionRequirement::parse("8.3.25.1234").expect("requirement"), + found: PlatformVersion::parse_strict("8.3.25.9999").expect("version"), + } + ); + } + + #[test] + fn strict_resolution_reports_prefix_version_mismatch() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let binary = + touch_versioned_platform_executable(&root, "8.3.24.9999", UtilityType::V8C, false); + let mut locator = strict_locator(root, Some("8.3.25"), vec![], vec![]); + + assert_eq!( + locator.locate(UtilityType::V8C).expect_err("mismatch"), + LocatorError::VersionMismatch { + utility: UtilityType::V8C, + path: canonical(&binary), + required: PlatformVersionRequirement::parse("8.3.25").expect("requirement"), + found: PlatformVersion::parse_strict("8.3.24.9999").expect("version"), + } + ); + } + + #[test] + fn strict_resolution_rejects_unknown_version_when_requirement_is_configured() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let binary = root.join("bin").join(UtilityType::Ibcmd.executable_name()); + touch_executable(&binary); + let mut locator = strict_locator(root, Some("8.3"), vec![], vec![]); + + assert_eq!( + locator.locate(UtilityType::Ibcmd).expect_err("unknown"), + LocatorError::UnknownVersion { + utility: UtilityType::Ibcmd, + path: canonical(&binary), + required: PlatformVersionRequirement::parse("8.3").expect("requirement"), + } + ); + } + + #[test] + fn strict_resolution_does_not_infer_version_from_outer_ancestor() { + let dir = tempdir().expect("tempdir"); + let installation = dir + .path() + .join("8.3.25.1234") + .join("unversioned-installation"); + let binary = installation + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&binary); + let mut locator = strict_locator(installation, Some("8.3.25.1234"), vec![], vec![]); + + assert_eq!( + locator + .locate(UtilityType::V8) + .expect_err("outer ancestor must not define installation version"), + LocatorError::UnknownVersion { + utility: UtilityType::V8, + path: canonical(&binary), + required: PlatformVersionRequirement::parse("8.3.25.1234").expect("requirement"), + } + ); + } + + #[test] + fn strict_resolution_selects_highest_version_and_reports_explicit_source() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let older = + touch_versioned_platform_executable(&root, "8.3.25.1000", UtilityType::V8, false); + let wanted = + touch_versioned_platform_executable(&root, "8.3.25.9999", UtilityType::V8, true); + let mut locator = strict_locator(root, Some("8.3.25"), vec![], vec![]); + + let location = locator.locate(UtilityType::V8).expect("locate"); + + assert_ne!(location.path, older); + assert_eq!(location.path, canonical(&wanted)); + assert_eq!(location.source, ResolutionSource::Explicit); + assert_eq!( + location.installation_root, + canonical( + wanted + .parent() + .and_then(Path::parent) + .expect("installation root") + ) + ); + } + + #[cfg(unix)] + #[test] + fn strict_prefix_selects_highest_across_direct_and_versioned_candidates() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let lower = + touch_versioned_platform_executable(&root, "8.3.25.1000", UtilityType::V8, true); + let higher = + touch_versioned_platform_executable(&root, "8.3.25.9999", UtilityType::V8, true); + let direct_alias = root.join("bin").join(UtilityType::V8.executable_name()); + fs::create_dir_all(direct_alias.parent().expect("direct parent")).expect("direct parent"); + std::os::unix::fs::symlink(&lower, &direct_alias).expect("direct alias"); + let mut locator = strict_locator(root, Some("8.3.25"), vec![], vec![]); + + let location = locator.locate(UtilityType::V8).expect("highest candidate"); + + assert_eq!(location.path, canonical(&higher)); + assert_eq!( + location.version, + Some(UtilityVersion::Platform( + PlatformVersion::parse_strict("8.3.25.9999").expect("version") + )) + ); + } + + #[test] + fn platform_resolution_pins_siblings_to_first_installation() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let fallback_root = dir.path().join("fallback"); + let v8 = touch_versioned_platform_executable( + &explicit_root, + "8.3.25.1234", + UtilityType::V8, + true, + ); + touch_versioned_platform_executable(&fallback_root, "8.3.25.1234", UtilityType::V8C, true); + let mut locator = strict_locator( + explicit_root, + Some("8.3.25.1234"), + vec![fallback_root], + vec![], + ); + let first = locator.locate(UtilityType::V8).expect("first utility"); + + assert_eq!(first.path, canonical(&v8)); + assert_eq!( + locator + .locate(UtilityType::V8C) + .expect_err("missing sibling"), + LocatorError::MissingSibling { + utility: UtilityType::V8C, + installation_root: first.installation_root, + } + ); + } + + #[cfg(unix)] + #[test] + fn pinned_resolution_rejects_sibling_symlink_to_nested_installation() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let installation = explicit_root.join("8.3.25.1234"); + let v8 = installation + .join("bin") + .join(UtilityType::V8.executable_name()); + let nested_installation = installation.join("nested").join("8.3.25.1234"); + let nested_v8c = nested_installation + .join("bin") + .join(UtilityType::V8C.executable_name()); + let sibling_alias = installation + .join("bin") + .join(UtilityType::V8C.executable_name()); + touch_executable(&v8); + touch_executable(&nested_v8c); + std::os::unix::fs::symlink(&nested_v8c, &sibling_alias).expect("sibling symlink"); + let mut locator = strict_locator(explicit_root, Some("8.3.25.1234"), vec![], vec![]); + let first = locator.locate(UtilityType::V8).expect("first utility"); + + assert_eq!( + locator + .locate(UtilityType::V8C) + .expect_err("nested installation must not satisfy pin"), + LocatorError::MissingSibling { + utility: UtilityType::V8C, + installation_root: first.installation_root, + } + ); + } + + #[cfg(unix)] + #[test] + fn strict_resolution_rejects_version_directory_symlink_outside_boundary() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let outside_root = dir.path().join("outside").join("8.3.25.1234"); + let outside_binary = outside_root.join("bin").join("1cv8"); + touch_executable(&outside_binary); + fs::create_dir_all(&explicit_root).expect("explicit root"); + std::os::unix::fs::symlink(&outside_root, explicit_root.join("8.3.25.1234")) + .expect("version symlink"); + let mut locator = + strict_locator(explicit_root.clone(), Some("8.3.25.1234"), vec![], vec![]); + + assert_eq!( + locator + .locate(UtilityType::V8) + .expect_err("boundary escape"), + LocatorError::StrictBoundaryNotFound { + utility: UtilityType::V8, + boundary: canonical(&explicit_root), + } + ); + } + + #[cfg(unix)] + #[test] + fn strict_resolution_recomputes_version_after_canonicalizing_alias() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let actual_root = explicit_root.join("8.3.26.9999"); + let actual_binary = actual_root.join("bin").join("1cv8"); + touch_executable(&actual_binary); + std::os::unix::fs::symlink(&actual_root, explicit_root.join("8.3.25.1234")) + .expect("version alias"); + let mut locator = strict_locator(explicit_root, Some("8.3.25.1234"), vec![], vec![]); + + assert_eq!( + locator.locate(UtilityType::V8).expect_err("alias mismatch"), + LocatorError::VersionMismatch { + utility: UtilityType::V8, + path: canonical(&actual_binary), + required: PlatformVersionRequirement::parse("8.3.25.1234").expect("requirement"), + found: PlatformVersion::parse_strict("8.3.26.9999").expect("actual version"), + } + ); + } + + #[cfg(unix)] + #[test] + fn strict_file_symlink_hint_resolves_sibling_in_canonical_installation() { + let dir = tempdir().expect("tempdir"); + let installation = dir.path().join("actual").join("8.3.25.1234"); + let v8 = installation + .join("bin") + .join(UtilityType::V8.executable_name()); + let v8c = installation + .join("bin") + .join(UtilityType::V8C.executable_name()); + touch_executable(&v8); + touch_executable(&v8c); + let aliases = dir.path().join("aliases"); + fs::create_dir_all(&aliases).expect("alias root"); + let hint = aliases.join(UtilityType::V8.executable_name()); + std::os::unix::fs::symlink(&v8, &hint).expect("file symlink"); + let mut missing_locator = strict_locator(hint.clone(), Some("8.3.25.1234"), vec![], vec![]); + assert_eq!( + missing_locator + .locate(UtilityType::Ibcmd) + .expect_err("missing canonical sibling"), + LocatorError::StrictBoundaryNotFound { + utility: UtilityType::Ibcmd, + boundary: canonical(&installation), + } + ); + let mut locator = strict_locator(hint, Some("8.3.25.1234"), vec![], vec![]); + + let location = locator.locate(UtilityType::V8C).expect("canonical sibling"); + + assert_eq!(location.path, canonical(&v8c)); + assert_eq!(location.installation_root, canonical(&installation)); + assert_eq!(location.source, ResolutionSource::Explicit); + } + + #[cfg(unix)] + #[test] + fn strict_cache_rejects_executable_replaced_by_outbound_symlink() { + let dir = tempdir().expect("tempdir"); + let installation = dir.path().join("platform").join("8.3.25.1234"); + let binary = installation + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&binary); + let mut locator = strict_locator(installation.clone(), Some("8.3.25.1234"), vec![], vec![]); + let first = locator.locate(UtilityType::V8).expect("initial location"); + let outside = dir + .path() + .join("outside") + .join("8.3.25.1234") + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&outside); + fs::remove_file(&binary).expect("replace cached executable"); + std::os::unix::fs::symlink(&outside, &binary).expect("outbound symlink"); + + assert_eq!( + locator + .locate(UtilityType::V8) + .expect_err("cached path must be revalidated"), + LocatorError::MissingSibling { + utility: UtilityType::V8, + installation_root: first.installation_root, + } + ); + } + + #[test] + fn empty_path_component_is_captured_as_current_directory() { + let current = PathBuf::from("/captured/current-directory"); + + assert_eq!( + normalize_path_roots( + vec![PathBuf::new(), PathBuf::from("/configured/bin")], + ¤t, + ), + vec![current, PathBuf::from("/configured/bin")] + ); + } + + #[test] + fn relative_path_components_are_absolutized_against_captured_current_directory() { + #[cfg(windows)] + let captured = PathBuf::from(r"C:\captured\current-directory"); + #[cfg(windows)] + let absolute = PathBuf::from(r"C:\absolute\bin"); + #[cfg(not(windows))] + let captured = PathBuf::from("/captured/current-directory"); + #[cfg(not(windows))] + let absolute = PathBuf::from("/absolute/bin"); + + assert_eq!( + normalize_path_roots( + vec![ + PathBuf::from("relative/bin"), + PathBuf::from("."), + absolute.clone(), + ], + &captured, + ), + vec![captured.join("relative/bin"), captured, absolute] + ); + } + + #[cfg(windows)] + #[test] + fn windows_drive_relative_path_components_use_captured_drive_directories() { + let captured = PathBuf::from(r"C:\captured\current-directory"); + let other_drive = PathBuf::from(r"D:other"); + let resolved_other_drive = std::path::absolute(&other_drive) + .expect("Windows resolves a drive-relative path using that drive's current directory"); + + assert_eq!( + normalize_path_roots(vec![PathBuf::from(r"C:tools"), other_drive], &captured), + vec![captured.join("tools"), resolved_other_drive] + ); + } + + #[cfg(windows)] + #[test] + fn windows_executable_hint_identity_is_ascii_case_insensitive() { + let dir = tempdir().expect("tempdir"); + let hint = dir.path().join("1CV8.EXE"); + touch_executable(&hint); + + let candidates = super::explicit_direct_candidates( + &hint, + UtilityType::V8, + super::FileHintSiblingResolution::Lexical, + ); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].path, canonical(&hint)); + } + + #[cfg(windows)] + #[test] + fn windows_installation_root_layout_components_are_ascii_case_insensitive() { + let version_root = PathBuf::from(r"C:\Program Files\1cv8\8.3.25.1234"); + + assert_eq!( + super::installation_root_for_executable(&version_root.join(r"BIN\1CV8.EXE")), + version_root + ); + assert_eq!( + super::installation_root_for_executable(&version_root.join(r"1CEDT\1CEDTCLI.EXE")), + version_root + ); + } + + #[test] + fn lenient_path_resolution_ignores_version_for_direct_hint() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let default_root = dir.path().join("default"); + let explicit = explicit_root + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&explicit); + touch_versioned_platform_executable(&default_root, "8.3.25.1234", UtilityType::V8, true); + let mut locator = lenient_locator( + Some(explicit_root), + Some("8.3.25.1234"), + vec![default_root], + vec![], + ); + + let location = locator.locate(UtilityType::V8).expect("direct hint"); + + assert_eq!(location.path, canonical(&explicit)); + assert_eq!(location.version, None); + assert_eq!(location.source, ResolutionSource::Explicit); + } + + #[test] + fn lenient_path_resolution_ignores_version_for_versioned_hint() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let default_root = dir.path().join("default"); + let explicit = touch_versioned_platform_executable( + &explicit_root, + "8.3.24.9999", + UtilityType::V8, + true, + ); + touch_versioned_platform_executable(&default_root, "8.3.25.1234", UtilityType::V8, true); + let mut locator = lenient_locator( + Some(explicit_root), + Some("8.3.25.1234"), + vec![default_root], + vec![], + ); + + let location = locator + .locate(UtilityType::V8) + .expect("version ignored for explicit path"); + + assert_eq!(location.path, canonical(&explicit)); + assert_eq!( + location.version, + Some(UtilityVersion::Platform( + PlatformVersion::parse_strict("8.3.24.9999").expect("version") + )) + ); + assert_eq!(location.source, ResolutionSource::Explicit); + } + + #[test] + fn lenient_path_resolution_does_not_fallback_to_default_roots() { + let dir = tempdir().expect("tempdir"); + let explicit_root = dir.path().join("explicit"); + let default_root = dir.path().join("default"); + let v8 = touch_versioned_platform_executable( + &explicit_root, + "8.3.25.1234", + UtilityType::V8, + true, + ); + touch_versioned_platform_executable(&default_root, "8.3.25.1234", UtilityType::V8C, true); + let mut locator = lenient_locator( + Some(explicit_root), + Some("8.3.25.1234"), + vec![default_root], + vec![], + ); + + let first = locator.locate(UtilityType::V8).expect("explicit v8"); + + assert_eq!(first.path, canonical(&v8)); + assert_eq!(first.source, ResolutionSource::Explicit); + assert_eq!( + locator + .locate(UtilityType::V8C) + .expect_err("configured path must not fallback"), + LocatorError::NotFound(UtilityType::V8C) + ); + } + + #[cfg(unix)] + #[test] + fn lenient_file_symlink_hint_cache_revalidates_current_hint() { + let dir = tempdir().expect("tempdir"); + let actual = dir + .path() + .join("actual") + .join("8.3.25.1234") + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&actual); + let aliases = dir.path().join("aliases"); + fs::create_dir_all(&aliases).expect("aliases"); + let hint = aliases.join(UtilityType::V8.executable_name()); + std::os::unix::fs::symlink(&actual, &hint).expect("hint symlink"); + let mut locator = lenient_locator(Some(hint.clone()), None, vec![], vec![]); + + let first = locator.locate(UtilityType::V8).expect("initial hint"); + fs::remove_file(&hint).expect("remove hint"); + + assert_eq!(first.path, canonical(&actual)); + assert_eq!( + locator + .locate(UtilityType::V8) + .expect_err("current hint is gone"), + LocatorError::NotFound(UtilityType::V8) + ); + } + + #[test] + fn version_only_resolution_uses_default_roots_and_path_with_version_filter() { + let dir = tempdir().expect("tempdir"); + let default_root = dir.path().join("default"); + let path_root = dir.path().join("path-bin"); + touch_versioned_platform_executable(&default_root, "8.3.24.9999", UtilityType::V8, true); + let wanted = touch_versioned_platform_executable( + &default_root, + "8.3.25.1234", + UtilityType::V8, + true, + ); + touch_executable(&path_root.join(UtilityType::V8.executable_name())); + let mut locator = lenient_locator( + None, + Some("8.3.25.1234"), + vec![default_root], + vec![path_root], + ); + + let location = locator.locate(UtilityType::V8).expect("versioned utility"); + + assert_eq!(location.path, canonical(&wanted)); + assert_eq!(location.source, ResolutionSource::DefaultRoot); + } + + #[test] + fn strict_without_path_uses_default_roots_with_version_filter() { + let dir = tempdir().expect("tempdir"); + let default_root = dir.path().join("default"); + let wanted = touch_versioned_platform_executable( + &default_root, + "8.3.25.1234", + UtilityType::V8, + true, + ); + let mut locator = Locator::with_search_roots( + None, + PlatformVersionRequirement::parse("8.3.25.1234"), + PlatformResolutionPolicy::Strict, + None, + None, + vec![default_root], + vec![], + vec![], + ); + + let location = locator.locate(UtilityType::V8).expect("versioned utility"); + + assert_eq!(location.path, canonical(&wanted)); + assert_eq!(location.source, ResolutionSource::DefaultRoot); + } + + #[test] + fn lenient_resolution_without_hint_uses_injected_path_roots_and_reports_path_source() { + let dir = tempdir().expect("tempdir"); + let path_root = dir.path().join("path-bin"); + let binary = path_root.join(UtilityType::Ibcmd.executable_name()); + touch_executable(&binary); + let mut locator = lenient_locator(None, None, vec![], vec![path_root.clone()]); + + let location = locator.locate(UtilityType::Ibcmd).expect("PATH utility"); + + assert_eq!(location.path, canonical(&binary)); + assert_eq!(location.source, ResolutionSource::Path); + assert_eq!(location.installation_root, canonical(&path_root)); + } + + #[test] + fn parse_strict_platform_version_requires_four_parts() { + assert!(PlatformVersion::parse_strict("8.3.25").is_none()); assert!(PlatformVersion::parse_strict("8.3.25.1234").is_some()); } @@ -792,7 +1923,54 @@ mod tests { let mut locator = Locator::with_roots(Some(v8.clone()), None, None, None, vec![], vec![]); - assert_eq!(locator.locate(UtilityType::V8C).expect("locate").path, v8c); + assert_eq!( + locator.locate(UtilityType::V8C).expect("locate").path, + canonical(&v8c) + ); + } + + #[cfg(unix)] + #[test] + fn explicit_file_alias_uses_lexical_name_for_utility_identity() { + let dir = tempdir().expect("tempdir"); + let actual = dir.path().join("actual").join("bin").join("1cv8-real"); + touch_executable(&actual); + let aliases = dir.path().join("aliases"); + fs::create_dir_all(&aliases).expect("aliases"); + let hint = aliases.join(UtilityType::V8.executable_name()); + std::os::unix::fs::symlink(&actual, &hint).expect("file alias"); + let mut locator = Locator::with_roots(Some(hint), None, None, None, vec![], vec![]); + + assert_eq!( + locator.locate(UtilityType::V8).expect("aliased V8").path, + canonical(&actual) + ); + } + + #[cfg(unix)] + #[test] + fn fallback_file_symlink_hint_resolves_lexical_sibling() { + let dir = tempdir().expect("tempdir"); + let actual_v8 = dir + .path() + .join("actual") + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&actual_v8); + let aliases = dir.path().join("aliases"); + fs::create_dir_all(&aliases).expect("aliases"); + let hint = aliases.join(UtilityType::V8.executable_name()); + let lexical_v8c = aliases.join(UtilityType::V8C.executable_name()); + std::os::unix::fs::symlink(&actual_v8, &hint).expect("V8 alias"); + touch_executable(&lexical_v8c); + let mut locator = Locator::with_roots(Some(hint), None, None, None, vec![], vec![]); + + let location = locator + .locate(UtilityType::V8C) + .expect("lexical fallback sibling"); + + assert_eq!(location.path, canonical(&lexical_v8c)); + assert_eq!(location.source, ResolutionSource::Explicit); } #[cfg(unix)] @@ -807,7 +1985,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::V8).expect("locate").path, - binary + canonical(&binary) ); } @@ -823,7 +2001,10 @@ mod tests { let mut locator = Locator::with_roots(Some(root), Some(version), None, None, vec![], vec![]); - assert_eq!(locator.locate(UtilityType::V8C).expect("locate").path, thin); + assert_eq!( + locator.locate(UtilityType::V8C).expect("locate").path, + canonical(&thin) + ); } #[cfg(unix)] @@ -840,7 +2021,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::Ibcmd).expect("locate").path, - ibcmd + canonical(&ibcmd) ); } @@ -864,7 +2045,7 @@ mod tests { ); let location = locator.locate(UtilityType::V8).expect("locate"); - assert_eq!(location.path, wanted); + assert_eq!(location.path, canonical(&wanted)); assert!(matches!( location.version, Some(UtilityVersion::Platform(_)) @@ -893,7 +2074,7 @@ mod tests { ); let location = locator.locate(UtilityType::V8).expect("locate"); - assert_eq!(location.path, wanted); + assert_eq!(location.path, canonical(&wanted)); assert_eq!( location.version, Some(UtilityVersion::Platform( @@ -926,7 +2107,7 @@ mod tests { ); let location = locator.locate(UtilityType::V8).expect("locate"); - assert_eq!(location.path, wanted); + assert_eq!(location.path, canonical(&wanted)); assert_eq!( location.version, Some(UtilityVersion::Platform( @@ -962,7 +2143,10 @@ mod tests { vec![root.clone()], vec![], ); - assert_eq!(exact_locator.locate(utility).expect("exact").path, exact); + assert_eq!( + exact_locator.locate(utility).expect("exact").path, + canonical(&exact) + ); let mut patch_locator = Locator::with_roots( None, @@ -974,7 +2158,7 @@ mod tests { ); assert_eq!( patch_locator.locate(utility).expect("patch").path, - patch_best + canonical(&patch_best) ); let mut minor_locator = Locator::with_roots( @@ -985,7 +2169,10 @@ mod tests { vec![root], vec![], ); - assert_eq!(minor_locator.locate(utility).expect("minor").path, exact); + assert_eq!( + minor_locator.locate(utility).expect("minor").path, + canonical(&exact) + ); } } @@ -1022,7 +2209,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::EdtCli).expect("locate").path, - newer + canonical(&newer) ); } @@ -1038,14 +2225,14 @@ mod tests { let mut locator = Locator::with_roots(None, Some(version), None, None, vec![root.clone()], vec![]); let first_path = locator.locate(UtilityType::V8).expect("first").path; - assert_eq!(first_path, first); + assert_eq!(first_path, canonical(&first)); fs::remove_file(&first).expect("remove"); let second = root.join("8.3.25.1234").join("bin").join("1cv8"); touch_executable(&second); let second_path = locator.locate(UtilityType::V8).expect("second").path; - assert_eq!(second_path, second); + assert_eq!(second_path, canonical(&second)); } #[test] @@ -1083,10 +2270,70 @@ mod tests { assert_eq!( locator.locate(UtilityType::EdtCli).expect("locate").path, - wanted + canonical(&wanted) + ); + } + + #[cfg(unix)] + #[test] + fn edt_version_requirement_rejects_canonical_symlink_mismatch() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("edt"); + let actual = dir.path().join("actual").join("1c-edt-2024.1.0+10-x86_64"); + let binary = actual + .join("1cedt") + .join(UtilityType::EdtCli.executable_name()); + touch_executable(&binary); + fs::create_dir_all(&root).expect("EDT root"); + std::os::unix::fs::symlink(&actual, root.join("1c-edt-2025.2.3+30-x86_64")) + .expect("version alias"); + let mut locator = Locator::with_roots( + None, + None, + None, + EdtVersion::parse_lenient("2025.2.3"), + vec![], + vec![root], + ); + + assert_eq!( + locator + .locate(UtilityType::EdtCli) + .expect_err("canonical EDT version mismatch"), + LocatorError::NotFound(UtilityType::EdtCli) ); } + #[cfg(unix)] + #[test] + fn edt_version_requirement_accepts_matching_canonical_symlink_target() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("edt"); + let actual = dir.path().join("actual").join("1c-edt-2025.2.3+30-x86_64"); + let binary = actual + .join("1cedt") + .join(UtilityType::EdtCli.executable_name()); + touch_executable(&binary); + fs::create_dir_all(&root).expect("EDT root"); + std::os::unix::fs::symlink(&actual, root.join("1c-edt-2024.1.0+10-x86_64")) + .expect("version alias"); + let mut locator = Locator::with_roots( + None, + None, + None, + EdtVersion::parse_lenient("2025.2.3"), + vec![], + vec![root], + ); + + let location = locator + .locate(UtilityType::EdtCli) + .expect("canonical EDT version match"); + + assert_eq!(location.path, canonical(&binary)); + assert!(matches!(location.version, Some(UtilityVersion::Edt(_)))); + } + #[cfg(unix)] #[test] fn edt_search_accepts_plain_numeric_version_hint() { @@ -1114,7 +2361,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::EdtCli).expect("locate").path, - wanted + canonical(&wanted) ); } } diff --git a/src/platform/utilities.rs b/src/platform/utilities.rs index 47d50e4..fc5aa56 100644 --- a/src/platform/utilities.rs +++ b/src/platform/utilities.rs @@ -1,6 +1,7 @@ use crate::config::model::AppConfig; use crate::platform::locator::{ - EdtVersion, Locator, PlatformVersionRequirement, UtilityLocation, UtilityType, + EdtVersion, Locator, LocatorOptions, PlatformResolutionPolicy, PlatformVersionRequirement, + UtilityLocation, UtilityType, }; use crate::platform::process::{ProcessExecutor, ProcessRunner}; use tracing::debug; @@ -37,17 +38,22 @@ impl PlatformUtilities { .and_then(EdtVersion::parse_lenient) }); Self { - locator: Locator::new( - config.tools.platform.path.clone(), - config + locator: Locator::new(LocatorOptions { + platform_hint: config.tools.platform.path.clone(), + platform_version: config .tools .platform .version .as_deref() .and_then(PlatformVersionRequirement::parse), + platform_policy: if config.tools.platform.strict { + PlatformResolutionPolicy::Strict + } else { + PlatformResolutionPolicy::Lenient + }, edt_hint, edt_version, - ), + }), standard_runner: ProcessExecutor, } } @@ -88,7 +94,10 @@ mod tests { AppConfig, BuildConfig, BuilderBackend, InfobaseConfig, McpConfig, PlatformToolConfig, SourceFormat, TestsConfig, ToolsConfig, }; - use crate::platform::locator::{EdtVersion, Locator, LocatorError, UtilityType}; + use crate::platform::locator::{ + EdtVersion, Locator, LocatorError, PlatformVersion, PlatformVersionRequirement, UtilityType, + }; + use crate::support::path::normalize_windows_verbatim_path; use std::fs; use std::path::{Path, PathBuf}; use tempfile::tempdir; @@ -102,16 +111,15 @@ mod tests { fs::set_permissions(path, perms).expect("chmod"); } - #[cfg(unix)] fn touch_executable(path: &Path) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create dirs"); } fs::write(path, "#!/bin/sh\nexit 0\n").expect("write"); + #[cfg(unix)] make_executable(path); } - #[cfg(unix)] fn sample_config(platform_path: Option, platform_version: Option<&str>) -> AppConfig { AppConfig { base_path: PathBuf::from("/tmp/project"), @@ -125,6 +133,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: platform_path, + strict: false, version: platform_version.map(str::to_owned), }, ..ToolsConfig::default() @@ -146,7 +155,10 @@ mod tests { let location = utilities.locate(UtilityType::EdtCli).expect("locate edt"); - assert_eq!(location.path, binary); + assert_eq!( + location.path, + binary.canonicalize().expect("canonical binary") + ); } #[cfg(unix)] @@ -194,7 +206,10 @@ mod tests { let location = utilities.locate(UtilityType::EdtCli).expect("locate edt"); - assert_eq!(location.path, wanted); + assert_eq!( + location.path, + wanted.canonicalize().expect("canonical binary") + ); } #[cfg(unix)] @@ -221,7 +236,59 @@ mod tests { let location = utilities.locate(utility).expect("locate platform utility"); - assert_eq!(location.path, wanted); + assert_eq!( + location.path, + wanted.canonicalize().expect("canonical binary") + ); } } + + #[cfg(unix)] + #[test] + fn from_config_ignores_platform_version_for_lenient_path_hint() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let binary = root + .join("8.4.1.1") + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&binary); + let config = sample_config(Some(root), Some("8.3")); + let mut utilities = PlatformUtilities::from_config(&config); + + let location = utilities + .locate(UtilityType::V8) + .expect("lenient path ignores configured version"); + + assert_eq!( + location.path, + binary.canonicalize().expect("canonical binary") + ); + } + + #[test] + fn from_config_maps_strict_platform_flag_to_fail_closed_policy() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("platform"); + let binary = root + .join("8.3.25.9999") + .join("bin") + .join(UtilityType::V8.executable_name()); + touch_executable(&binary); + let mut config = sample_config(Some(root), Some("8.3.25.1234")); + config.tools.platform.strict = true; + let mut utilities = PlatformUtilities::from_config(&config); + + assert_eq!( + utilities.locate(UtilityType::V8).expect_err("mismatch"), + LocatorError::VersionMismatch { + utility: UtilityType::V8, + path: normalize_windows_verbatim_path( + &binary.canonicalize().expect("canonical binary") + ), + required: PlatformVersionRequirement::parse("8.3.25.1234").expect("requirement"), + found: PlatformVersion::parse_strict("8.3.25.9999").expect("version"), + } + ); + } } diff --git a/src/use_cases/artifacts.rs b/src/use_cases/artifacts.rs index db4cfff..e4d7649 100644 --- a/src/use_cases/artifacts.rs +++ b/src/use_cases/artifacts.rs @@ -1348,6 +1348,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: Some(platform_path.to_path_buf()), + strict: false, version: None, }, ..ToolsConfig::default() diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index ead66b2..7c07513 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -918,6 +918,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: Some(platform_path.to_path_buf()), + strict: false, version: None, }, ..ToolsConfig::default() @@ -958,6 +959,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: Some(platform_path.to_path_buf()), + strict: false, version: None, }, enterprise: Default::default(), diff --git a/src/use_cases/check_syntax.rs b/src/use_cases/check_syntax.rs index 13ac47b..7ab1314 100644 --- a/src/use_cases/check_syntax.rs +++ b/src/use_cases/check_syntax.rs @@ -1122,6 +1122,7 @@ mod tests { tools: ToolsConfig { platform: crate::config::model::PlatformToolConfig { path: Some(platform_path.to_path_buf()), + strict: false, version: None, }, enterprise: Default::default(), diff --git a/src/use_cases/configure_extensions.rs b/src/use_cases/configure_extensions.rs index 5747e80..5e09dfa 100644 --- a/src/use_cases/configure_extensions.rs +++ b/src/use_cases/configure_extensions.rs @@ -308,6 +308,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: Some(ibcmd_path.to_path_buf()), + strict: false, version: None, }, ..ToolsConfig::default() diff --git a/src/use_cases/dump_config.rs b/src/use_cases/dump_config.rs index dfec016..b80a721 100644 --- a/src/use_cases/dump_config.rs +++ b/src/use_cases/dump_config.rs @@ -1279,6 +1279,7 @@ exit 0"#, tools: ToolsConfig { platform: PlatformToolConfig { path: Some(platform_path.to_path_buf()), + strict: false, version: None, }, ..ToolsConfig::default() diff --git a/src/use_cases/external_artifacts.rs b/src/use_cases/external_artifacts.rs index 7b7666c..d1929d2 100644 --- a/src/use_cases/external_artifacts.rs +++ b/src/use_cases/external_artifacts.rs @@ -339,6 +339,7 @@ mod tests { tools: ToolsConfig { platform: crate::config::model::PlatformToolConfig { path: Some(platform.to_path_buf()), + strict: false, version: None, }, edt_cli: crate::config::model::EdtCliConfig { diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 176cbc2..5777949 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -2,12 +2,14 @@ use std::path::Path; use std::time::Duration; use crate::config::model::AppConfig; -use crate::domain::launch::{ExternalEpfWaitResult, LaunchMode, LaunchResult}; +use crate::domain::launch::{ + ExternalEpfWaitResult, LaunchMode, LaunchResult, PlatformResolution, PlatformResolutionSource, +}; use crate::domain::runner::{launch_key_alias_matches, LaunchOptions}; use crate::platform::enterprise::{ build_launch_args, normalize_launch_payload_path, LaunchClientMode, }; -use crate::platform::locator::UtilityType; +use crate::platform::locator::{ResolutionSource, UtilityLocation, UtilityType, UtilityVersion}; use crate::platform::process::{ManagedSpawnMode, ProcessRequest}; use crate::platform::utilities::PlatformUtilities; use crate::support::error::AppError; @@ -77,6 +79,7 @@ pub fn execute( let location = utilities .locate(utility) .map_err(|error| UseCaseFailure::without_payload(AppError::from(error)))?; + let platform_resolution = platform_resolution(&location); let process_request = ProcessRequest { program: location.path.clone(), args: build_launch_args( @@ -127,6 +130,7 @@ pub fn execute( mode, pid: Some(pid), binary: location.path, + platform_resolution, message: Some(message.clone()), mcp_readiness: None, external_epf_wait: Some(ExternalEpfWaitResult { @@ -158,6 +162,7 @@ pub fn execute( mode, pid: Some(pid), binary: binary.clone(), + platform_resolution: platform_resolution.clone(), message: Some(launch_message(config, args, &binary, pid)), mcp_readiness: None, external_epf_wait: None, @@ -205,12 +210,38 @@ pub fn execute( mode, pid: Some(spawned.pid), binary: spawned.binary.clone(), + platform_resolution, message: Some(launch_message(config, args, &spawned.binary, spawned.pid)), mcp_readiness: None, external_epf_wait: None, }) } +fn platform_resolution(location: &UtilityLocation) -> PlatformResolution { + PlatformResolution { + path: location.path.clone(), + version: location.version.as_ref().map(utility_version_string), + source: match location.source { + ResolutionSource::Explicit => PlatformResolutionSource::Explicit, + ResolutionSource::DefaultRoot => PlatformResolutionSource::DefaultRoot, + ResolutionSource::Path => PlatformResolutionSource::Path, + }, + installation_root: location.installation_root.clone(), + } +} + +fn utility_version_string(version: &UtilityVersion) -> String { + match version { + UtilityVersion::Platform(version) => version.to_string(), + UtilityVersion::Edt(version) => version + .parts + .iter() + .map(u32::to_string) + .collect::>() + .join("."), + } +} + struct ExternalEpfWaitPlan { timeout_ms: u64, execute_path: String, @@ -429,12 +460,13 @@ fn build_client_mcp_payload( #[cfg(test)] mod tests { - use super::execute; + use super::{execute, platform_resolution}; use crate::config::model::{ AppConfig, BuildConfig, BuilderBackend, EnterpriseToolConfig, PlatformToolConfig, SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolExtensionArtifactConfig, ToolExtensionConfig, ToolExtensionInput, ToolsConfig, }; + use crate::platform::locator::{ResolutionSource, UtilityLocation, UtilityType}; use crate::use_cases::context::{CommandName, ExecutionContext}; use crate::use_cases::request::{ ClientMcpMode, ClientMcpOptionsRequest, LaunchRequest, LaunchTargetRequest, @@ -477,6 +509,27 @@ mod tests { fs::read_to_string(path).expect("args log") } + #[test] + fn launch_resolution_serializes_all_sources_and_unknown_version_as_null() { + for (source, expected_source) in [ + (ResolutionSource::Explicit, "explicit"), + (ResolutionSource::DefaultRoot, "default-root"), + (ResolutionSource::Path, "path"), + ] { + let resolution = platform_resolution(&UtilityLocation { + utility: UtilityType::V8, + path: PathBuf::from("/opt/1cv8/bin/1cv8"), + version: None, + source, + installation_root: PathBuf::from("/opt/1cv8"), + }); + let json = serde_json::to_value(resolution).expect("resolution JSON"); + + assert_eq!(json["source"], expected_source); + assert!(json["version"].is_null()); + } + } + fn sample_config(base_path: &Path, work_path: &Path, platform_path: &Path) -> AppConfig { AppConfig { base_path: base_path.to_path_buf(), @@ -494,6 +547,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: Some(platform_path.to_path_buf()), + strict: false, version: None, }, enterprise: EnterpriseToolConfig::default(), diff --git a/src/use_cases/load_artifact.rs b/src/use_cases/load_artifact.rs index 54acc42..79319cc 100644 --- a/src/use_cases/load_artifact.rs +++ b/src/use_cases/load_artifact.rs @@ -969,6 +969,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: Some(binary.to_path_buf()), + strict: false, version: None, }, enterprise: Default::default(), diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 7ecd18a..a5d9af8 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -254,6 +254,13 @@ fn insert_client_mcp_config(path: &Path, body: &str) { fs::write(path, updated).expect("config"); } +fn canonical_path_string(path: &Path) -> String { + fs::canonicalize(path) + .expect("canonical path") + .to_string_lossy() + .into_owned() +} + fn write_config( path: &Path, _base_path: &Path, @@ -422,7 +429,7 @@ fn launch_json_returns_pid_and_selected_binary() { assert_eq!(data["mode"], "thin"); assert_eq!( data["binary"].as_str().expect("binary"), - install_dir.join("bin").join("1cv8c").to_string_lossy() + canonical_path_string(&install_dir.join("bin").join("1cv8c")) ); assert!(data["pid"].as_u64().expect("pid") > 0); } @@ -482,7 +489,7 @@ fn launch_designer_accepts_positional_mode() { assert_eq!(payload["data"]["mode"], "designer"); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), - install_dir.join("bin").join("1cv8").to_string_lossy() + canonical_path_string(&install_dir.join("bin").join("1cv8")) ); } @@ -504,13 +511,14 @@ fn launch_thick_uses_v8_binary() { let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), - install_dir.join("bin").join("1cv8").to_string_lossy() + canonical_path_string(&install_dir.join("bin").join("1cv8")) ); } #[test] -fn launch_uses_versioned_root_hint() { +fn launch_json_exposes_platform_resolution_metadata() { let (_dir, config_path, version_dir, _work_path) = setup_versioned_project(); + let canonical_version_dir = fs::canonicalize(&version_dir).expect("canonical version dir"); let output = v8_runner_command() .args([ "--config", @@ -526,7 +534,30 @@ fn launch_uses_versioned_root_hint() { let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), - version_dir.join("bin").join("1cv8c").to_string_lossy() + canonical_version_dir + .join("bin") + .join("1cv8c") + .to_string_lossy() + ); + assert_eq!( + payload["data"]["platform_resolution"]["path"] + .as_str() + .expect("resolution path"), + canonical_version_dir + .join("bin") + .join("1cv8c") + .to_string_lossy() + ); + assert_eq!( + payload["data"]["platform_resolution"]["version"], + "8.3.25.1234" + ); + assert_eq!(payload["data"]["platform_resolution"]["source"], "explicit"); + assert_eq!( + payload["data"]["platform_resolution"]["installation_root"] + .as_str() + .expect("installation root"), + canonical_version_dir.to_string_lossy() ); } @@ -674,7 +705,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert_eq!(payload["data"]["mode"], "mcp"); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), - install_dir.join("bin").join("1cv8").to_string_lossy() + canonical_path_string(&install_dir.join("bin").join("1cv8")) ); let args = read_args_log(&args_log); diff --git a/tests/mcp_http.rs b/tests/mcp_http.rs index b670bbd..da6701a 100644 --- a/tests/mcp_http.rs +++ b/tests/mcp_http.rs @@ -31,6 +31,28 @@ fn assert_envelope_business_failure(payload: &Value, command: &str) { assert!(payload["error"]["message"].is_string()); } +fn assert_launch_platform_resolution(data: &Value) { + let binary = data["binary"].as_str().expect("launch binary"); + let resolution = &data["platform_resolution"]; + let path = resolution["path"].as_str().expect("resolution path"); + let installation_root = resolution["installation_root"] + .as_str() + .expect("resolution installation root"); + + assert_eq!(path, binary); + assert!(Path::new(path).is_absolute()); + assert!(resolution["version"].is_null()); + assert_eq!(resolution["source"], "explicit"); + assert_eq!( + Path::new(path) + .parent() + .and_then(Path::parent) + .expect("installation root from binary path") + .to_string_lossy(), + installation_root + ); +} + fn reserve_local_address() -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral listener"); let address = listener.local_addr().expect("local addr"); @@ -678,6 +700,7 @@ async fn mcp_http_launch_app_returns_success_payload_over_live_session() { let structured = &payload["result"]["structuredContent"]; assert_envelope_success(structured, "launch"); assert_eq!(structured["data"]["ok"], true); + assert_launch_platform_resolution(&structured["data"]); server.shutdown().await; } diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs index 372ffa7..bf680ea 100644 --- a/tests/mcp_stdio.rs +++ b/tests/mcp_stdio.rs @@ -37,6 +37,28 @@ fn assert_envelope_business_failure(payload: &Value, command: &str) { assert!(payload["error"]["message"].is_string()); } +fn assert_launch_platform_resolution(data: &Value) { + let binary = data["binary"].as_str().expect("launch binary"); + let resolution = &data["platform_resolution"]; + let path = resolution["path"].as_str().expect("resolution path"); + let installation_root = resolution["installation_root"] + .as_str() + .expect("resolution installation root"); + + assert_eq!(path, binary); + assert!(Path::new(path).is_absolute()); + assert!(resolution["version"].is_null()); + assert_eq!(resolution["source"], "explicit"); + assert_eq!( + Path::new(path) + .parent() + .and_then(Path::parent) + .expect("installation root from binary path") + .to_string_lossy(), + installation_root + ); +} + fn run_cli_json(config_path: &Path, args: &[&str]) -> Value { let (success, payload) = run_cli_json_with_status(config_path, args); assert!(success, "CLI command should succeed: {args:?}"); @@ -1139,6 +1161,8 @@ async fn mcp_stdio_launch_app_returns_success_for_thin_client() { let payload: Value = response.structured_content.expect("structured payload"); assert_envelope_success(&payload, "launch"); assert_eq!(payload["data"]["ok"], true); + assert_launch_platform_resolution(&payload["data"]); + wait_for_invocation_count(&enterprise_calls_log, 1).await; assert!(!fs::read_to_string(enterprise_calls_log) .expect("enterprise calls") .contains("RunUnitTests="));