From 496a53188641ffc74b5a0d02f306363420c886cc Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 00:19:15 +0300 Subject: [PATCH 1/9] docs(platform): design strict resolution - define fail-closed platform pinning semantics - plan typed locator, JSON metadata, and verification --- .../2026-07-22-strict-platform-resolution.md | 31 +++++++++++++++++++ ...07-22-strict-platform-resolution-design.md | 27 ++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-strict-platform-resolution.md create mode 100644 docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md 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..1e8857d --- /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; strict resolution searches only the explicit boundary 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 and relative platform paths. +- [ ] Add `PlatformToolConfig.strict`, schema fields, strict-without-path validation, and path normalization. +- [ ] Regenerate both checked-in schemas and run focused config tests. + +### Task 2: Typed strict locator + +- [ ] Add failing locator tests for no fallback, exact/prefix mismatch, unknown version, and sibling consistency. +- [ ] Add typed policy/source/errors and source-aware candidates. +- [ ] Bind strict 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..a6bb39a --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md @@ -0,0 +1,27 @@ +# Strict Platform Resolution Design + +## Goal + +Make an explicitly pinned 1C platform installation fail closed when requested, while preserving the legacy discovery fallback by default. + +## Configuration contract + +`tools.platform.strict` is a boolean with default `false`. `strict: true` requires `tools.platform.path`. The path is normalized relative to the primary config directory. + +When strict mode is disabled, explicit path, default installation roots, and PATH keep the current fallback order. When strict mode is enabled, only the explicit path boundary is searched. A missing requested utility, an unknown version when `tools.platform.version` is configured, or a version mismatch is a typed locator error and never falls back. + +Version requirements retain the existing semantics: four components are exact; two or three components are prefixes and select the highest matching installation below an explicit version root. + +## Installation consistency + +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 in default roots or PATH is rejected. + +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 strict missing paths, exact and prefix mismatch, unknown pinned version, versioned-root selection, sibling consistency, legacy fallback, relative path normalization, schema validation, and launch JSON metadata. Default behavior and existing locator tests remain unchanged. From 266d2ab42981d87dfacae177883361e9d8221678 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 00:27:42 +0300 Subject: [PATCH 2/9] feat(config): add strict platform configuration - add strict platform path validation and normalization\n- regenerate platform configuration schemas --- docs/schemas/v8project.local.schema.json | 5 ++ docs/schemas/v8project.schema.json | 37 +++++++++++ src/config/loader.rs | 76 +++++++++++++++++++++++ src/config/model.rs | 19 ++++++ src/config/schema.rs | 78 ++++++++++++++++++++++++ src/config/validate.rs | 10 +++ src/platform/utilities.rs | 1 + src/use_cases/artifacts.rs | 1 + src/use_cases/build_project.rs | 2 + src/use_cases/check_syntax.rs | 1 + src/use_cases/configure_extensions.rs | 1 + src/use_cases/dump_config.rs | 1 + src/use_cases/external_artifacts.rs | 1 + src/use_cases/launch_app.rs | 1 + src/use_cases/load_artifact.rs | 1 + 15 files changed, 235 insertions(+) diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 5b1023d..49627f5 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -411,6 +411,11 @@ "null" ] }, + "strict": { + "default": false, + "description": "Require platform utility resolution to stay within the configured path.", + "type": "boolean" + }, "version": { "description": "Platform version requirement used for discovery.", "type": [ diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index fc3d72b..53c9327 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -285,6 +285,38 @@ }, "PlatformToolSchema": { "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "strict": { + "const": true + } + }, + "required": [ + "strict" + ] + }, + "then": { + "allOf": [ + { + "required": [ + "path" + ] + }, + { + "properties": { + "path": { + "not": { + "type": "null" + } + } + } + } + ] + } + } + ], "properties": { "path": { "description": "Platform binary, installation `bin` directory, or platform root discovery hint.", @@ -293,6 +325,11 @@ "null" ] }, + "strict": { + "default": false, + "description": "Require platform utility resolution to stay within the configured path.", + "type": "boolean" + }, "version": { "description": "Platform version requirement used for discovery.", "type": [ diff --git a/src/config/loader.rs b/src/config/loader.rs index 4252661..9d7ca64 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,77 @@ 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_rejects_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 error = load_config(config_path.to_str(), None).expect_err("strict path validation"); + + assert!(matches!( + error, + ConfigLoadError::ValidationError(ConfigValidationError::StrictPlatformRequiresPath) + )); + } + + #[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 c98f3d8..2623791 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -553,6 +553,10 @@ pub struct PlatformToolConfig { /// directory, or to a platform root that contains versioned subdirectories. pub path: Option, + /// Require platform utility resolution to stay within the configured path. + #[serde(default)] + pub strict: bool, + /// Platform version requirement in `major.minor`, `major.minor.patch`, or /// `major.minor.patch.build` format. /// @@ -651,3 +655,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..19e80c7 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -26,6 +26,7 @@ pub fn main_config_schema_json() -> Value { let mut schema = serde_json::to_value(schema_for!(MainConfigSchema)).expect("schema json"); set_schema_id(&mut schema, &main_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); + add_platform_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); schema } @@ -112,6 +113,29 @@ fn add_tool_extension_schema_constraints(schema: &mut Value) { ); } +fn add_platform_schema_constraints(schema: &mut Value) { + let Some(object) = schema_object_mut(schema, &["PlatformToolSchema"]) else { + return; + }; + let all_of = object + .entry("allOf") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("schema allOf array"); + all_of.push(json!({ + "if": { + "properties": { "strict": { "const": true } }, + "required": ["strict"] + }, + "then": { + "allOf": [ + { "required": ["path"] }, + { "properties": { "path": { "not": { "type": "null" } } } } + ] + } + })); +} + fn reject_multiple_non_null_properties(schema: &mut Value, def_path: &[&str], names: &[&str]) { let Some(object) = schema_object_mut(schema, def_path) else { return; @@ -629,6 +653,9 @@ struct PlatformToolSchema { /// Platform binary, installation `bin` directory, or platform root discovery hint. #[serde(default, skip_serializing_if = "Option::is_none")] path: Option, + /// Require platform utility resolution to stay within the configured path. + #[serde(default)] + strict: bool, /// Platform version requirement used for discovery. #[serde(default, skip_serializing_if = "Option::is_none")] version: Option, @@ -1251,6 +1278,57 @@ mod tests { assert_config_loader_ok(&config); } + #[test] + fn platform_strict_schema_requires_path_and_accepts_false_without_path() { + 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_invalid(&main_config_schema_json(), &strict_without_path); + assert_schema_invalid(&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_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..8152f57 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -103,6 +103,9 @@ pub enum ConfigValidationError { #[error("platform version must use format major.minor, major.minor.patch or major.minor.patch.build: {0}")] InvalidPlatformVersion(String), + #[error("tools.platform.path is required when tools.platform.strict is true")] + StrictPlatformRequiresPath, + #[error("build.partialLoadThreshold must be greater than or equal to 1")] InvalidPartialLoadThreshold, @@ -670,6 +673,10 @@ fn validate_matrix(_config: &AppConfig) -> Result<(), ConfigValidationError> { } fn validate_platform_version(config: &AppConfig) -> Result<(), ConfigValidationError> { + if config.tools.platform.strict && config.tools.platform.path.is_none() { + return Err(ConfigValidationError::StrictPlatformRequiresPath); + } + if let Some(version) = config.tools.platform.version.as_deref() { if PlatformVersionRequirement::parse(version).is_none() { return Err(ConfigValidationError::InvalidPlatformVersion( @@ -1133,6 +1140,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: None, + strict: false, version: Some("8.3.25".to_owned()), }, ..ToolsConfig::default() @@ -1170,6 +1178,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: None, + strict: false, version: Some("8.3".to_owned()), }, ..ToolsConfig::default() @@ -1207,6 +1216,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: None, + strict: false, version: Some("8".to_owned()), }, ..ToolsConfig::default() diff --git a/src/platform/utilities.rs b/src/platform/utilities.rs index 47d50e4..7a34f42 100644 --- a/src/platform/utilities.rs +++ b/src/platform/utilities.rs @@ -125,6 +125,7 @@ mod tests { tools: ToolsConfig { platform: PlatformToolConfig { path: platform_path, + strict: false, version: platform_version.map(str::to_owned), }, ..ToolsConfig::default() 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 2df039f..dceed39 100644 --- a/src/use_cases/dump_config.rs +++ b/src/use_cases/dump_config.rs @@ -1276,6 +1276,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 3fa370b..3685b13 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -377,6 +377,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(), From e64709cd710167b8fbd5e7d05bec239fd82045c2 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 01:03:48 +0300 Subject: [PATCH 3/9] feat(platform): enforce strict utility resolution - add typed source-aware fail-closed locator policy - pin canonical platform installations and preserve fallback behavior - cover version, boundary, PATH, alias, and sibling resolution --- src/platform/locator.rs | 1146 ++++++++++++++++++++++++++++++------- src/platform/utilities.rs | 65 ++- 2 files changed, 1004 insertions(+), 207 deletions(-) diff --git a/src/platform/locator.rs b/src/platform/locator.rs index 907ee04..6b8c5d3 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 { + /// Prefer the configured hint, then retain legacy default-root and `PATH` fallback. + #[default] + Fallback, + /// Resolve platform utilities only inside the configured hint 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, + /// Platform fallback behavior. + 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,99 @@ 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, } /// 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, } } @@ -246,20 +336,11 @@ impl Locator { 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 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); - } - - let candidates = self.search_candidates(utility); - let selected = self.select_candidate(utility, candidates)?; + let selected = if utility.is_platform() { + self.locate_platform(utility)? + } else { + self.locate_edt(utility)? + }; self.cache.insert(cache_key, selected.clone()); Ok(selected) } @@ -272,15 +353,41 @@ impl Locator { edt_version: Option, platform_roots: Vec, edt_roots: Vec, + ) -> Self { + Self::with_search_roots( + platform_hint, + platform_version, + PlatformResolutionPolicy::Fallback, + 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, } } @@ -299,134 +406,142 @@ impl Locator { } } - fn resolve_explicit_hint(&self, utility: UtilityType) -> Option { - let hint = if utility.is_platform() { - self.platform_hint.as_deref() - } else { - self.edt_hint.as_deref() - }?; - - let candidate = resolve_from_hint(hint, utility)?; - if !is_valid_executable(&candidate) { - return None; - } - - Some(UtilityLocation { - utility, - version: infer_version(utility, &candidate), - path: candidate, - }) - } - - fn search_candidates(&self, utility: UtilityType) -> Vec { - let mut candidates = Vec::new(); - - if utility.is_platform() { - if let Some(required) = &self.platform_version { - candidates.extend(platform_candidates_for_version( + 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, - required, - &self.platform_roots, - )); - } else { - candidates.extend(platform_candidates_any_version( + pinned_platform_candidates(utility, pinned), + self.platform_version.as_ref(), + &pinned.root, + ) + .ok_or_else(|| LocatorError::MissingSibling { utility, - &self.platform_roots, - )); + installation_root: pinned.root.clone(), + }); } - } else { - if let Some(required) = &self.edt_version { - candidates.extend(edt_candidates_for_version( - utility, - required, - &self.edt_roots, - )); - } else { - candidates.extend(edt_candidates_any_version(utility, &self.edt_roots)); + } + + let direct_explicit_candidates = self + .platform_hint + .as_deref() + .map(|hint| explicit_direct_candidates(hint, utility)) + .unwrap_or_default(); + let strict_boundary = match self.platform_policy { + PlatformResolutionPolicy::Fallback => None, + PlatformResolutionPolicy::Strict => { + self.platform_hint.as_deref().map(strict_candidate_boundary) } + }; + if let Some(location) = select_candidate( + utility, + direct_explicit_candidates.clone(), + match self.platform_policy { + PlatformResolutionPolicy::Fallback => None, + PlatformResolutionPolicy::Strict => self.platform_version.as_ref(), + }, + strict_boundary.as_deref(), + ) { + self.pin_platform(&location); + return Ok(location); } - candidates.extend(path_candidates(utility)); - candidates - } + let versioned_explicit_candidates = self + .platform_hint + .as_deref() + .filter(|hint| hint.is_dir()) + .map(|hint| { + platform_candidates_any_version( + utility, + std::slice::from_ref(&hint.to_path_buf()), + ResolutionSource::Explicit, + ) + }) + .unwrap_or_default(); + if let Some(location) = select_candidate( + utility, + versioned_explicit_candidates.clone(), + self.platform_version.as_ref(), + strict_boundary.as_deref(), + ) { + self.pin_platform(&location); + return 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 self.platform_policy == PlatformResolutionPolicy::Strict { + let mut explicit_candidates = direct_explicit_candidates; + explicit_candidates.extend(versioned_explicit_candidates); + return Err(strict_resolution_error( + utility, + self.platform_hint.as_deref(), + explicit_candidates, + self.platform_version.as_ref(), + strict_boundary.as_deref(), + )); + } - 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(); - } + 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.platform_version.as_ref(), None) + .ok_or(LocatorError::NotFound(utility))?; + self.pin_platform(&location); + Ok(location) + } - 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 { + 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)); + fn locate_edt(&self, utility: UtilityType) -> Result { + let direct_explicit = self + .edt_hint + .as_deref() + .map(|hint| explicit_direct_candidates(hint, utility)) + .unwrap_or_default(); + if let Some(location) = select_edt_candidate(direct_explicit, utility) { + return Ok(location); + } - if candidates.is_empty() { - return Err(LocatorError::NotFound(utility)); + let versioned_explicit = match (self.edt_hint.as_ref(), self.edt_version.as_ref()) { + (Some(hint), Some(required)) if hint.is_dir() => edt_candidates_for_version( + utility, + required, + std::slice::from_ref(hint), + ResolutionSource::Explicit, + ), + (Some(hint), None) if hint.is_dir() => edt_candidates_any_version( + utility, + std::slice::from_ref(hint), + ResolutionSource::Explicit, + ), + (Some(_) | None, Some(_) | None) => Vec::new(), + }; + if let Some(location) = select_edt_candidate(versioned_explicit, utility) { + 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))? - } + let mut candidates = if let Some(required) = self.edt_version.as_ref() { + edt_candidates_for_version( + utility, + required, + &self.edt_roots, + ResolutionSource::DefaultRoot, + ) } else { - candidates - .into_iter() - .max_by(|left, right| { - compare_versions(left.version.as_ref(), right.version.as_ref()) - }) - .ok_or(LocatorError::NotFound(utility))? + edt_candidates_any_version(utility, &self.edt_roots, ResolutionSource::DefaultRoot) }; - - Ok(UtilityLocation { - utility, - path: chosen.path, - version: chosen.version, - }) + candidates.extend(path_candidates(utility, &self.path_roots)); + select_edt_candidate(candidates, utility).ok_or(LocatorError::NotFound(utility)) } } @@ -437,14 +552,23 @@ 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) -> Vec { if hint.is_file() { + let canonical_hint = canonical_boundary(hint); let target_name = utility.executable_name(); let file_name_matches = hint .file_name() @@ -452,41 +576,55 @@ fn resolve_from_hint(hint: &Path, utility: UtilityType) -> Option { .map(|name| 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)) + match canonical_hint.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 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]) -> Vec { +fn platform_candidates_any_version( + utility: UtilityType, + roots: &[PathBuf], + source: ResolutionSource, +) -> Vec { let mut candidates = Vec::new(); for root in roots { @@ -510,10 +648,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 +661,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 +688,12 @@ fn edt_candidates_any_version(utility: UtilityType, roots: &[PathBuf]) -> Vec Vec { - edt_candidates_any_version(utility, roots) + edt_candidates_any_version(utility, roots, source) .into_iter() .filter(|candidate| { matches!( @@ -582,20 +729,220 @@ 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 captured_path_roots() -> Vec { + let roots = std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).collect()) + .unwrap_or_default(); + let current_dir = std::env::current_dir().unwrap_or_default(); + normalize_path_roots(roots, ¤t_dir) +} + +fn normalize_path_roots(roots: Vec, current_dir: &Path) -> Vec { + roots + .into_iter() + .map(|root| { + if root.as_os_str().is_empty() { + current_dir.to_path_buf() + } else { + root + } + }) + .collect() +} + +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, +) -> Option { + choose_candidate( + utility, + canonical_candidates(utility, candidates, None), + 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; + } + if utility.is_platform() { + 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(|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() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "bin") + { + installation_root_for_executable(&canonical) + } else { + canonical + } +} + +fn installation_root_for_executable(path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or(path); + match parent.file_name().and_then(|name| name.to_str()) { + Some("bin" | "1cedt") => parent.parent().unwrap_or(parent).to_path_buf(), + Some(_) | None => parent.to_path_buf(), + } } fn infer_version(utility: UtilityType, path: &Path) -> Option { @@ -704,8 +1051,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 +1068,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,6 +1098,379 @@ mod tests { path } + 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, + ) + } + + #[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_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") + ) + ); + } + + #[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); + } + + #[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 fallback_resolution_preserves_unknown_direct_hint_precedence() { + 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 = Locator::with_search_roots( + Some(explicit_root), + PlatformVersionRequirement::parse("8.3.25.1234"), + PlatformResolutionPolicy::Fallback, + None, + None, + vec![default_root], + vec![], + 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 fallback_resolution_keeps_platform_utility_searches_independent() { + 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, + ); + let v8c = touch_versioned_platform_executable( + &default_root, + "8.3.25.1234", + UtilityType::V8C, + true, + ); + let mut locator = Locator::with_search_roots( + Some(explicit_root), + PlatformVersionRequirement::parse("8.3.25.1234"), + PlatformResolutionPolicy::Fallback, + None, + None, + vec![default_root], + vec![], + vec![], + ); + + let first = locator.locate(UtilityType::V8).expect("explicit v8"); + let second = locator.locate(UtilityType::V8C).expect("fallback v8c"); + + assert_eq!(first.path, canonical(&v8)); + assert_eq!(first.source, ResolutionSource::Explicit); + assert_eq!(second.path, canonical(&v8c)); + assert_eq!(second.source, ResolutionSource::DefaultRoot); + } + + #[test] + fn fallback_resolution_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 = Locator::with_search_roots( + None, + None, + PlatformResolutionPolicy::Fallback, + None, + None, + vec![], + 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()); @@ -792,7 +1516,28 @@ 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)] @@ -807,7 +1552,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::V8).expect("locate").path, - binary + canonical(&binary) ); } @@ -823,7 +1568,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 +1588,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::Ibcmd).expect("locate").path, - ibcmd + canonical(&ibcmd) ); } @@ -864,7 +1612,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 +1641,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 +1674,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 +1710,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 +1725,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 +1736,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 +1776,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::EdtCli).expect("locate").path, - newer + canonical(&newer) ); } @@ -1038,14 +1792,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,7 +1837,7 @@ mod tests { assert_eq!( locator.locate(UtilityType::EdtCli).expect("locate").path, - wanted + canonical(&wanted) ); } @@ -1114,7 +1868,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 7a34f42..d94269b 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::Fallback + }, 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"), @@ -147,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)] @@ -195,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)] @@ -222,7 +236,36 @@ 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") + ); } } + + #[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"), + } + ); + } } From 2579cfc506c79a1bb5b16f3bf6486b103d9a3ff1 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 01:33:26 +0300 Subject: [PATCH 4/9] fix(platform): harden canonical locator resolution - revalidate cached executable identity and strict boundaries - select versions from canonical roots across strict and EDT discovery - preserve legacy hint behavior and capture absolute PATH roots --- src/platform/locator.rs | 530 ++++++++++++++++++++++++++++++++-------- 1 file changed, 423 insertions(+), 107 deletions(-) diff --git a/src/platform/locator.rs b/src/platform/locator.rs index 6b8c5d3..23c4379 100644 --- a/src/platform/locator.rs +++ b/src/platform/locator.rs @@ -294,6 +294,12 @@ struct PinnedPlatformInstallation { 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, @@ -330,8 +336,8 @@ 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); } @@ -345,6 +351,59 @@ impl Locator { Ok(selected) } + fn revalidate_cached_location( + &self, + utility: UtilityType, + cached: &UtilityLocation, + ) -> Option { + if cached.utility != utility { + return None; + } + + 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.platform_version.as_ref(), + &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.platform_version.as_ref(), + boundary.as_deref(), + ) + } + ( + UtilityType::V8 | UtilityType::V8C | UtilityType::Ibcmd, + PlatformResolutionPolicy::Fallback, + Some(_) | None, + ) => select_candidate(utility, vec![candidate], None, None), + ( + UtilityType::EdtCli, + PlatformResolutionPolicy::Fallback | PlatformResolutionPolicy::Strict, + Some(_) | None, + ) => select_edt_candidate(vec![candidate], utility, None), + }?; + + (current == *cached).then_some(current) + } + #[cfg(test)] pub(crate) fn with_roots( platform_hint: Option, @@ -425,7 +484,18 @@ impl Locator { let direct_explicit_candidates = self .platform_hint .as_deref() - .map(|hint| explicit_direct_candidates(hint, utility)) + .map(|hint| { + explicit_direct_candidates( + hint, + utility, + match self.platform_policy { + PlatformResolutionPolicy::Fallback => FileHintSiblingResolution::Lexical, + PlatformResolutionPolicy::Strict => { + FileHintSiblingResolution::CanonicalInstallation + } + }, + ) + }) .unwrap_or_default(); let strict_boundary = match self.platform_policy { PlatformResolutionPolicy::Fallback => None, @@ -433,19 +503,6 @@ impl Locator { self.platform_hint.as_deref().map(strict_candidate_boundary) } }; - if let Some(location) = select_candidate( - utility, - direct_explicit_candidates.clone(), - match self.platform_policy { - PlatformResolutionPolicy::Fallback => None, - PlatformResolutionPolicy::Strict => self.platform_version.as_ref(), - }, - strict_boundary.as_deref(), - ) { - self.pin_platform(&location); - return Ok(location); - } - let versioned_explicit_candidates = self .platform_hint .as_deref() @@ -458,26 +515,43 @@ impl Locator { ) }) .unwrap_or_default(); - if let Some(location) = select_candidate( - utility, - versioned_explicit_candidates.clone(), - self.platform_version.as_ref(), - strict_boundary.as_deref(), - ) { - self.pin_platform(&location); - return Ok(location); - } - if self.platform_policy == PlatformResolutionPolicy::Strict { - let mut explicit_candidates = direct_explicit_candidates; - explicit_candidates.extend(versioned_explicit_candidates); - return Err(strict_resolution_error( - utility, - self.platform_hint.as_deref(), - explicit_candidates, - self.platform_version.as_ref(), - strict_boundary.as_deref(), - )); + match self.platform_policy { + PlatformResolutionPolicy::Strict => { + let mut explicit_candidates = direct_explicit_candidates; + explicit_candidates.extend(versioned_explicit_candidates); + if let Some(location) = select_candidate( + utility, + explicit_candidates.clone(), + self.platform_version.as_ref(), + strict_boundary.as_deref(), + ) { + self.pin_platform(&location); + return Ok(location); + } + return Err(strict_resolution_error( + utility, + self.platform_hint.as_deref(), + explicit_candidates, + self.platform_version.as_ref(), + strict_boundary.as_deref(), + )); + } + PlatformResolutionPolicy::Fallback => { + if let Some(location) = + select_candidate(utility, direct_explicit_candidates, None, None) + { + return Ok(location); + } + if let Some(location) = select_candidate( + utility, + versioned_explicit_candidates, + self.platform_version.as_ref(), + None, + ) { + return Ok(location); + } + } } let mut candidates = platform_candidates_any_version( @@ -506,42 +580,33 @@ impl Locator { let direct_explicit = self .edt_hint .as_deref() - .map(|hint| explicit_direct_candidates(hint, utility)) + .map(|hint| { + explicit_direct_candidates(hint, utility, FileHintSiblingResolution::Lexical) + }) .unwrap_or_default(); - if let Some(location) = select_edt_candidate(direct_explicit, utility) { + if let Some(location) = select_edt_candidate(direct_explicit, utility, None) { return Ok(location); } - let versioned_explicit = match (self.edt_hint.as_ref(), self.edt_version.as_ref()) { - (Some(hint), Some(required)) if hint.is_dir() => edt_candidates_for_version( + let versioned_explicit = match self.edt_hint.as_ref() { + Some(hint) if hint.is_dir() => edt_candidates_any_version( utility, - required, std::slice::from_ref(hint), ResolutionSource::Explicit, ), - (Some(hint), None) if hint.is_dir() => edt_candidates_any_version( - utility, - std::slice::from_ref(hint), - ResolutionSource::Explicit, - ), - (Some(_) | None, Some(_) | None) => Vec::new(), + Some(_) | None => Vec::new(), }; - if let Some(location) = select_edt_candidate(versioned_explicit, utility) { + if let Some(location) = + select_edt_candidate(versioned_explicit, utility, self.edt_version.as_ref()) + { return Ok(location); } - let mut candidates = if let Some(required) = self.edt_version.as_ref() { - edt_candidates_for_version( - utility, - required, - &self.edt_roots, - ResolutionSource::DefaultRoot, - ) - } else { - edt_candidates_any_version(utility, &self.edt_roots, ResolutionSource::DefaultRoot) - }; + 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).ok_or(LocatorError::NotFound(utility)) + select_edt_candidate(candidates, utility, self.edt_version.as_ref()) + .ok_or(LocatorError::NotFound(utility)) } } @@ -566,7 +631,11 @@ fn compare_versions( } } -fn explicit_direct_candidates(hint: &Path, utility: UtilityType) -> Vec { +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(); @@ -579,7 +648,11 @@ fn explicit_direct_candidates(hint: &Path, utility: UtilityType) -> Vec hint, + FileHintSiblingResolution::CanonicalInstallation => canonical_hint.as_path(), + }; + match sibling_base.parent() { Some(parent) => parent.join(target_name), None => return Vec::new(), } @@ -701,23 +774,6 @@ fn edt_candidates_any_version( candidates } -fn edt_candidates_for_version( - utility: UtilityType, - required: &EdtVersion, - roots: &[PathBuf], - source: ResolutionSource, -) -> Vec { - edt_candidates_any_version(utility, roots, source) - .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; @@ -746,23 +802,66 @@ fn captured_path_roots() -> Vec { let roots = std::env::var_os("PATH") .map(|paths| std::env::split_paths(&paths).collect()) .unwrap_or_default(); - let current_dir = std::env::current_dir().unwrap_or_default(); - normalize_path_roots(roots, ¤t_dir) + 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() - .map(|root| { - if root.as_os_str().is_empty() { - current_dir.to_path_buf() + .filter_map(|root| { + if root.is_absolute() { + Some(root) } else { - root + absolutize_relative_path_root(root, current_dir) } }) .collect() } +#[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) { + return None; + } + current_dir.join(root.components().skip(1).collect::()) + } else { + current_dir.join(root) + }; + + absolute.is_absolute().then_some(absolute) +} + fn pinned_platform_candidates( utility: UtilityType, pinned: &PinnedPlatformInstallation, @@ -823,10 +922,34 @@ fn choose_candidate( fn select_edt_candidate( candidates: Vec, utility: UtilityType, + required: Option<&EdtVersion>, ) -> Option { choose_candidate( utility, - canonical_candidates(utility, candidates, None), + 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, ) } @@ -852,9 +975,7 @@ fn canonical_candidates( }) { return None; } - if utility.is_platform() { - candidate.version = infer_version(utility, &path); - } + candidate.version = infer_version(utility, &path); Some(CanonicalCandidate { path, version: candidate.version, @@ -946,26 +1067,15 @@ fn installation_root_for_executable(path: &Path) -> PathBuf { } 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) - }) - .collect(); - - if utility.is_platform() { - component_strings - .iter() - .find_map(|value| PlatformVersion::parse_strict(value)) - .map(UtilityVersion::Platform) - } else { - component_strings - .iter() - .find_map(|value| EdtVersion::parse_lenient(value)) - .map(UtilityVersion::Edt) + 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), } } @@ -1194,6 +1304,31 @@ mod tests { ); } + #[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"); @@ -1220,6 +1355,31 @@ mod tests { ); } + #[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"); @@ -1369,6 +1529,38 @@ mod tests { 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"); @@ -1382,6 +1574,44 @@ mod tests { ); } + #[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_component_uses_same_drive_captured_directory() { + let captured = PathBuf::from(r"C:\captured\current-directory"); + + assert_eq!( + normalize_path_roots( + vec![PathBuf::from(r"C:tools"), PathBuf::from(r"D:other")], + &captured, + ), + vec![captured.join("tools")] + ); + } + #[test] fn fallback_resolution_preserves_unknown_direct_hint_precedence() { let dir = tempdir().expect("tempdir"); @@ -1540,6 +1770,32 @@ mod tests { ); } + #[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)] #[test] fn explicit_directory_hint_checks_direct_and_bin_layouts() { @@ -1841,6 +2097,66 @@ mod tests { ); } + #[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() { From 076b74c83619e38bd3f9ad5e08aedfc88acb4e70 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 01:51:34 +0300 Subject: [PATCH 5/9] feat(launch): expose platform resolution metadata - add canonical resolution details to launch JSON and MCP results - cover source/version metadata and document strict resolution behavior --- README.md | 4 ++ SKILL/references/config-and-backends.md | 4 +- docs/CAPABILITIES.md | 3 ++ docs/CONFIGURATION.md | 19 ++++++++ src/domain/launch.rs | 27 +++++++++++ src/mcp/service.rs | 21 ++++++++- src/use_cases/launch_app.rs | 58 ++++++++++++++++++++++-- tests/cli_launch.rs | 60 +++++++++++++++++++++---- tests/mcp_http.rs | 23 ++++++++++ tests/mcp_stdio.rs | 24 ++++++++++ 10 files changed, 230 insertions(+), 13 deletions(-) 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 ee85c14..a2dc4f9 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -11,7 +11,9 @@ 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. `strict` defaults to + `false`; when set to `true`, `path` is required and resolution fails closed inside one canonical + installation root (no default-root or `PATH` fallback, including for unknown pinned versions). - `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 d7ed484..54e4361 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -330,6 +330,9 @@ 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`. +- 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 b97f7c6..2107bbd 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,21 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - на каталог `bin`; - на корень установки с версиями. +Относительный путь нормализуется относительно каталога primary `v8project.yaml`. + +### `tools.platform.strict` + +- Тип: boolean +- Обязателен: нет +- По умолчанию: `false` + +При `strict: true` поле `tools.platform.path` обязательно. Поиск ограничивается указанной +установкой: отсутствующая utility, неизвестная версия при заданном `tools.platform.version` или +несовпадение версии завершают команду ошибкой без fallback к default roots или `PATH`. +Первая найденная platform utility фиксирует один canonical installation root; последующие +`1cv8`, `1cv8c` и `ibcmd` выбираются только из этого root. При `strict: false` сохранён legacy +порядок: explicit path, default roots, затем `PATH`. + ### `tools.platform.version` - Тип: строка @@ -520,6 +536,9 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - `8.3.20`: выбирается максимальная найденная сборка `8.3.20.*`; - `8.3`: выбирается максимальная найденная версия `8.3.*.*`. +В strict mode version requirement не допускает неизвестную версию: такая установка отклоняется +вместо fallback. + ## `tools.enterprise` ### `tools.enterprise.additional-launch-keys` diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 9acb21f..a085d8a 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. @@ -20,6 +22,31 @@ pub struct LaunchResult { pub mcp_readiness: Option, } +/// 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 6487287..e5363e3 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::{ @@ -1820,6 +1822,7 @@ mod tests { mode: result_mode, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })); @@ -1850,6 +1853,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })); @@ -1897,6 +1901,7 @@ mod tests { mode: LaunchMode::Thin, pid: Some(42), binary: PathBuf::from("/opt/1cv8c"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })), @@ -1937,6 +1942,7 @@ mod tests { mode: LaunchMode::Thin, pid: Some(42), binary: PathBuf::from("/opt/1cv8c"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })), @@ -1971,6 +1977,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })), @@ -2010,6 +2017,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })), @@ -2049,6 +2057,7 @@ mod tests { mode: LaunchMode::Designer, pid: None, binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })), @@ -2089,6 +2098,7 @@ mod tests { mode: LaunchMode::Designer, pid: None, binary: PathBuf::from("/opt/1cv8"), + platform_resolution: sample_platform_resolution(), message: None, mcp_readiness: None, })), @@ -2519,6 +2529,15 @@ mod tests { } } + fn sample_platform_resolution() -> PlatformResolution { + PlatformResolution { + path: PathBuf::from("/opt/1cv8"), + 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/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 3685b13..74cf364 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::{LaunchMode, LaunchResult}; +use crate::domain::launch::{ + LaunchMode, LaunchResult, PlatformResolution, PlatformResolutionSource, +}; use crate::domain::runner::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::ProcessRequest; use crate::platform::utilities::PlatformUtilities; use crate::support::error::AppError; @@ -75,6 +77,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( @@ -104,6 +107,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, }; @@ -150,11 +154,37 @@ 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, }) } +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("."), + } +} + fn client_mcp_readiness_url( config: &AppConfig, args: &LaunchArgs, @@ -312,12 +342,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, @@ -360,6 +391,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(), diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 03b6c50..125d60e 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -244,6 +244,24 @@ fn insert_client_mcp_config(path: &Path, body: &str) { fs::write(path, config.replace("tools:\n platform:", &replacement)).expect("config"); } +fn set_client_mcp_wait_ready_timeout(path: &Path, timeout_ms: u64) { + let config = fs::read_to_string(path).expect("config"); + let client_mcp = " client_mcp:\n port: 9874\n"; + let replacement = format!("{client_mcp} wait_ready_timeout_ms: {timeout_ms}\n"); + assert!( + config.contains(client_mcp), + "expected configured client_mcp port" + ); + fs::write(path, config.replace(client_mcp, &replacement)).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, @@ -412,7 +430,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); } @@ -472,7 +490,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")) ); } @@ -494,13 +512,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", @@ -516,7 +535,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() ); } @@ -665,7 +707,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); @@ -797,7 +839,8 @@ fn launch_mcp_va_wait_ready_returns_registered_vanessa_tools() { #[test] fn launch_mcp_va_wait_ready_fails_when_vanessa_tools_are_missing() { let (_dir, config_path, install_dir, args_log) = setup_mcp_va_project(); - prepend_config(&config_path, "execution_timeout: 700\n"); + prepend_config(&config_path, "execution_timeout: 2500\n"); + set_client_mcp_wait_ready_timeout(&config_path, 700); let (port, server) = start_fake_mcp_server(&["infobase_info"]); write_logging_script(&install_dir.join("bin").join("1cv8"), &args_log); @@ -885,7 +928,8 @@ fn launch_mcp_wait_ready_returns_client_mcp_tools_without_vanessa_requirements() #[test] fn launch_mcp_wait_ready_fails_when_endpoint_never_starts() { let (_dir, config_path, _install_dir, _work_path) = setup_project(); - prepend_config(&config_path, "execution_timeout: 700\n"); + prepend_config(&config_path, "execution_timeout: 2500\n"); + insert_client_mcp_config(&config_path, " wait_ready_timeout_ms: 700\n"); let port = free_tcp_port(); let output = v8_runner_command() 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=")); From efb222a1092f9deba6239c28f3c18468ff194068 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 01:57:16 +0300 Subject: [PATCH 6/9] test(mcp): align launch resolution fixtures - parameterize platform metadata by selected binary - preserve launch-result path invariants in MCP tests --- src/mcp/service.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/mcp/service.rs b/src/mcp/service.rs index e5363e3..ca108fb 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -1822,7 +1822,7 @@ mod tests { mode: result_mode, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, })); @@ -1853,7 +1853,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, })); @@ -1901,7 +1901,7 @@ mod tests { mode: LaunchMode::Thin, pid: Some(42), binary: PathBuf::from("/opt/1cv8c"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8c"), message: None, mcp_readiness: None, })), @@ -1942,7 +1942,7 @@ mod tests { mode: LaunchMode::Thin, pid: Some(42), binary: PathBuf::from("/opt/1cv8c"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8c"), message: None, mcp_readiness: None, })), @@ -1977,7 +1977,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, })), @@ -2017,7 +2017,7 @@ mod tests { mode: LaunchMode::Mcp, pid: Some(42), binary: PathBuf::from("/opt/1cv8"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, })), @@ -2057,7 +2057,7 @@ mod tests { mode: LaunchMode::Designer, pid: None, binary: PathBuf::from("/opt/1cv8"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, })), @@ -2098,7 +2098,7 @@ mod tests { mode: LaunchMode::Designer, pid: None, binary: PathBuf::from("/opt/1cv8"), - platform_resolution: sample_platform_resolution(), + platform_resolution: sample_platform_resolution("/opt/1cv8"), message: None, mcp_readiness: None, })), @@ -2529,9 +2529,9 @@ mod tests { } } - fn sample_platform_resolution() -> PlatformResolution { + fn sample_platform_resolution(path: &str) -> PlatformResolution { PlatformResolution { - path: PathBuf::from("/opt/1cv8"), + path: PathBuf::from(path), version: Some("8.3.25.1234".to_owned()), source: PlatformResolutionSource::Explicit, installation_root: PathBuf::from("/opt"), From 294a53ade8802164acddf3840d2da9da6d03b26b Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 02:14:59 +0300 Subject: [PATCH 7/9] fix(platform): address final resolution review - preserve Windows drive-relative PATH roots and case-insensitive utility identity - allow strict primary config to receive its platform path from the local overlay - regenerate the published schema and clarify strict root pinning --- docs/schemas/v8project.schema.json | 32 -------- ...07-22-strict-platform-resolution-design.md | 2 +- src/config/schema.rs | 59 +++++++------- src/platform/locator.rs | 81 +++++++++++++++---- 4 files changed, 98 insertions(+), 76 deletions(-) diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 53c9327..f6284d1 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -285,38 +285,6 @@ }, "PlatformToolSchema": { "additionalProperties": false, - "allOf": [ - { - "if": { - "properties": { - "strict": { - "const": true - } - }, - "required": [ - "strict" - ] - }, - "then": { - "allOf": [ - { - "required": [ - "path" - ] - }, - { - "properties": { - "path": { - "not": { - "type": "null" - } - } - } - } - ] - } - } - ], "properties": { "path": { "description": "Platform binary, installation `bin` directory, or platform root discovery hint.", 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 index a6bb39a..a59cd1a 100644 --- a/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md +++ b/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md @@ -14,7 +14,7 @@ Version requirements retain the existing semantics: four components are exact; t ## Installation consistency -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 in default roots or PATH is rejected. +In strict mode, 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 in default roots or PATH is rejected. Each location carries a typed resolution source (`explicit`, `default-root`, or `path`), an absolute canonical executable path, inferred version, and canonical installation root. diff --git a/src/config/schema.rs b/src/config/schema.rs index 19e80c7..d760e41 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -26,7 +26,6 @@ pub fn main_config_schema_json() -> Value { let mut schema = serde_json::to_value(schema_for!(MainConfigSchema)).expect("schema json"); set_schema_id(&mut schema, &main_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); - add_platform_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); schema } @@ -113,29 +112,6 @@ fn add_tool_extension_schema_constraints(schema: &mut Value) { ); } -fn add_platform_schema_constraints(schema: &mut Value) { - let Some(object) = schema_object_mut(schema, &["PlatformToolSchema"]) else { - return; - }; - let all_of = object - .entry("allOf") - .or_insert_with(|| Value::Array(Vec::new())) - .as_array_mut() - .expect("schema allOf array"); - all_of.push(json!({ - "if": { - "properties": { "strict": { "const": true } }, - "required": ["strict"] - }, - "then": { - "allOf": [ - { "required": ["path"] }, - { "properties": { "path": { "not": { "type": "null" } } } } - ] - } - })); -} - fn reject_multiple_non_null_properties(schema: &mut Value, def_path: &[&str], names: &[&str]) { let Some(object) = schema_object_mut(schema, def_path) else { return; @@ -1279,7 +1255,7 @@ mod tests { } #[test] - fn platform_strict_schema_requires_path_and_accepts_false_without_path() { + 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() @@ -1293,8 +1269,8 @@ mod tests { minimal_project_config_without_base_path() ); - assert_schema_invalid(&main_config_schema_json(), &strict_without_path); - assert_schema_invalid(&main_config_schema_json(), &strict_with_null_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); } @@ -1329,6 +1305,35 @@ mod tests { ); } + #[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/platform/locator.rs b/src/platform/locator.rs index 23c4379..a5b42a5 100644 --- a/src/platform/locator.rs +++ b/src/platform/locator.rs @@ -641,8 +641,7 @@ fn explicit_direct_candidates( 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); let path = if file_name_matches { @@ -851,10 +850,11 @@ fn absolutize_relative_path_root(root: PathBuf, current_dir: &Path) -> Option()) + } else { + std::path::absolute(root).ok()? } - current_dir.join(root.components().skip(1).collect::()) } else { current_dir.join(root) }; @@ -1049,8 +1049,7 @@ fn strict_candidate_boundary(hint: &Path) -> PathBuf { if hint.is_file() || canonical .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == "bin") + .is_some_and(|name| executable_component_matches(name, "bin")) { installation_root_for_executable(&canonical) } else { @@ -1060,9 +1059,27 @@ fn strict_candidate_boundary(hint: &Path) -> PathBuf { fn installation_root_for_executable(path: &Path) -> PathBuf { let parent = path.parent().unwrap_or(path); - match parent.file_name().and_then(|name| name.to_str()) { - Some("bin" | "1cedt") => parent.parent().unwrap_or(parent).to_path_buf(), - Some(_) | None => parent.to_path_buf(), + 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) } } @@ -1600,15 +1617,47 @@ mod tests { #[cfg(windows)] #[test] - fn windows_drive_relative_path_component_uses_same_drive_captured_directory() { + 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"), PathBuf::from(r"D:other")], - &captured, - ), - vec![captured.join("tools")] + 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 ); } From 7286a7d60e76fb50e20edbf6c8fbbb3aa09f41b2 Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Wed, 22 Jul 2026 02:32:29 +0300 Subject: [PATCH 8/9] docs(platform): fix plan heading hierarchy - promote task headings to satisfy Markdown structure checks --- .../plans/2026-07-22-strict-platform-resolution.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md b/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md index 1e8857d..3c2b655 100644 --- a/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md +++ b/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md @@ -8,20 +8,20 @@ **Tech Stack:** Rust, serde, schemars, clap integration tests, existing locator and launch contracts. -### Task 1: Configuration and path normalization +## Task 1: Configuration and path normalization - [ ] Add failing model/schema/loader tests for strict and relative platform paths. - [ ] Add `PlatformToolConfig.strict`, schema fields, strict-without-path validation, and path normalization. - [ ] Regenerate both checked-in schemas and run focused config tests. -### Task 2: Typed strict locator +## Task 2: Typed strict locator - [ ] Add failing locator tests for no fallback, exact/prefix mismatch, unknown version, and sibling consistency. - [ ] Add typed policy/source/errors and source-aware candidates. - [ ] Bind strict resolution to one canonical installation root and capture PATH roots once. - [ ] Run the complete locator and utilities suites. -### Task 3: JSON and documentation +## 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. From abc5c79097829d71c34a42ba0e8c3d8e359e1572 Mon Sep 17 00:00:00 2001 From: alkoleft Date: Mon, 27 Jul 2026 02:42:05 +0300 Subject: [PATCH 9/9] fix(platform): align path version strict contract - make configured platform path explicit-only - ignore platform version for lenient path hints - document and test the path/version/strict matrix --- SKILL/references/config-and-backends.md | 7 +- docs/CAPABILITIES.md | 4 + docs/CONFIGURATION.md | 29 +- docs/schemas/v8project.local.schema.json | 6 +- docs/schemas/v8project.schema.json | 6 +- .../2026-07-22-strict-platform-resolution.md | 10 +- ...07-22-strict-platform-resolution-design.md | 31 +- src/config/loader.rs | 10 +- src/config/model.rs | 7 +- src/config/schema.rs | 6 +- src/config/validate.rs | 7 - src/platform/locator.rs | 282 +++++++++++++----- src/platform/utilities.rs | 25 +- 13 files changed, 307 insertions(+), 123 deletions(-) diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index 8e4bd80..df5e69c 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -11,9 +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`, `version`, and `strict`: platform discovery hints. `strict` defaults to - `false`; when set to `true`, `path` is required and resolution fails closed inside one canonical - installation root (no default-root or `PATH` fallback, including for unknown pinned versions). +- `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 e620dd6..ac7b0c9 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -341,6 +341,10 @@ 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 для остальных команд. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 73a9387..c359c24 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -510,6 +510,8 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - на корень установки с версиями. Относительный путь нормализуется относительно каталога primary `v8project.yaml`. +Если `path` задан, поиск platform utilities ограничивается этим путём и не переходит к default +roots или `PATH`, независимо от `strict`. ### `tools.platform.strict` @@ -517,12 +519,15 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - Обязателен: нет - По умолчанию: `false` -При `strict: true` поле `tools.platform.path` обязательно. Поиск ограничивается указанной -установкой: отсутствующая utility, неизвестная версия при заданном `tools.platform.version` или -несовпадение версии завершают команду ошибкой без fallback к default roots или `PATH`. -Первая найденная platform utility фиксирует один canonical installation root; последующие -`1cv8`, `1cv8c` и `ibcmd` выбираются только из этого root. При `strict: false` сохранён legacy -порядок: explicit path, default roots, затем `PATH`. +`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` @@ -536,8 +541,16 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не - `8.3.20`: выбирается максимальная найденная сборка `8.3.20.*`; - `8.3`: выбирается максимальная найденная версия `8.3.*.*`. -В strict mode version requirement не допускает неизвестную версию: такая установка отклоняется -вместо fallback. +Матрица поведения: + +| Конфигурация | Поведение | +| --- | --- | +| `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` diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 49627f5..6bf0e42 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -405,7 +405,7 @@ "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" @@ -413,11 +413,11 @@ }, "strict": { "default": false, - "description": "Require platform utility resolution to stay within the configured path.", + "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 f6284d1..5dc6d5a 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -287,7 +287,7 @@ "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" @@ -295,11 +295,11 @@ }, "strict": { "default": false, - "description": "Require platform utility resolution to stay within the configured path.", + "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 index 3c2b655..cd43057 100644 --- a/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md +++ b/docs/superpowers/plans/2026-07-22-strict-platform-resolution.md @@ -4,21 +4,21 @@ **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; strict resolution searches only the explicit boundary and pins one canonical installation root for all platform utilities. +**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 and relative platform paths. -- [ ] Add `PlatformToolConfig.strict`, schema fields, strict-without-path validation, 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 no fallback, exact/prefix mismatch, unknown version, and sibling consistency. +- [ ] 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. -- [ ] Bind strict resolution to one canonical installation root and capture PATH roots once. +- [ ] 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 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 index a59cd1a..1c00d8f 100644 --- a/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md +++ b/docs/superpowers/specs/2026-07-22-strict-platform-resolution-design.md @@ -2,19 +2,36 @@ ## Goal -Make an explicitly pinned 1C platform installation fail closed when requested, while preserving the legacy discovery fallback by default. +Make a configured 1C platform path an explicit search boundary and make version enforcement +predictable across path and non-path discovery. ## Configuration contract -`tools.platform.strict` is a boolean with default `false`. `strict: true` requires `tools.platform.path`. The path is normalized relative to the primary config directory. +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`. -When strict mode is disabled, explicit path, default installation roots, and PATH keep the current fallback order. When strict mode is enabled, only the explicit path boundary is searched. A missing requested utility, an unknown version when `tools.platform.version` is configured, or a version mismatch is a typed locator error and never falls back. +Contract matrix: -Version requirements retain the existing semantics: four components are exact; two or three components are prefixes and select the highest matching installation below an explicit version root. +| 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 -In strict mode, 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 in default roots or PATH is rejected. +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. @@ -24,4 +41,6 @@ Each location carries a typed resolution source (`explicit`, `default-root`, or ## Verification -TDD covers strict missing paths, exact and prefix mismatch, unknown pinned version, versioned-root selection, sibling consistency, legacy fallback, relative path normalization, schema validation, and launch JSON metadata. Default behavior and existing locator tests remain unchanged. +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 9d7ca64..8405273 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -1271,7 +1271,7 @@ mod tests { } #[test] - fn load_config_rejects_strict_platform_without_path() { + 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"); @@ -1287,12 +1287,10 @@ mod tests { ) .expect("write config"); - let error = load_config(config_path.to_str(), None).expect_err("strict path validation"); + let config = load_config(config_path.to_str(), None).expect("strict without path"); - assert!(matches!( - error, - ConfigLoadError::ValidationError(ConfigValidationError::StrictPlatformRequiresPath) - )); + assert!(config.tools.platform.strict); + assert!(config.tools.platform.path.is_none()); } #[test] diff --git a/src/config/model.rs b/src/config/model.rs index a6d554d..70bca6d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -553,7 +553,12 @@ pub struct PlatformToolConfig { /// directory, or to a platform root that contains versioned subdirectories. pub path: Option, - /// Require platform utility resolution to stay within the configured path. + /// 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, diff --git a/src/config/schema.rs b/src/config/schema.rs index d760e41..fc1b89e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -626,13 +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, - /// Require platform utility resolution to stay within the configured path. + /// 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 used for discovery. + /// 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, } diff --git a/src/config/validate.rs b/src/config/validate.rs index 8152f57..d2fa149 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -103,9 +103,6 @@ pub enum ConfigValidationError { #[error("platform version must use format major.minor, major.minor.patch or major.minor.patch.build: {0}")] InvalidPlatformVersion(String), - #[error("tools.platform.path is required when tools.platform.strict is true")] - StrictPlatformRequiresPath, - #[error("build.partialLoadThreshold must be greater than or equal to 1")] InvalidPartialLoadThreshold, @@ -673,10 +670,6 @@ fn validate_matrix(_config: &AppConfig) -> Result<(), ConfigValidationError> { } fn validate_platform_version(config: &AppConfig) -> Result<(), ConfigValidationError> { - if config.tools.platform.strict && config.tools.platform.path.is_none() { - return Err(ConfigValidationError::StrictPlatformRequiresPath); - } - if let Some(version) = config.tools.platform.version.as_deref() { if PlatformVersionRequirement::parse(version).is_none() { return Err(ConfigValidationError::InvalidPlatformVersion( diff --git a/src/platform/locator.rs b/src/platform/locator.rs index a5b42a5..cbb2fa4 100644 --- a/src/platform/locator.rs +++ b/src/platform/locator.rs @@ -187,10 +187,10 @@ pub enum UtilityVersion { /// Resolution behavior for configured platform installation hints. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum PlatformResolutionPolicy { - /// Prefer the configured hint, then retain legacy default-root and `PATH` fallback. + /// Do not enforce `tools.platform.version` for a configured platform path. #[default] - Fallback, - /// Resolve platform utilities only inside the configured hint boundary. + Lenient, + /// Enforce `tools.platform.version` inside a configured platform path boundary. Strict, } @@ -201,7 +201,7 @@ pub struct LocatorOptions { pub platform_hint: Option, /// Optional platform version prefix or exact build. pub platform_version: Option, - /// Platform fallback behavior. + /// Whether a configured platform path enforces `platform_version`. pub platform_policy: PlatformResolutionPolicy, /// Configured EDT executable or installation hint. pub edt_hint: Option, @@ -359,6 +359,12 @@ impl Locator { if cached.utility != utility { return None; } + if utility.is_platform() + && self.platform_hint.is_some() + && self.platform_policy == PlatformResolutionPolicy::Lenient + { + return None; + } let candidate = Candidate { path: cached.path.clone(), @@ -373,7 +379,7 @@ impl Locator { ) => select_pinned_candidate( utility, vec![candidate], - self.platform_version.as_ref(), + self.effective_platform_version_requirement(), &pinned.root, ), ( @@ -385,18 +391,23 @@ impl Locator { select_candidate( utility, vec![candidate], - self.platform_version.as_ref(), + self.effective_platform_version_requirement(), boundary.as_deref(), ) } ( UtilityType::V8 | UtilityType::V8C | UtilityType::Ibcmd, - PlatformResolutionPolicy::Fallback, + PlatformResolutionPolicy::Lenient, Some(_) | None, - ) => select_candidate(utility, vec![candidate], None, None), + ) => select_candidate( + utility, + vec![candidate], + self.effective_platform_version_requirement(), + None, + ), ( UtilityType::EdtCli, - PlatformResolutionPolicy::Fallback | PlatformResolutionPolicy::Strict, + PlatformResolutionPolicy::Lenient | PlatformResolutionPolicy::Strict, Some(_) | None, ) => select_edt_candidate(vec![candidate], utility, None), }?; @@ -416,7 +427,7 @@ impl Locator { Self::with_search_roots( platform_hint, platform_version, - PlatformResolutionPolicy::Fallback, + PlatformResolutionPolicy::Lenient, edt_hint, edt_version, platform_roots, @@ -452,7 +463,8 @@ impl Locator { 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 @@ -465,13 +477,22 @@ impl Locator { } } + fn effective_platform_version_requirement(&self) -> Option<&PlatformVersionRequirement> { + if self.platform_hint.is_some() && self.platform_policy == PlatformResolutionPolicy::Lenient + { + None + } else { + self.platform_version.as_ref() + } + } + 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.platform_version.as_ref(), + self.effective_platform_version_requirement(), &pinned.root, ) .ok_or_else(|| LocatorError::MissingSibling { @@ -489,7 +510,7 @@ impl Locator { hint, utility, match self.platform_policy { - PlatformResolutionPolicy::Fallback => FileHintSiblingResolution::Lexical, + PlatformResolutionPolicy::Lenient => FileHintSiblingResolution::Lexical, PlatformResolutionPolicy::Strict => { FileHintSiblingResolution::CanonicalInstallation } @@ -498,7 +519,7 @@ impl Locator { }) .unwrap_or_default(); let strict_boundary = match self.platform_policy { - PlatformResolutionPolicy::Fallback => None, + PlatformResolutionPolicy::Lenient => None, PlatformResolutionPolicy::Strict => { self.platform_hint.as_deref().map(strict_candidate_boundary) } @@ -516,42 +537,29 @@ impl Locator { }) .unwrap_or_default(); - match self.platform_policy { - PlatformResolutionPolicy::Strict => { - let mut explicit_candidates = direct_explicit_candidates; - explicit_candidates.extend(versioned_explicit_candidates); - if let Some(location) = select_candidate( - utility, - explicit_candidates.clone(), - self.platform_version.as_ref(), - strict_boundary.as_deref(), - ) { - self.pin_platform(&location); - return Ok(location); - } - return Err(strict_resolution_error( + 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); + } + return match self.platform_policy { + PlatformResolutionPolicy::Strict => Err(strict_resolution_error( utility, self.platform_hint.as_deref(), explicit_candidates, - self.platform_version.as_ref(), + required, strict_boundary.as_deref(), - )); - } - PlatformResolutionPolicy::Fallback => { - if let Some(location) = - select_candidate(utility, direct_explicit_candidates, None, None) - { - return Ok(location); - } - if let Some(location) = select_candidate( - utility, - versioned_explicit_candidates, - self.platform_version.as_ref(), - None, - ) { - return Ok(location); - } - } + )), + PlatformResolutionPolicy::Lenient => Err(LocatorError::NotFound(utility)), + }; } let mut candidates = platform_candidates_any_version( @@ -560,14 +568,20 @@ impl Locator { ResolutionSource::DefaultRoot, ); candidates.extend(path_candidates(utility, &self.path_roots)); - let location = select_candidate(utility, candidates, self.platform_version.as_ref(), None) - .ok_or(LocatorError::NotFound(utility))?; + let location = select_candidate( + utility, + candidates, + self.effective_platform_version_requirement(), + None, + ) + .ok_or(LocatorError::NotFound(utility))?; self.pin_platform(&location); Ok(location) } fn pin_platform(&mut self, location: &UtilityLocation) { - if self.platform_policy != PlatformResolutionPolicy::Strict { + if self.platform_policy != PlatformResolutionPolicy::Strict || self.platform_hint.is_none() + { return; } self.pinned_platform = Some(PinnedPlatformInstallation { @@ -1243,6 +1257,24 @@ mod tests { ) } + 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"); @@ -1662,7 +1694,7 @@ mod tests { } #[test] - fn fallback_resolution_preserves_unknown_direct_hint_precedence() { + 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"); @@ -1671,15 +1703,11 @@ mod tests { .join(UtilityType::V8.executable_name()); touch_executable(&explicit); touch_versioned_platform_executable(&default_root, "8.3.25.1234", UtilityType::V8, true); - let mut locator = Locator::with_search_roots( + let mut locator = lenient_locator( Some(explicit_root), - PlatformVersionRequirement::parse("8.3.25.1234"), - PlatformResolutionPolicy::Fallback, - None, - None, + Some("8.3.25.1234"), vec![default_root], vec![], - vec![], ); let location = locator.locate(UtilityType::V8).expect("direct hint"); @@ -1690,59 +1718,159 @@ mod tests { } #[test] - fn fallback_resolution_keeps_platform_utility_searches_independent() { + 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 v8 = touch_versioned_platform_executable( + let explicit = touch_versioned_platform_executable( &explicit_root, - "8.3.25.1234", + "8.3.24.9999", UtilityType::V8, true, ); - let v8c = touch_versioned_platform_executable( - &default_root, + 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::V8C, + UtilityType::V8, true, ); - let mut locator = Locator::with_search_roots( + touch_versioned_platform_executable(&default_root, "8.3.25.1234", UtilityType::V8C, true); + let mut locator = lenient_locator( Some(explicit_root), - PlatformVersionRequirement::parse("8.3.25.1234"), - PlatformResolutionPolicy::Fallback, - None, - None, + Some("8.3.25.1234"), vec![default_root], vec![], - vec![], ); let first = locator.locate(UtilityType::V8).expect("explicit v8"); - let second = locator.locate(UtilityType::V8C).expect("fallback v8c"); assert_eq!(first.path, canonical(&v8)); assert_eq!(first.source, ResolutionSource::Explicit); - assert_eq!(second.path, canonical(&v8c)); - assert_eq!(second.source, ResolutionSource::DefaultRoot); + assert_eq!( + locator + .locate(UtilityType::V8C) + .expect_err("configured path must not fallback"), + LocatorError::NotFound(UtilityType::V8C) + ); } + #[cfg(unix)] #[test] - fn fallback_resolution_uses_injected_path_roots_and_reports_path_source() { + 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"); - let binary = path_root.join(UtilityType::Ibcmd.executable_name()); - touch_executable(&binary); - let mut locator = Locator::with_search_roots( + 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, - PlatformResolutionPolicy::Fallback, + PlatformVersionRequirement::parse("8.3.25.1234"), + PlatformResolutionPolicy::Strict, None, None, + vec![default_root], vec![], vec![], - vec![path_root.clone()], ); + 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)); diff --git a/src/platform/utilities.rs b/src/platform/utilities.rs index d94269b..fc5aa56 100644 --- a/src/platform/utilities.rs +++ b/src/platform/utilities.rs @@ -49,7 +49,7 @@ impl PlatformUtilities { platform_policy: if config.tools.platform.strict { PlatformResolutionPolicy::Strict } else { - PlatformResolutionPolicy::Fallback + PlatformResolutionPolicy::Lenient }, edt_hint, edt_version, @@ -243,6 +243,29 @@ mod tests { } } + #[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");