diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 11f9be3f1..6320363f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,6 +21,7 @@ MXC (Microsoft eXecution Container) is a cross-platform sandboxed code execution - Use `#[cfg(target_os = "...")]` and existing Cargo feature gates for platform-specific code. - Preserve the distinction between run-to-completion, streaming, and state-aware lifecycle APIs. - Unsupported policy must fail closed. Do not accept a field that the selected backend cannot enforce. +- The `mxc-sdk` `policy_store` module (prototype, pending API review) is the single tool-requirements resolver and owns the bundled catalog data in `src/mxc-sdk/policy_store/`. `mxc_ffi` exposes it to the Node and C# SDKs; do not reimplement resolution per language or move it into `mxc_engine`. See [`docs/policy-store/README.md`](../docs/policy-store/README.md). See: diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md new file mode 100644 index 000000000..484b96d54 --- /dev/null +++ b/docs/policy-store/README.md @@ -0,0 +1,194 @@ +# MXC Policy Store (prototype) + +> **PROTOTYPE, pending API review.** The API names and shapes here are proposed +> and may change before sign-off. The policy store is not part of MXC 1.0; a +> later MXC SDK release is targeted. + +The policy store resolves known tools (for example `git`, `node`, `npm`) to +command-free **`ContainerRequirements`** from a reviewed catalog bundled +statically in the MXC SDKs. The result is a best-effort floor, not a +guarantee, and it never grants access by itself: the caller reviews and +constrains it, adds its own command (object spread in Node, +`ContainerRequest::from_requirements` in Rust, +`ContainerRequest.FromRequirements` in C#), and passes the request to the +SDK's existing v1 container APIs. It is complementary to Learning Mode, not a +replacement. Lookup never creates a container, contacts a network service, +writes state, or downloads data. + +- The spec is in [microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309), + head `5c2ba8e`: `docs/mxc-policy-store-api.md` is the caller-facing API + contract (types, behavior, errors, examples), and + `docs/mxc-policy-store.md` the catalog design (data model, packaging, + source layout, contribution). +- [design.md](design.md) records the prototype's implementation decisions and + its known gaps against that spec. +- [`catalog/views/`](../../src/mxc-sdk/policy_store/catalog/views/) holds the + generated reviewer view and exact-request view for each revision. + +## Where it lives + +| Layer | Location | API | +|---|---|---| +| Resolver and V1 data | `src/mxc-sdk/src/policy_store/` (internal `mxc-sdk` module) and `src/mxc-sdk/policy_store/` (catalog, schemas, conformance) | Single implementation | +| Rust SDK | `mxc_sdk::v1` | `resolve_tool_requirements`, `resolve_tool_requirements_with_diagnostics`, `get_catalog_info`, `list_catalog_entries`; inputs and diagnostics in `mxc_sdk::v1::tool_requirements` | +| C ABI | `src/ffi/mxc_ffi/src/policy_store.rs` | `mxc_resolve_tool_requirements_json`, `mxc_resolve_tool_requirements_with_diagnostics_json`, `mxc_policy_catalog_info_json`, `mxc_list_policy_catalog_entries_json`, `mxc_policy_store_result_free` | +| Node SDK | `sdk/node/src/v1/tool-requirements.ts` (`@microsoft/mxc-sdk/v1`) | `resolveToolRequirements`, `resolveToolRequirementsWithDiagnostics` (both return a `Promise`), `getCatalogInfo`, `listCatalogEntries` | +| C# SDK | `sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs` | `MxcContainer.ResolveToolRequirements`, `ResolveToolRequirementsWithDiagnostics` (plus `Async` forms), `GetCatalogInfo`, `ListCatalogEntries` | + +There is one resolver. The catalog is compiled into `mxc-sdk`, so the data +ships inside `mxc_ffi` and every SDK that loads it. The Rust SDK returns +`ContainerRequirements` built from the existing v1 section types. The Node and +C# SDKs send a JSON lookup request (`{"tools", "context"?}`) through +`mxc_ffi` and map the result onto their own v1 section types. The SDK owns +the wire version: the catalog authors access only (`default.requirements`, +never a `version` or `command`). Resolution stays outside `mxc_engine`: it never +selects a backend or launches a container. + +## Example (Node) + +```ts +import { + ContainerRequest, + resolveToolRequirementsWithDiagnostics, +} from '@microsoft/mxc-sdk/v1'; + +const { requirements, diagnostics } = await resolveToolRequirementsWithDiagnostics( + { invocationName: 'git', packageUrl: 'pkg:generic/git', detectedVersion: '2.45.1', intent: 'push' }, + { projectRoot: '/work/repo', symbols: { git_prefix: '/usr', ssh_prefix: '/usr' } }, +); +// diagnostics.tools[0] => { inputIndex: 0, contributes: true, status: 'matched_version', selection: { entryId: 'tool:git', ... } } +// diagnostics.resolvedDependencies[0].entryId === 'tool:ssh' +if (requirements) { + const request: ContainerRequest = { ...requirements, command: 'git push' }; +} +``` + +## Entry model + +Each catalog entry has one unversioned **default** (`requirements` holding +container access sections, its dependencies, and named **intents** such as +`fetch` and `push`) and additive **overlays**: `platformVariants` for a +platform or architecture, and `versionVariants` keyed by a purl `vers` range +in the entry's required `versionScheme` (`npm`, `semver`, `pypi`, `nuget`, or +`intdot`). Overlays use `policyAdditions`, `intentAdditions` (extending +intents the default declares), and `newIntents`; they can never remove or +narrow anything. The effective policy is the default plus the platform +overlay plus at most one version overlay, and an intent then selects from it. + +## Resolution + +One call resolves one input or an array in a single pass. Each input is a +tool candidate (`invocationName`, optional `packageUrl`, `detectedVersion`, +and `intent`), or a bare name. + +- A `purl` match (on type, namespace, and name) is strong; an + `invocation-name` match is weak and needs `allowWeakIdentityFallback`. + Names compare case-insensitively on Windows and macOS and exactly on Linux. + Purl components other than type/namespace/name are ignored with a + `purl_components_ignored` warning; an unparseable purl is `purl_invalid`. +- Per-input statuses: + + | Situation | Status | Contributes | + |---|---|---| + | No version | `matched_default` | default (+ platform) | + | Version in a range | `matched_version` | default + that overlay | + | Valid version in no range | `version_out_of_range` | default (+ platform) | + | Unparseable version | `version_unparseable` | nothing | + | Intent not defined | `intent_unsupported` | nothing | + | No eligible entry | `tool_unmatched` | nothing | + + No intent selects the base plus every intent. Other inputs still resolve. +- `diagnostics.tools[i]` is `{ inputIndex, contributes, status, selection? }`. + `contributes` is true only when that input added access to the returned + requirements, so every flag is false when no requirements are returned. + `selection` (entry, match kind, version selection, intent selection) is + present once an entry is chosen. The Node and C# SDKs reject a result whose + shape breaks these rules with `backend_error`; unfamiliar status strings + are passed through. +- `projectRoot` and `symbols.project_root` both bind `${project_root}`. The + same non-empty value in both is accepted; different values fail with + `invalid_context` before lookup. A caller-supplied symbol is reported as + a `symbol_resolved` warning with `source: "caller"`. +- Dependencies contribute their default plus platform base additions; a + reference naming intents adds those intents. `resolvedDependencies` is + de-duplicated and attributes each dependency to every requesting input + (`inputIndexes`). +- Composition satisfies every contributing pair, and never more: paths are + substituted, normalized, and de-duplicated per layer; read-write supersedes + read-only; overlapping catalog denies are removed with structured + warnings. `ui` and `timeoutMs` come from at most one entry: omission + elsewhere adds nothing, and two distinct entries supplying either fails + with `composition_conflict`, even when the values are equal. Alias paths + are preserved as written. Paths whose filesystem + object identity cannot be established fail closed for that pair. The + composed result is validated as an exact MXC 1.0.0 request before it is + returned. +- Warnings are structured objects with a stable `code` and code-specific + fields (for example `entryId`, `detectedVersion`, `ignoredComponents`). + +Verifying that the executable really carries the strong identity passed in +`packageUrl`, and that `detectedVersion` is accurate, is the caller's job. +The caller's own restrictions always win. + +## Failures + +Failures reuse MXC's error codes, with a stable sub-reason: `details.reason` +on the Node `MxcError` and `MxcException.Reason` in C#. The Rust `Error` +carries the code only. + +| Code | Reason | Meaning | +|---|---|---| +| `policy_validation` | `invalid_catalog` | Catalog data violates the contract, or a resolved field does not fit the v1 section types. | +| `policy_validation` | `composition_conflict` | The contributing pairs cannot be composed without granting broader access. | +| `policy_validation` | `ambiguous_match` | An input matches more than one entry at the same rank. | +| `malformed_request` | `invalid_context` | The caller's inputs or context are invalid. | +| `unsupported_containment` | `unsupported_host` | The host platform, or a needed native architecture, cannot be determined. | +| `backend_error` | `integrity` | Bundled catalog data cannot be read or declares the wrong revision. | +| `backend_error` | `revision_unavailable` | An explicitly requested revision is not bundled. | + +Absence is not a failure: the resolve call yields no requirements. + +## Changing the catalog + +Authors edit one JSON file per tool under +`src/mxc-sdk/policy_store/catalog/entries/`. Each file holds that tool's full +entry: identity, default, intents, every platform and version variant, +dependencies, and provenance. Optional category subdirectories are +organizational only; a file's path never affects identity, lookup, or +composition. `revisions/.json` and `views/` are generated, +immutable published snapshots: never edit them by hand, and never rewrite one +that is already published. + +1. Add a new `{ catalogRevision, file }` (``, + `revisions/.json`) to `catalog/manifest.json` and point + `defaultRevision` at it. The data is compiled into the native library and + inherits MXC package signing. +2. Edit the entry files. Bump `entryRevision` for every entry that changes. + Every entry needs a `versionScheme` and one `default`; overlays are + additive only, and version ranges in one entry must not overlap. +3. Generate the revision snapshot from the entries with + `MXC_POLICY_STORE_UPDATE_REVISION=1`, then the views with + `MXC_POLICY_STORE_UPDATE_VIEWS=1` (both run + `cargo test -p mxc-sdk --test policy_store_catalog_validation`), and review + them. The assembler orders entries by `entryId` and rejects duplicate + `entryId` values and dependencies on entries no file defines. +4. Add or update a case in `conformance/fixtures/bundled-catalog.json`. Every + entry in the default revision needs one. To record actual outcomes for + review, run the conformance test with + `MXC_POLICY_STORE_UPDATE_FIXTURES=`, then merge them by hand. +5. Run the checks (from `src/`). They fail if a generated snapshot or view is + stale against its inputs: + + ```text + cargo test -p mxc-sdk --lib policy_store + cargo test -p mxc-sdk --test policy_store_library --test policy_store_conformance --test policy_store_catalog_validation --test policy_store_vectors --test policy_store_sdk + cargo test -p mxc_ffi --test policy_store + ``` + + Set `MXC_POLICY_STORE_BASE_REF=origin/main` to also check that no + published revision changed. + +The JSON Schemas in `src/mxc-sdk/policy_store/schema/` describe the catalog +shape for editors. The Rust validation in +`tests/policy_store_catalog_validation.rs` covers the same rules and more, +including every materialized effective policy as an exact MXC 1.0.0 request. diff --git a/docs/policy-store/design.md b/docs/policy-store/design.md new file mode 100644 index 000000000..3eab87002 --- /dev/null +++ b/docs/policy-store/design.md @@ -0,0 +1,72 @@ +# Policy Store prototype: implementation notes + +> **PROTOTYPE, pending API review.** Not part of MXC 1.0; a later MXC SDK +> release is targeted. API names and shapes may change before sign-off. + +The spec is in [microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309), +head `5c2ba8e`: `docs/mxc-policy-store-api.md` is the caller-facing API +contract and `docs/mxc-policy-store.md` the catalog design. This prototype +tracks both. The spec is the source of truth; this +page is not a copy of it. It records only how the prototype implements the +spec, the decisions it made where the spec leaves room, and its known gaps. +The [README](README.md) describes the API and the catalog workflow. + +## Shape + +- One resolver, an internal `policy_store` module of `mxc-sdk` + (`src/mxc-sdk/src/policy_store/`), with the V1 catalog compiled in from + `src/mxc-sdk/policy_store/catalog/`. There is no separate crate, library, + or CLI. +- Catalog sources follow design §6.3: one editable file per tool in + `catalog/entries/`. `assemble_revision` (a checked generator, like the + reviewer views) builds the default revision snapshot in `entryId` order and + rejects duplicate IDs and dangling dependencies; a cargo test fails when the + checked-in snapshot is stale. The static, manifest-selected embedding is + unchanged. +- Rust: `mxc_sdk::v1::{resolve_tool_requirements, + resolve_tool_requirements_with_diagnostics, get_catalog_info, + list_catalog_entries}` return `ContainerRequirements` built from the v1 + section types. Lookup types live in `mxc_sdk::v1::tool_requirements`. +- Node and .NET call the same resolver through panic-contained `mxc_ffi` + exports. Node resolution is promise-based (`koffi` async calls, so the + event loop is not blocked); catalog inspection is synchronous. .NET exposes + `MxcContainer.ResolveToolRequirements` and + `ResolveToolRequirementsWithDiagnostics` with `Async` forms. +- Catalog revision `2026-10-06.1` (`sdkContractVersion` `1.0.0`). Every + effective policy is materialized at build-test time, bound to fixture + symbols and a validation-only command, and validated as an exact MXC 1.0.0 + `ContainerRequest`; `catalog/views/.requests.json` records those + requests and `.md` is the reviewer view. + +## Decisions where the spec leaves room + +| Topic | Prototype behavior | +|---|---| +| purl parsing | An `@` before the last `/` is an unencoded npm scope, not a version (`pkg:npm/@scope/name`). Matching compares type, namespace, and name only; other components produce `purl_components_ignored`. | +| purl name normalization | No per-type normalization beyond the spec's comparison rule; open in the spec. | +| Empty `packageUrl` | `malformed_request` / `invalid_context`, like other invalid inputs. | +| Filesystem identity on a foreign platform | A lookup for a platform other than the host cannot examine object identity, so affected pairs fail closed. Tests resolve for the host platform against existing temporary directories. | +| Identity read failures | A discovery read failure maps to `unsupported_containment` / `unsupported_host`. | +| Read-only alias promotion | Reported as `readonly_superseded`. Alias paths are kept as written. | +| Lookup-time exact validation | A composed result that fails exact MXC 1.0.0 validation maps to `policy_validation` / `composition_conflict`. | +| `architecture_fallback` | Also emitted for dependencies and for the default selection. | +| `npm_cache` symbol | Discovery is limited; callers pass it in `symbols`. | +| Error reason | Node `details.reason` and .NET `MxcException.Reason` carry the stable reason. The Rust `Error` carries the code only. | +| FFI request | The `{"tools", "context"?}` lookup request is catalog-lookup input, not policy ingress, so it is not an exact versioned contract. | +| .NET runner contract | The lookup is on `MxcContainer` per the spec but is not part of `IContainerRunner`, which models container execution. | + +## Known gaps + +- Final API names are pending API review. +- The exact policy validator and version mapping, and purl normalization and + equality, follow the spec's open items and may change. +- The JSON Schemas in `policy_store/schema/` are editor aids; the Rust + catalog-validation tests are authoritative. + +## Tests + +From `src/`: `cargo test -p mxc-sdk --lib policy_store`, the +`policy_store_*` integration tests in `src/mxc-sdk/tests/`, and +`cargo test -p mxc_ffi --test policy_store`. Node: +`sdk/node/tests/unit/policy-store.test.ts`. .NET: +`sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs`. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs index 5fe7cfd67..2a16e529a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs @@ -361,4 +361,49 @@ static void CheckRoundTrip(T value, Func check, string name) Check(probe.Probes.BaseContainerApiPresent, "probe facts parsed"); Check(probe.Probes.UiCapabilities.CanBlockClipboardRead, "probe ui capabilities parsed"); +// 11. Prototype policy store DTOs (pending API review): the lookup request +// writer, the diagnostics result with absent and retained selection and +// true/false contribution flags, rejected missing/non-boolean flags, and +// the inspection metadata roots. +var lookupRequest = MxcContainer.ToolRequirementsRequest( + new ToolCandidate[] { "git", new("npm") { PackageUrl = "pkg:npm/npm", Intent = "install" } }, + new ResolveContext { ProjectRoot = "C:\\repo", Symbols = new Dictionary { ["npm_cache"] = "C:\\c" } }); +Check(lookupRequest[^1] == 0, "lookup request is NUL-terminated"); +const string resolutionJson = """ +{"requirements":{"filesystem":{"readwritePaths":["C:\\repo"]},"timeoutMs":5}, + "diagnostics":{"catalogRevision":"r","tools":[ + {"inputIndex":0,"contributes":true,"status":"matched_default","selection":{"entryId":"tool:git","entryRevision":1, + "matchedIdentities":[{"kind":"invocation-name","strength":"weak"}],"versionSelection":{"status":"matched_default"}, + "intentSelection":{"mode":"all","selected":["fetch"]}}}, + {"inputIndex":1,"contributes":false,"status":"version_unparseable","selection":{"entryId":"tool:npm","entryRevision":1, + "matchedIdentities":[{"kind":"purl","strength":"strong"}],"versionSelection":{"status":"version_unparseable","detectedVersion":"x"}}}, + {"inputIndex":2,"contributes":false,"status":"tool_unmatched"}], + "resolvedDependencies":[], + "warnings":[{"code":"tool_unmatched","inputIndex":2,"invocationName":"nope","message":"m"}, + {"code":"symbol_resolved","inputIndexes":[0],"entryIds":["tool:git"],"symbol":"project_root","value":"C:\\repo","source":"caller","message":"m"}]}} +"""; +var lookup = MxcContainer.ParseToolRequirementsResolution(resolutionJson, requireDiagnostics: true); +Check(lookup.Requirements?.TimeoutMs == 5, "policy store requirements parsed"); +Check(lookup.Diagnostics.Tools[0] is { Contributes: true, Selection.EntryId: "tool:git" }, "contributing selection parsed"); +Check(lookup.Diagnostics.Tools[1] is { Contributes: false, Selection.EntryId: "tool:npm" }, "failed selection retained"); +Check(lookup.Diagnostics.Tools[2] is { Contributes: false, Selection: null }, "absent selection parsed"); +Check(lookup.Diagnostics.Warnings.Count == 2, "structured warnings parsed"); +foreach (var flag in new[] { "", "\"contributes\":\"true\"," }) +{ + var malformed = $$$"""{"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":0,{{{flag}}}"status":"tool_unmatched"}],"resolvedDependencies":[],"warnings":[]}}"""; + try + { + MxcContainer.ParseToolRequirementsResolution(malformed, requireDiagnostics: true); + Check(false, "missing or non-boolean contributes is rejected"); + } + catch (MxcException error) when (error.Code == ErrorCode.BackendError) + { + } +} +var catalogInfo = MxcJson.Deserialize( + """{"catalogSchemaVersion":"1","catalogRevision":"r","sdkContractVersion":"1.0.0"}""", + MxcJson.PolicyStoreOptions); +Check(catalogInfo?.SdkContractVersion == "1.0.0", "catalog info parsed"); +CheckRoundTrip(catalogInfo!, x => x.CatalogRevision == "r", "catalog info round-trips"); + Console.WriteLine("AOT smoke test passed: all JSON paths are reflection-free."); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs index a05eaddea..ccb506edd 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs @@ -42,6 +42,15 @@ public void OnlyTheCodeAndMessageConstructorIsPublic() signatures); } + [Fact] + public void Reason_IsAbsentOutsideThePolicyStoreAndNotPubliclySettable() + { + Assert.Null(new MxcException(ErrorCode.BackendError, "x").Reason); + var setter = typeof(MxcException).GetProperty(nameof(MxcException.Reason))!.SetMethod; + Assert.NotNull(setter); + Assert.False(setter!.IsPublic); + } + [Fact] public void ApiDetail_IsCarriedAlongsideTheCodeAndMessage() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/SandboxAdapterTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/SandboxAdapterTests.cs index 57ac0ce91..d089ca587 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/SandboxAdapterTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/SandboxAdapterTests.cs @@ -70,6 +70,19 @@ public void LifecycleAdapterDelegatesStaticValidation() Assert.Equal(ErrorCode.MalformedId, exception.Code); } + // The prototype policy-store lookup (pending API review) lives on + // MxcContainer per the spec but is a pure catalog lookup, not container + // execution, so it is not part of the injectable runner contract. + private static readonly HashSet PolicyStoreLookups = new() + { + nameof(MxcContainer.ResolveToolRequirements), + nameof(MxcContainer.ResolveToolRequirementsAsync), + nameof(MxcContainer.ResolveToolRequirementsWithDiagnostics), + nameof(MxcContainer.ResolveToolRequirementsWithDiagnosticsAsync), + nameof(MxcContainer.GetCatalogInfo), + nameof(MxcContainer.ListCatalogEntries), + }; + [Theory] [InlineData(typeof(MxcContainer), typeof(IContainerRunner))] [InlineData(typeof(MxcLifecycle), typeof(IContainerLifecycle))] @@ -81,7 +94,8 @@ public void InjectableContractsMirrorStaticFacadeMethods( foreach (var facadeMethod in staticFacade .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) .Where(method => !method.IsSpecialName) - .Where(method => staticFacade != typeof(MxcContainer) || method.Name != nameof(MxcContainer.Probe))) + .Where(method => staticFacade != typeof(MxcContainer) || method.Name != nameof(MxcContainer.Probe)) + .Where(method => staticFacade != typeof(MxcContainer) || !PolicyStoreLookups.Contains(method.Name))) { var parameterTypes = facadeMethod.GetParameters() .Select(parameter => parameter.ParameterType) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs new file mode 100644 index 000000000..7e2731430 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk.V1; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests.V1; + +/// +/// The prototype tool-requirements lookup (pending API review). Lookups target +/// the current host, whose filesystem object identity is examined, so every +/// symbol names an existing temporary directory. +/// +public sealed class ToolRequirementsTests : IDisposable +{ + private readonly string _root; + private readonly string _ssh; + private readonly ResolveContext _context; + + public ToolRequirementsTests() + { + _root = Path.Combine(Path.GetTempPath(), "mxc-policy-store-" + Guid.NewGuid().ToString("N")); + string Dir(params string[] parts) + { + var path = Path.Combine(new[] { _root }.Concat(parts).ToArray()); + Directory.CreateDirectory(path); + return path; + } + + _ssh = Dir("ssh"); + Dir("pd", "Git"); + _context = new ResolveContext + { + ProjectRoot = Dir("repo"), + Symbols = new Dictionary + { + ["node_prefix"] = Dir("node"), + ["npm_prefix"] = Dir("npm"), + ["npm_cache"] = Dir("npm-cache"), + ["git_prefix"] = Dir("git"), + ["ssh_prefix"] = _ssh, + ["temp_dir"] = Dir("temp"), + ["programData"] = Dir("pd"), + }, + }; + } + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private static ToolCandidate Git(string? version = null, string? intent = null) => + new("git") { PackageUrl = "pkg:generic/git", DetectedVersion = version, Intent = intent }; + + [Fact] + public void GetCatalogInfo_ReportsTheBundledCatalogAndContract() + { + var info = MxcContainer.GetCatalogInfo(); + + Assert.Equal("1", info.CatalogSchemaVersion); + Assert.Equal("1.0.0", info.SdkContractVersion); + Assert.False(string.IsNullOrWhiteSpace(info.CatalogRevision)); + } + + [Fact] + public void ListCatalogEntries_ReturnsMetadata() + { + var entries = MxcContainer.ListCatalogEntries(); + + Assert.Contains(entries, entry => entry.EntryId == "tool:npm"); + var git = Assert.Single(entries, entry => entry.EntryId == "tool:git"); + Assert.Equal("intdot", git.VersionScheme); + Assert.Equal(new[] { "fetch", "local", "push" }, git.Default.Intents.Select(i => i.Name).Order()); + Assert.Equal( + new[] { "vers:intdot/>=2.40|<2.50", "vers:intdot/>=2.50|<3" }, + git.VersionVariants.Select(v => v.VersionRange)); + Assert.Equal("bundle-fetch", Assert.Single(git.VersionVariants[1].NewIntents).Name); + } + + [Fact] + public async Task ResolveToolRequirementsAsync_ReturnsCommandFreeRequirements() + { + var requirements = await MxcContainer.ResolveToolRequirementsAsync( + new ToolCandidate("npm") { PackageUrl = "pkg:npm/npm" }, + _context, + TestContext.Current.CancellationToken); + + Assert.NotNull(requirements); + Assert.NotEmpty(requirements.Filesystem!.ReadonlyPaths); + + var request = ContainerRequest.FromRequirements(requirements, "npm --version"); + Assert.Equal("npm --version", request.Command); + Assert.Same(requirements.Filesystem, request.Filesystem); + } + + [Fact] + public void ResolveToolRequirements_ReturnsNullForAnUnknownTool() + { + Assert.Null(MxcContainer.ResolveToolRequirements("no-such-tool", _context)); + } + + [Fact] + public void WeakIdentity_RequiresTheOptIn() + { + Assert.Null(MxcContainer.ResolveToolRequirementsWithDiagnostics("git", _context).Requirements); + + var resolution = MxcContainer.ResolveToolRequirementsWithDiagnostics( + new ToolCandidate[] { "git" }, + _context with { AllowWeakIdentityFallback = true }); + + Assert.NotNull(resolution.Requirements); + var tool = Assert.Single(resolution.Diagnostics.Tools); + Assert.Equal(0, tool.InputIndex); + Assert.True(tool.Contributes); + Assert.Equal("tool:git", tool.Selection!.EntryId); + Assert.Equal("weak", tool.Selection.MatchedIdentities[0].Strength); + var weak = Assert.IsType( + Assert.Single(resolution.Diagnostics.Warnings, w => w.Code == "weak_identity")); + Assert.Equal("git", weak.InvocationName); + } + + [Fact] + public async Task VersionAndIntent_SelectARangeAndAttributeDependencies() + { + var push = await MxcContainer.ResolveToolRequirementsWithDiagnosticsAsync( + Git("2.45.1", "push"), + _context, + TestContext.Current.CancellationToken); + + var tool = Assert.Single(push.Diagnostics.Tools); + Assert.Equal("matched_version", tool.Status); + Assert.Equal("vers:intdot/>=2.40|<2.50", tool.Selection!.VersionSelection.SelectedVersionRange); + Assert.Equal("named", tool.Selection.IntentSelection!.Mode); + Assert.Equal("push", tool.Selection.IntentSelection.Requested); + Assert.Equal(new[] { "push" }, tool.Selection.IntentSelection.Selected); + var ssh = Assert.Single(push.Diagnostics.ResolvedDependencies); + Assert.Equal("tool:ssh", ssh.EntryId); + Assert.Equal(new[] { 0 }, ssh.InputIndexes); + Assert.Equal("none", ssh.IntentSelection.Mode); + Assert.Contains(_ssh, push.Requirements!.Filesystem!.ReadonlyPaths); + Assert.Single(push.Requirements.Network!.Egress!.Allow!); + } + + [Fact] + public void PerPairOutcomes_CarryStructuredWarnings() + { + var outOfRange = MxcContainer.ResolveToolRequirementsWithDiagnostics(Git("2.30.0", "fetch"), _context); + Assert.Equal("version_out_of_range", outOfRange.Diagnostics.Tools[0].Status); + Assert.NotNull(outOfRange.Requirements); + var warning = Assert.IsType(outOfRange.Diagnostics.Warnings[0]); + Assert.Equal("version_out_of_range", warning.Code); + Assert.Equal("tool:git", warning.EntryId); + Assert.Equal("2.30.0", warning.DetectedVersion); + + var unparseable = MxcContainer.ResolveToolRequirementsWithDiagnostics(Git("banana", "fetch"), _context); + Assert.Equal("version_unparseable", unparseable.Diagnostics.Tools[0].Status); + Assert.Null(unparseable.Requirements); + Assert.False(unparseable.Diagnostics.Tools[0].Contributes); + Assert.Equal("tool:git", unparseable.Diagnostics.Tools[0].Selection!.EntryId); + + var unsupported = MxcContainer.ResolveToolRequirementsWithDiagnostics(Git(intent: "bundle-fetch"), _context); + Assert.Equal("intent_unsupported", unsupported.Diagnostics.Tools[0].Status); + Assert.Null(unsupported.Requirements); + + var bundle = MxcContainer.ResolveToolRequirementsWithDiagnostics(Git("2.55", "bundle-fetch"), _context); + Assert.Equal("matched_version", bundle.Diagnostics.Tools[0].Status); + Assert.Empty(bundle.Diagnostics.ResolvedDependencies); + + var invalidPurl = MxcContainer.ResolveToolRequirementsWithDiagnostics( + new ToolCandidate("npm") { PackageUrl = "not a purl" }, + _context); + Assert.Equal("tool_unmatched", invalidPurl.Diagnostics.Tools[0].Status); + Assert.Equal("purl_invalid", invalidPurl.Diagnostics.Warnings[0].Code); + + var ignored = MxcContainer.ResolveToolRequirementsWithDiagnostics( + new ToolCandidate("npm") { PackageUrl = "pkg:npm/npm@10.0.0" }, + _context); + var components = Assert.IsType(ignored.Diagnostics.Warnings[0]); + Assert.Equal("purl_components_ignored", components.Code); + Assert.Equal(new[] { "version" }, components.IgnoredComponents); + } + + [Fact] + public void Composition_AToolWithoutNetworkDoesNotVetoAnother() + { + var resolution = MxcContainer.ResolveToolRequirementsWithDiagnostics( + new ToolCandidate[] { Git(intent: "local"), Git(intent: "fetch"), "no-such-tool" }, + _context); + + Assert.Single(resolution.Requirements!.Network!.Egress!.Allow!); + Assert.Equal("tool_unmatched", resolution.Diagnostics.Tools[2].Status); + Assert.Equal(new[] { true, true, false }, resolution.Diagnostics.Tools.Select(t => t.Contributes)); + Assert.Null(resolution.Diagnostics.Tools[2].Selection); + var unmatched = Assert.IsType( + Assert.Single(resolution.Diagnostics.Warnings, w => w.Code == "tool_unmatched")); + Assert.Equal(2, unmatched.InputIndex); + Assert.Equal("no-such-tool", unmatched.InvocationName); + } + + [Fact] + public void ProjectRoot_BindsThroughEitherContextFormAndRejectsDifferingValues() + { + var symbols = new Dictionary(_context.Symbols!) + { + ["project_root"] = _context.ProjectRoot!, + }; + var viaSymbol = MxcContainer.ResolveToolRequirements( + Git(intent: "local"), + _context with { ProjectRoot = null, Symbols = symbols }); + var viaRoot = MxcContainer.ResolveToolRequirements(Git(intent: "local"), _context); + Assert.Equal(viaRoot!.Filesystem!.ReadwritePaths, viaSymbol!.Filesystem!.ReadwritePaths); + Assert.NotNull(MxcContainer.ResolveToolRequirements( + Git(intent: "local"), + _context with { Symbols = symbols })); + + symbols["project_root"] = _context.ProjectRoot + "x"; + var error = Assert.Throws(() => MxcContainer.ResolveToolRequirements( + Array.Empty(), + _context with { Symbols = symbols })); + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + Assert.Equal("invalid_context", error.Reason); + } + + [Fact] + public void ToolCandidate_RejectsANullNameAndConvertsAPlainString() + { + Assert.Throws(() => new ToolCandidate(null!)); + Assert.Throws(() => { ToolCandidate _ = (string)null!; }); + + ToolCandidate converted = "git"; + Assert.Equal("git", converted.InvocationName); + Assert.Null(converted.PackageUrl); + Assert.Null(converted.DetectedVersion); + Assert.Null(converted.Intent); + } + + [Theory] + [InlineData("""{"requirements":{},"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":0,"status":"matched_default"}],"resolvedDependencies":[],"warnings":[]}}""")] + [InlineData("""{"requirements":{},"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":0,"contributes":"true","status":"matched_default"}],"resolvedDependencies":[],"warnings":[]}}""")] + [InlineData("""{"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":0,"contributes":true,"status":"matched_default"}],"resolvedDependencies":[],"warnings":[]}}""")] + [InlineData("""{"requirements":{},"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":0,"contributes":false,"status":"matched_default"}],"resolvedDependencies":[],"warnings":[]}}""")] + [InlineData("""{"requirements":{},"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":1,"contributes":true,"status":"matched_default"}],"resolvedDependencies":[],"warnings":[]}}""")] + public void ParseToolRequirementsResolution_RejectsMalformedContribution(string json) + { + var error = Assert.Throws( + () => MxcContainer.ParseToolRequirementsResolution(json, requireDiagnostics: true)); + Assert.Equal(ErrorCode.BackendError, error.Code); + } + + [Fact] + public void ParseToolRequirementsResolution_AcceptsAnUnfamiliarStatus() + { + var resolution = MxcContainer.ParseToolRequirementsResolution( + """{"requirements":{},"diagnostics":{"catalogRevision":"r","tools":[{"inputIndex":0,"contributes":true,"status":"some_future_status"}],"resolvedDependencies":[],"warnings":[]}}""", + requireDiagnostics: true); + Assert.Equal("some_future_status", Assert.Single(resolution.Diagnostics.Tools).Status); + } + + [Fact] + public async Task InvalidContext_ThrowsMxcExceptionWithAReason() + { + var platform = Assert.Throws( + () => MxcContainer.ResolveToolRequirements("npm", _context with { Platform = "plan9" })); + Assert.Equal(ErrorCode.MalformedRequest, platform.Code); + Assert.Equal("invalid_context", platform.Reason); + + var intent = await Assert.ThrowsAsync( + () => MxcContainer.ResolveToolRequirementsAsync( + Git(intent: string.Empty), + _context, + TestContext.Current.CancellationToken)); + Assert.Equal("invalid_context", intent.Reason); + } + + [Fact] + public void ToolRequirementsRequest_OmitsUnsetFields() + { + var bytes = MxcContainer.ToolRequirementsRequest(new ToolCandidate[] { "git" }, null); + + Assert.Equal(0, bytes[^1]); + Assert.Equal( + """{"tools":[{"invocationName":"git"}]}""", + System.Text.Encoding.UTF8.GetString(bytes, 0, bytes.Length - 1)); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs index beb2b5147..0badc80d9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs @@ -91,6 +91,11 @@ public CamelCaseJsonStringEnumConverter() [JsonSerializable(typeof(LifecycleResult))] [JsonSerializable(typeof(ProvisionResult))] [JsonSerializable(typeof(ContainerId))] +// Prototype policy store results (pending API review). +[JsonSerializable(typeof(ContainerRequirements))] +[JsonSerializable(typeof(NativeToolRequirementsResolution))] +[JsonSerializable(typeof(CatalogInfo))] +[JsonSerializable(typeof(CatalogEntryMetadata[]))] // Primitive and collection shapes resolved by the custom converters and helpers. [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(List))] @@ -122,6 +127,13 @@ internal static class MxcJson /// internal static readonly JsonSerializerOptions ProbeOptions = CreateProbeOptions(); + /// + /// Options for the prototype policy store's results: strict like + /// , so a field the v1 types cannot hold is + /// rejected rather than silently dropped. + /// + internal static readonly JsonSerializerOptions PolicyStoreOptions = CreateProbeOptions(); + private static JsonSerializerOptions CreateOptions() { var options = new JsonSerializerOptions(MxcJsonContext.Default.Options); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequest.cs index 1e6cd94e5..bf4b86aef 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequest.cs @@ -18,6 +18,23 @@ public ContainerRequest(string command) Command = command; } + /// + /// PROTOTYPE, pending API review. Create a request for + /// carrying reviewed + /// . The sections are shared, not copied. + /// + public static ContainerRequest FromRequirements(ContainerRequirements requirements, string command) + { + ArgumentNullException.ThrowIfNull(requirements); + return new ContainerRequest(command) + { + Filesystem = requirements.Filesystem, + Network = requirements.Network, + Ui = requirements.Ui, + TimeoutMs = requirements.TimeoutMs, + }; + } + /// The command line to run. [JsonPropertyName("command")] public string Command { get; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs new file mode 100644 index 000000000..ec48708e7 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs @@ -0,0 +1,368 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Mxc.Sdk.Native; + +namespace Microsoft.Mxc.Sdk.V1; + +/// +/// PROTOTYPE, pending API review. The policy store's tool-requirements +/// lookup: resolve known tools to command-free +/// from the catalog bundled statically in +/// the native mxc_ffi library. +/// +/// +/// +/// The result is a best-effort floor, not a guarantee, and complementary to +/// Learning Mode. Lookup never grants access, creates a container, contacts a +/// network service, or writes state; nothing is downloaded. Names and shapes +/// may change before sign-off, and this API is not part of MXC 1.0. +/// +/// +/// Failures throw : +/// is the MXC error code and the optional +/// stable reason (for example invalid_context). +/// +/// +public static partial class MxcContainer +{ + /// + /// PROTOTYPE, pending API review. Resolve one tool to composed + /// container requirements. + /// + /// The requirements, or when none resolved. + /// The input, context, or catalog is invalid. + public static ContainerRequirements? ResolveToolRequirements( + ToolCandidate tool, + ResolveContext? context = null) + { + ArgumentNullException.ThrowIfNull(tool); + return ResolveToolRequirements(new[] { tool }, context); + } + + /// + /// PROTOTYPE, pending API review. Resolve several tools to one set of + /// composed container requirements. The result may cover only some of the + /// tools; use + /// to see which contributed. + /// + /// The requirements, or when none resolved. + /// The input, context, or catalog is invalid. + public static ContainerRequirements? ResolveToolRequirements( + IReadOnlyList tools, + ResolveContext? context = null) + { + var json = CallResolve(ToolRequirementsRequest(tools, context), withDiagnostics: false); + return ParseToolRequirementsResolution(json, requireDiagnostics: false).Requirements; + } + + /// + /// PROTOTYPE, pending API review. Like + /// , plus + /// per-input statuses, contributing entries and dependencies, and + /// structured warnings from the same pass. + /// + /// The input, context, or catalog is invalid. + public static ToolRequirementsResolution ResolveToolRequirementsWithDiagnostics( + ToolCandidate tool, + ResolveContext? context = null) + { + ArgumentNullException.ThrowIfNull(tool); + return ResolveToolRequirementsWithDiagnostics(new[] { tool }, context); + } + + /// + /// PROTOTYPE, pending API review. Like + /// , + /// plus per-input statuses, contributing entries and dependencies, and + /// structured warnings from the same pass. + /// + /// The input, context, or catalog is invalid. + public static ToolRequirementsResolution ResolveToolRequirementsWithDiagnostics( + IReadOnlyList tools, + ResolveContext? context = null) + { + var json = CallResolve(ToolRequirementsRequest(tools, context), withDiagnostics: true); + return ParseToolRequirementsResolution(json, requireDiagnostics: true); + } + + /// + /// PROTOTYPE, pending API review. Asynchronous form of + /// ; + /// lookup examines host filesystem objects off the calling thread. + /// + public static Task ResolveToolRequirementsAsync( + ToolCandidate tool, + ResolveContext? context = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tool); + return ResolveToolRequirementsAsync(new[] { tool }, context, cancellationToken); + } + + /// + /// PROTOTYPE, pending API review. Asynchronous form of + /// . + /// + public static Task ResolveToolRequirementsAsync( + IReadOnlyList tools, + ResolveContext? context = null, + CancellationToken cancellationToken = default) + { + var request = ToolRequirementsRequest(tools, context); + return MxcLifecycle.RunBlockingOperationAsync( + () => ParseToolRequirementsResolution( + CallResolve(request, withDiagnostics: false), + requireDiagnostics: false).Requirements, + _ => { }, + cancellationToken); + } + + /// + /// PROTOTYPE, pending API review. Asynchronous form of + /// . + /// + public static Task ResolveToolRequirementsWithDiagnosticsAsync( + ToolCandidate tool, + ResolveContext? context = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tool); + return ResolveToolRequirementsWithDiagnosticsAsync(new[] { tool }, context, cancellationToken); + } + + /// + /// PROTOTYPE, pending API review. Asynchronous form of + /// . + /// + public static Task ResolveToolRequirementsWithDiagnosticsAsync( + IReadOnlyList tools, + ResolveContext? context = null, + CancellationToken cancellationToken = default) + { + var request = ToolRequirementsRequest(tools, context); + return MxcLifecycle.RunBlockingOperationAsync( + () => ParseToolRequirementsResolution( + CallResolve(request, withDiagnostics: true), + requireDiagnostics: true), + _ => { }, + cancellationToken); + } + + /// + /// PROTOTYPE, pending API review. The bundled catalog's schema + /// version, default revision, and SDK contract version. + /// + public static CatalogInfo GetCatalogInfo() => + DeserializeStoreResult(CallInspect(listEntries: false)); + + /// + /// PROTOTYPE, pending API review. Metadata for every entry in the + /// bundled catalog's default revision. It never exposes a requirements body. + /// + public static IReadOnlyList ListCatalogEntries() => + DeserializeStoreResult(CallInspect(listEntries: true)); + + /// + /// Writes the {"tools", "context"?} lookup request. Validation is the + /// native resolver's; this only rejects null references. + /// + internal static byte[] ToolRequirementsRequest( + IReadOnlyList tools, + ResolveContext? context) + { + ArgumentNullException.ThrowIfNull(tools); + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteStartArray("tools"); + foreach (var tool in tools) + { + if (tool is null) + { + throw new ArgumentException("A tool candidate is null.", nameof(tools)); + } + + writer.WriteStartObject(); + writer.WriteString("invocationName", tool.InvocationName); + WriteOptional(writer, "packageUrl", tool.PackageUrl); + WriteOptional(writer, "detectedVersion", tool.DetectedVersion); + WriteOptional(writer, "intent", tool.Intent); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + if (context is not null) + { + writer.WriteStartObject("context"); + WriteOptional(writer, "projectRoot", context.ProjectRoot); + if (context.Symbols is not null) + { + writer.WriteStartObject("symbols"); + foreach (var (name, value) in context.Symbols) + { + writer.WriteString(name, value); + } + + writer.WriteEndObject(); + } + + WriteOptional(writer, "platform", context.Platform); + WriteOptional(writer, "architecture", context.Architecture); + WriteOptional(writer, "catalogRevision", context.CatalogRevision); + writer.WriteBoolean("allowWeakIdentityFallback", context.AllowWeakIdentityFallback); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + + buffer.WriteByte(0); + return buffer.ToArray(); + } + + private static void WriteOptional(Utf8JsonWriter writer, string name, string? value) + { + if (value is not null) + { + writer.WriteString(name, value); + } + } + + private static unsafe string CallResolve(byte[] request, bool withDiagnostics) + { + fixed (byte* requestPtr = request) + { + MxcPolicyStoreResult result = default; + var status = withDiagnostics + ? NativeMethods.mxc_resolve_tool_requirements_with_diagnostics_json(requestPtr, &result) + : NativeMethods.mxc_resolve_tool_requirements_json(requestPtr, &result); + return TakeStoreResult(status, &result); + } + } + + private static unsafe string CallInspect(bool listEntries) + { + MxcPolicyStoreResult result = default; + var status = listEntries + ? NativeMethods.mxc_list_policy_catalog_entries_json(&result) + : NativeMethods.mxc_policy_catalog_info_json(&result); + return TakeStoreResult(status, &result); + } + + private static unsafe string TakeStoreResult(int status, MxcPolicyStoreResult* result) + { + try + { + if (status != (int)ErrorCode.Success) + { + var error = NativeError.ToException(status, result->error, "unknown error"); + error.Reason = NativeError.ToStringOrNull(result->reason_utf8); + throw error; + } + + return NativeError.ToStringOrNull(result->json_utf8) + ?? throw new MxcException( + ErrorCode.BackendError, + "The policy store returned no result."); + } + finally + { + NativeMethods.mxc_policy_store_result_free(result); + } + } + + /// + /// Maps the native result onto the v1 types. A field the SDK model cannot + /// hold is rejected rather than silently dropped from the requirements. + /// + internal static ToolRequirementsResolution ParseToolRequirementsResolution( + string json, + bool requireDiagnostics) + { + var resolution = DeserializeStoreResult(json); + if (requireDiagnostics && resolution.Diagnostics is null) + { + throw new MxcException(ErrorCode.BackendError, "The policy store returned no diagnostics."); + } + + if (resolution.Diagnostics is { } diagnostics) + { + ValidateDiagnostics(resolution.Requirements, diagnostics); + } + + return new ToolRequirementsResolution( + resolution.Requirements, + resolution.Diagnostics ?? EmptyToolRequirementsDiagnostics); + } + + /// + /// Rejects a diagnostics result whose shape the SDK cannot vouch for: input + /// records out of order, or output present unless at least one input + /// contributes. Unfamiliar status strings stay descriptive. + /// + private static void ValidateDiagnostics( + ContainerRequirements? requirements, + ToolRequirementsDiagnostics diagnostics) + { + string? problem = null; + if (diagnostics.CatalogRevision is null + || diagnostics.Tools is null + || diagnostics.ResolvedDependencies is null + || diagnostics.Warnings is null) + { + problem = "a required diagnostics field is missing"; + } + else + { + for (var index = 0; problem is null && index < diagnostics.Tools.Count; index++) + { + var tool = diagnostics.Tools[index]; + if (tool is null || tool.InputIndex != index || tool.Status is null) + { + problem = $"tools[{index}] is malformed or out of input order"; + } + } + + if (problem is null + && (requirements is not null) != diagnostics.Tools.Any(tool => tool.Contributes)) + { + problem = "requirements must be present exactly when at least one input contributes"; + } + } + + if (problem is not null) + { + throw new MxcException( + ErrorCode.BackendError, + $"The policy store result does not match the SDK v1 types: {problem}."); + } + } + + private static T DeserializeStoreResult(string json) + { + try + { + return MxcJson.Deserialize(json, MxcJson.PolicyStoreOptions) + ?? throw new JsonException("The policy store returned null JSON."); + } + catch (JsonException error) + { + throw new MxcException( + ErrorCode.BackendError, + $"The policy store result does not match the SDK v1 types: {error.Message}", + error); + } + } + + private static readonly ToolRequirementsDiagnostics EmptyToolRequirementsDiagnostics = + new(string.Empty, Array.Empty(), Array.Empty(), Array.Empty()); +} + +/// The native {"requirements"?, "diagnostics"?} document. +internal sealed record NativeToolRequirementsResolution( + ContainerRequirements? Requirements, + ToolRequirementsDiagnostics? Diagnostics); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs index 07b37d411..8cdb07dfc 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs @@ -16,7 +16,7 @@ namespace Microsoft.Mxc.Sdk.V1; /// mxc_ffi library and runs or spawns a complete /// . /// -public static class MxcContainer +public static partial class MxcContainer { static MxcContainer() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs index 02a6825f8..c24e777e1 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs @@ -38,6 +38,18 @@ public sealed class MxcException : Exception /// public string? Remediation { get; } + /// + /// PROTOTYPE, pending API review. The policy store's optional, + /// stable failure reason (details.reason): invalid_context, + /// invalid_catalog, ambiguous_match, + /// composition_conflict, unsupported_host, integrity, + /// or revision_unavailable. alone is sufficient + /// for handling; an absent or unrecognized reason has the same meaning as + /// the code. for every failure that did not come + /// from the tool-requirements lookup. + /// + public string? Reason { get; internal set; } + /// Create an exception with the given code and message. public MxcException(ErrorCode code, string message) : this(code, message, null, null, null) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs new file mode 100644 index 000000000..d738c239c --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs @@ -0,0 +1,561 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Mxc.Sdk.V1; + +/// +/// PROTOTYPE, pending API review. Command-free container requirements: +/// the filesystem, network, UI, and timeout fields of a +/// , using the same v1 section types. +/// +/// +/// A caller reviews and constrains the requirements, then adds its own command +/// with . The requirements are a +/// best-effort floor, not a guarantee. +/// +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed class ContainerRequirements +{ + /// Cross-backend filesystem access requirements. + [JsonPropertyName("filesystem")] + public FilesystemPolicy? Filesystem { get; set; } + + /// Cross-backend directional network requirements. + [JsonPropertyName("network")] + public NetworkPolicy? Network { get; set; } + + /// Cross-backend UI requirements. + [JsonPropertyName("ui")] + public UiPolicy? Ui { get; set; } + + /// Execution timeout in milliseconds; null means none required. + [JsonPropertyName("timeoutMs")] + public uint? TimeoutMs { get; set; } +} + +/// Catalog platform selector values. +public static class CatalogPlatforms +{ + /// Windows. + public const string Windows = "windows"; + + /// Linux. + public const string Linux = "linux"; + + /// macOS. + public const string MacOS = "macos"; +} + +/// Catalog architecture selector values. +public static class CatalogArchitectures +{ + /// x64 (AMD64 / x86_64). + public const string X64 = "x64"; + + /// ARM64 (aarch64). + public const string Arm64 = "arm64"; +} + +/// +/// PROTOTYPE, pending API review. A tool to look up. A plain string +/// converts implicitly to a candidate with only . +/// +/// +/// A string[] does not convert element-by-element to +/// IReadOnlyList<ToolCandidate>; use a typed +/// ToolCandidate[] or a target-typed collection expression. +/// +public sealed class ToolCandidate +{ + /// Creates a candidate identified only by its invocation name. + /// The bare invocation name, for example git. + /// is null. + public ToolCandidate(string invocationName) + { + ArgumentNullException.ThrowIfNull(invocationName); + InvocationName = invocationName; + } + + /// + /// The bare invocation name, for example git. A weak identity, used + /// only when is set. + /// + public string InvocationName { get; } + + /// + /// A Package URL, for example pkg:npm/npm. A strong identity matched + /// on type, namespace, and name; a version, qualifiers, or subpath are + /// ignored with a purl_components_ignored warning, and an invalid + /// PURL makes the pair tool_unmatched. + /// + public string? PackageUrl { get; init; } + + /// + /// The tool version the caller detected. It selects at most one reviewed + /// version range; no other version evidence is inferred. + /// + public string? DetectedVersion { get; init; } + + /// + /// The intended operation, for example fetch or push. When + /// omitted, the base plus every intent of the effective entry applies. + /// + public string? Intent { get; init; } + + /// String shorthand for new ToolCandidate(name). + public static implicit operator ToolCandidate(string invocationName) => new(invocationName); +} + +/// PROTOTYPE, pending API review. Lookup context shared by every input. +public sealed record ResolveContext +{ + /// Substituted for the project_root catalog symbol. + public string? ProjectRoot { get; init; } + + /// + /// Catalog symbol values, for example git_prefix. They take + /// precedence over host values, discovery, and contract defaults. + /// + public IReadOnlyDictionary? Symbols { get; init; } + + /// A value; defaults to the host platform. + public string? Platform { get; init; } + + /// A value; defaults to the host architecture. + public string? Architecture { get; init; } + + /// Defaults to the bundled catalog's default revision. + public string? CatalogRevision { get; init; } + + /// Allow entries matched only by invocation name. Off by default. + public bool AllowWeakIdentityFallback { get; init; } +} + +/// A satisfied identity predicate. +/// purl or invocation-name. +/// strong or weak. +public sealed record MatchedIdentity(string Kind, string Strength); + +/// How a detected version selected the entry's version data. +/// +/// matched_default, matched_version, version_out_of_range, +/// or version_unparseable. +/// +public sealed record VersionSelection(string Status) +{ + /// The caller's detected version, when supplied. + public string? DetectedVersion { get; init; } + + /// The selected vers range; only for matched_version. + public string? SelectedVersionRange { get; init; } +} + +/// +/// Which intents were selected. A dependency reports none (base only) +/// unless its reference names intents, which report named. +/// +/// named, all, none, or unsupported. +/// Selected intent names, sorted. +public sealed record IntentSelection(string Mode, IReadOnlyList Selected) +{ + /// The requested intent, when one was supplied. + public string? Requested { get; init; } +} + +/// +/// Metadata about the catalog entry one input selected. It is retained when a +/// version or intent failure stops the input contributing. +/// +public sealed record ToolSelection( + string EntryId, + int EntryRevision, + IReadOnlyList MatchedIdentities, + VersionSelection VersionSelection, + IntentSelection? IntentSelection = null); + +/// Per-input attribution, in input order. +/// The input's position. +/// +/// The pair's version status, or intent_unsupported, +/// tool_unmatched, or filesystem_identity_unresolved. +/// +/// +/// Whether this input's complete resolved requirements, including its selected +/// dependencies, are in the returned requirements. Not unique access, +/// authorization, or an execution guarantee. Required in the native result. +/// +/// +/// The selected entry; when no entry was selected. Its +/// presence alone never means the input contributes. +/// +public sealed record ToolDiagnostics( + int InputIndex, + [property: JsonRequired] bool Contributes, + string Status, + ToolSelection? Selection = null); + +/// A dependency pulled in by one or more inputs. +/// The dependency entry. +/// Its entry revision. +/// Sorted, distinct indexes of every requiring input. +/// Always matched_default. +/// The selected dependency intents. +public sealed record ResolvedDependency( + string EntryId, + int EntryRevision, + IReadOnlyList InputIndexes, + VersionSelection VersionSelection, + IntentSelection IntentSelection) +{ + /// The dependency's declared vers range, when one is declared. + public string? RequiredVersionRange { get; init; } +} + +/// A resolved path, its access class, and its source entries. +/// The resolved path. +/// denied, readonly, or readwrite. +/// Contributing entries. +public sealed record PathRequirement(string Path, string Access, IReadOnlyList EntryIds); + +/// A complete egress rule and its source entries. +public sealed record NetworkRequirement(NetworkRulePolicy Rule, IReadOnlyList EntryIds); + +/// +/// A structured diagnostics warning. selects the category; +/// is human-readable, not a parsing contract. +/// +[JsonConverter(typeof(ResolutionWarningJsonConverter))] +public abstract record ResolutionWarning(string Code, string Message); + +/// +/// A warning about one input: version_out_of_range, +/// version_unparseable, intent_unsupported, +/// tool_unmatched, purl_invalid, +/// purl_components_ignored, or weak_identity. +/// +public sealed record ToolResolutionWarning(string Code, int InputIndex, string Message) + : ResolutionWarning(Code, Message) +{ + /// The matched entry, for codes that have one. + public string? EntryId { get; init; } + + /// The detected version, for the version codes. + public string? DetectedVersion { get; init; } + + /// The requested intent, for intent_unsupported. + public string? Intent { get; init; } + + /// The invocation name, for tool_unmatched and weak_identity. + public string? InvocationName { get; init; } + + /// The package URL, for the PURL codes. + public string? PackageUrl { get; init; } + + /// + /// version, qualifiers, and/or subpath, for + /// purl_components_ignored. + /// + public IReadOnlyList? IgnoredComponents { get; init; } +} + +/// +/// A warning about shared resolution: architecture_default, +/// architecture_fallback, symbol_resolved, +/// symbol_unresolved, filesystem_case_assumed, +/// filesystem_identity_unresolved, readonly_superseded, +/// filesystem_deny_removed, or network_deny_removed. +/// +public record ResolutionDetailWarning( + string Code, + IReadOnlyList InputIndexes, + IReadOnlyList EntryIds, + string Message) + : ResolutionWarning(Code, Message) +{ + /// The target platform, for the architecture and identity codes. + public string? Platform { get; init; } + + /// The target architecture, for the architecture codes. + public string? Architecture { get; init; } + + /// platform or default, for architecture_fallback. + public string? Selected { get; init; } + + /// The catalog symbol, for the symbol codes. + public string? Symbol { get; init; } + + /// The resolved value, for symbol_resolved. + public string? Value { get; init; } + + /// + /// caller, discovery, host, or default, for + /// symbol_resolved. + /// + public string? Source { get; init; } + + /// The affected paths, for the filesystem case and identity codes. + public IReadOnlyList? Paths { get; init; } + + /// case_sensitive, for filesystem_case_assumed. + public string? Comparison { get; init; } +} + +/// +/// readonly_superseded or filesystem_deny_removed: the complete +/// original path requirement removed and the requirements that needed it gone. +/// +public sealed record PathAdjustmentWarning( + string Code, + IReadOnlyList InputIndexes, + IReadOnlyList EntryIds, + string Message, + PathRequirement Removed, + IReadOnlyList RequiredBy) + : ResolutionDetailWarning(Code, InputIndexes, EntryIds, Message); + +/// +/// network_deny_removed: the complete catalog egress deny removed and +/// the requested rules that overlapped it. Other grants may now apply +/// throughout its whole scope. +/// +public sealed record NetworkDenyRemovedWarning( + IReadOnlyList InputIndexes, + IReadOnlyList EntryIds, + string Message, + NetworkRequirement Removed, + IReadOnlyList RequiredBy) + : ResolutionDetailWarning("network_deny_removed", InputIndexes, EntryIds, Message); + +/// Match attribution and warnings from one resolution pass. +public sealed record ToolRequirementsDiagnostics( + string CatalogRevision, + IReadOnlyList Tools, + IReadOnlyList ResolvedDependencies, + IReadOnlyList Warnings); + +/// +/// PROTOTYPE, pending API review. Result of +/// . +/// +/// The composed requirements, or when none resolved. +/// Attribution and warnings from the same pass. +public sealed record ToolRequirementsResolution( + ContainerRequirements? Requirements, + ToolRequirementsDiagnostics Diagnostics); + +/// The bundled catalog's schema version, default revision, and SDK contract version. +public sealed record CatalogInfo( + string CatalogSchemaVersion, + string CatalogRevision, + string SdkContractVersion); + +/// One identity predicate of a catalog entry. +/// purl or invocation-name. +public sealed record CatalogIdentityMetadata(string Kind) +{ + /// The package URL, for purl. + public string? Value { get; init; } + + /// The names, for invocation-name. + public IReadOnlyList? Names { get; init; } +} + +/// An intent an entry or overlay defines or extends. +public sealed record CatalogIntentMetadata(string Name, IReadOnlyList DependencyEntryIds) +{ + /// Example subcommands, when the catalog lists them. + public IReadOnlyList? ExampleSubcommands { get; init; } +} + +/// The entry's unversioned default. +public sealed record CatalogDefaultMetadata( + IReadOnlyList DependencyEntryIds, + IReadOnlyList Intents); + +/// An additive platform overlay. +/// A value. +/// A value, or for every architecture. +/// Added dependencies. +/// Extensions of intents the default declares. +/// Intents the overlay introduces. +public sealed record CatalogPlatformVariantMetadata( + string Platform, + string? Architecture, + IReadOnlyList DependencyEntryIds, + IReadOnlyList IntentAdditions, + IReadOnlyList NewIntents); + +/// An additive version overlay, selected by a purl vers range. +/// The vers range. +/// Added dependencies. +/// Extensions of intents the default declares. +/// Intents the overlay introduces. +public sealed record CatalogVersionVariantMetadata( + string VersionRange, + IReadOnlyList DependencyEntryIds, + IReadOnlyList IntentAdditions, + IReadOnlyList NewIntents); + +/// Entry provenance. +public sealed record CatalogProvenance(string Method, string SourceRevision); + +/// Inspection metadata for one catalog entry. It never exposes a requirements body. +public sealed record CatalogEntryMetadata( + string CatalogRevision, + string EntryId, + int EntryRevision, + string DisplayName, + string VersionScheme, + IReadOnlyList Identity, + CatalogDefaultMetadata Default, + IReadOnlyList PlatformVariants, + IReadOnlyList VersionVariants, + CatalogProvenance Provenance); + +/// +/// Reads the structured warning union by code. Unknown codes or fields +/// are rejected rather than dropped. +/// +internal sealed class ResolutionWarningJsonConverter : JsonConverter +{ + private static readonly HashSet ToolCodes = new(StringComparer.Ordinal) + { + "version_out_of_range", "version_unparseable", "intent_unsupported", "tool_unmatched", + "purl_invalid", "purl_components_ignored", "weak_identity", + }; + + private static readonly HashSet DetailCodes = new(StringComparer.Ordinal) + { + "architecture_default", "architecture_fallback", "symbol_resolved", "symbol_unresolved", + "filesystem_case_assumed", "filesystem_identity_unresolved", + }; + + public override ResolutionWarning Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new JsonException("A warning must be an object."); + } + + var code = RequiredString(root, "code"); + var message = RequiredString(root, "message"); + if (ToolCodes.Contains(code)) + { + Only(root, "code", "inputIndex", "message", "entryId", "detectedVersion", "intent", + "invocationName", "packageUrl", "ignoredComponents"); + return new ToolResolutionWarning(code, Required(root, "inputIndex").GetInt32(), message) + { + EntryId = OptionalString(root, "entryId"), + DetectedVersion = OptionalString(root, "detectedVersion"), + Intent = OptionalString(root, "intent"), + InvocationName = OptionalString(root, "invocationName"), + PackageUrl = OptionalString(root, "packageUrl"), + IgnoredComponents = OptionalStrings(root, "ignoredComponents"), + }; + } + + var inputIndexes = Required(root, "inputIndexes").EnumerateArray().Select(i => i.GetInt32()).ToArray(); + var entryIds = RequiredStrings(root, "entryIds"); + switch (code) + { + case "readonly_superseded": + case "filesystem_deny_removed": + Only(root, "code", "inputIndexes", "entryIds", "message", "removed", "requiredBy"); + return new PathAdjustmentWarning( + code, + inputIndexes, + entryIds, + message, + ReadPath(Required(root, "removed")), + Required(root, "requiredBy").EnumerateArray().Select(ReadPath).ToArray()); + case "network_deny_removed": + Only(root, "code", "inputIndexes", "entryIds", "message", "removed", "requiredBy"); + return new NetworkDenyRemovedWarning( + inputIndexes, + entryIds, + message, + ReadRule(Required(root, "removed"), options), + Required(root, "requiredBy").EnumerateArray().Select(r => ReadRule(r, options)).ToArray()); + } + + if (!DetailCodes.Contains(code)) + { + throw new JsonException($"Unknown warning code '{code}'."); + } + + Only(root, "code", "inputIndexes", "entryIds", "message", "platform", "architecture", + "selected", "symbol", "value", "source", "paths", "comparison"); + return new ResolutionDetailWarning(code, inputIndexes, entryIds, message) + { + Platform = OptionalString(root, "platform"), + Architecture = OptionalString(root, "architecture"), + Selected = OptionalString(root, "selected"), + Symbol = OptionalString(root, "symbol"), + Value = OptionalString(root, "value"), + Source = OptionalString(root, "source"), + Paths = OptionalStrings(root, "paths"), + Comparison = OptionalString(root, "comparison"), + }; + } + + public override void Write( + Utf8JsonWriter writer, + ResolutionWarning value, + JsonSerializerOptions options) => + throw new NotSupportedException("Resolution warnings are read-only results."); + + private static PathRequirement ReadPath(JsonElement element) + { + Only(element, "path", "access", "entryIds"); + return new PathRequirement( + RequiredString(element, "path"), + RequiredString(element, "access"), + RequiredStrings(element, "entryIds")); + } + + private static NetworkRequirement ReadRule(JsonElement element, JsonSerializerOptions options) + { + Only(element, "rule", "entryIds"); + var rule = Required(element, "rule").Deserialize(MxcJson.TypeInfo(options)) + ?? throw new JsonException("A network requirement needs a rule."); + return new NetworkRequirement(rule, RequiredStrings(element, "entryIds")); + } + + private static void Only(JsonElement element, params string[] allowed) + { + foreach (var property in element.EnumerateObject()) + { + if (Array.IndexOf(allowed, property.Name) < 0) + { + throw new JsonException($"Unexpected warning field '{property.Name}'."); + } + } + } + + private static JsonElement Required(JsonElement element, string name) => + element.TryGetProperty(name, out var value) + ? value + : throw new JsonException($"A warning is missing '{name}'."); + + private static string RequiredString(JsonElement element, string name) => + Required(element, name).GetString() ?? throw new JsonException($"'{name}' must be a string."); + + private static string[] RequiredStrings(JsonElement element, string name) => + Required(element, name).EnumerateArray() + .Select(item => item.GetString() ?? throw new JsonException($"'{name}' must hold strings.")) + .ToArray(); + + private static string? OptionalString(JsonElement element, string name) => + element.TryGetProperty(name, out var value) ? value.GetString() : null; + + private static string[]? OptionalStrings(JsonElement element, string name) => + element.TryGetProperty(name, out _) ? RequiredStrings(element, name) : null; +} diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 44f7ad0be..b67f8b1c1 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -199,6 +199,36 @@ result. They accept the corresponding operation options. Backend/platform discovery, errors, telemetry, and helpers are also in `Microsoft.Mxc.Sdk.V1`; no public SDK types remain outside it. +## Tool requirements lookup (prototype) + +> **PROTOTYPE, pending API review.** Not part of MXC 1.0; names and shapes +> may change. The result is a best-effort floor from the catalog bundled in +> the SDK, not a guarantee, and it never grants access by itself. See +> [`docs/policy-store/`](../../docs/policy-store/README.md). + +```csharp +using Microsoft.Mxc.Sdk.V1; + +var resolution = await MxcContainer.ResolveToolRequirementsWithDiagnosticsAsync( + new ToolCandidate("git") { PackageUrl = "pkg:generic/git", DetectedVersion = "2.45.1", Intent = "push" }, + new ResolveContext + { + ProjectRoot = @"D:\work\repo", + Symbols = new Dictionary { ["git_prefix"] = @"D:\tools\git", ["ssh_prefix"] = @"D:\tools\ssh" }, + }); +Console.WriteLine(resolution.Diagnostics.Tools[0].Status); // matched_version +if (resolution.Requirements is { } requirements) +{ + var request = ContainerRequest.FromRequirements(requirements, "git push"); +} +``` + +`ResolveToolRequirements` and `ResolveToolRequirementsWithDiagnostics` +accept one tool or a list, with `Async` forms. `GetCatalogInfo` and +`ListCatalogEntries` inspect the bundled catalog. Failures throw +`MxcException`; `MxcException.Reason` carries a stable reason such as +`invalid_context` and is `null` for errors outside the policy store. + ## Public V1 types | Purpose | .NET type | diff --git a/sdk/node/README.md b/sdk/node/README.md index b3e7e9b21..bef7d9352 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -188,6 +188,35 @@ the SDK does not create runtime containment objects or factories. `UiPolicy` types for cross-backend restrictions. Backend-specific settings remain on the selected containment configuration. +## Tool requirements lookup (prototype) + +> **PROTOTYPE, pending API review.** Not part of MXC 1.0; names and shapes +> may change. The result is a best-effort floor from the catalog bundled in +> the SDK, not a guarantee, and it never grants access by itself. See +> [`docs/policy-store/`](../../docs/policy-store/README.md). + +```ts +import { + resolveToolRequirementsWithDiagnostics, + type ContainerRequest, +} from '@microsoft/mxc-sdk/v1'; + +const { requirements, diagnostics } = await resolveToolRequirementsWithDiagnostics( + { invocationName: 'git', packageUrl: 'pkg:generic/git', detectedVersion: '2.45.1', intent: 'push' }, + { projectRoot: '/work/repo', symbols: { git_prefix: '/usr', ssh_prefix: '/usr' } }, +); +console.log(diagnostics.tools[0].status); // 'matched_version' +if (requirements) { + const request: ContainerRequest = { ...requirements, command: 'git push' }; +} +``` + +`resolveToolRequirements` and `resolveToolRequirementsWithDiagnostics` +return promises and accept one tool or an array. `getCatalogInfo` and +`listCatalogEntries` inspect the bundled catalog. Failures reject with +`MxcError`, whose `details.reason` carries a stable reason such as +`invalid_context`. + ## Public V1 types | Purpose | TypeScript type | diff --git a/sdk/node/package.json b/sdk/node/package.json index a2cd260bc..9704d02e6 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -36,7 +36,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/v1-sandbox-policy.test.js dist-tests/tests/unit/sdk-v1-conformance.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/state-aware-binding.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/probe.test.js dist-tests/tests/unit/native-library.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/pty-binding.test.js dist-tests/tests/unit/process-container-pty.test.js dist-tests/tests/unit/pty-options.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/sandbox-process.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/telemetry-request-worker.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/v1-sandbox-policy.test.js dist-tests/tests/unit/sdk-v1-conformance.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/policy-store.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/state-aware-binding.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/probe.test.js dist-tests/tests/unit/native-library.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/pty-binding.test.js dist-tests/tests/unit/process-container-pty.test.js dist-tests/tests/unit/pty-options.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/sandbox-process.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/telemetry-request-worker.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build", "typecheck:integration": "node tests/integration/check-package-build.mjs", diff --git a/sdk/node/src/bindings/policy-store.ts b/sdk/node/src/bindings/policy-store.ts new file mode 100644 index 000000000..808d24a39 --- /dev/null +++ b/sdk/node/src/bindings/policy-store.ts @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Koffi wrappers for the prototype policy store entry points in mxc_ffi. +// Resolution runs on Koffi's worker pool, because it examines host +// filesystem objects; metadata inspection is synchronous. + +import koffi from 'koffi'; +import { MxcError } from '../v1/errors.js'; +import { loadMxcFfi } from '../native-library.js'; +import { bindNativeFunction } from './native-function.js'; +import { + AbiErrorDetailType, + decodeString, + nativeStatusError, + type AbiErrorDetail, +} from './native-error.js'; + +interface AbiPolicyStoreResult { + status: number; + json: unknown | null; + reason: unknown | null; + error: AbiErrorDetail; +} + +const AbiPolicyStoreResultType = koffi.struct('MxcNodePolicyStoreResult', { + status: 'int32_t', + json: 'void *', + reason: 'void *', + error: AbiErrorDetailType, +}); + +type RequestFunction = (request: string, result: AbiPolicyStoreResult) => number; +type InspectFunction = (result: AbiPolicyStoreResult) => number; +type FreeFunction = (result: AbiPolicyStoreResult) => void; + +/** The `mxc_ffi` policy store symbols that take a request document. */ +export type PolicyStoreRequestSymbol = + | 'mxc_resolve_tool_requirements_json' + | 'mxc_resolve_tool_requirements_with_diagnostics_json'; + +/** The `mxc_ffi` policy store symbols that take no input. */ +export type PolicyStoreInspectSymbol = + | 'mxc_policy_catalog_info_json' + | 'mxc_list_policy_catalog_entries_json'; + +function decode(status: number, result: AbiPolicyStoreResult): unknown { + if (status !== 0 || result.status !== 0) { + const error = nativeStatusError(result.status || status, result.error); + const reason = decodeString(result.reason); + if (reason === undefined) throw error; + throw new MxcError({ + code: error.code, + message: error.message, + operation: error.operation, + nativeCode: error.nativeCode, + remediation: error.remediation, + details: { ...error.details, reason }, + }); + } + const json = decodeString(result.json); + if (json === undefined) { + throw new MxcError('backend_error', 'native policy store returned no result'); + } + return JSON.parse(json); +} + +function bindFree(native: ReturnType) { + return bindNativeFunction(native.handle, { + symbol: 'mxc_policy_store_result_free', + result: 'void', + parameters: [koffi.pointer(AbiPolicyStoreResultType)], + }); +} + +function bindRequest(native: ReturnType, symbol: PolicyStoreRequestSymbol) { + return bindNativeFunction(native.handle, { + symbol, + result: 'int32_t', + parameters: ['const char *', koffi.out(koffi.pointer(AbiPolicyStoreResultType))], + }); +} + +/** Calls a request-taking policy store entry point off the event loop. */ +export async function callPolicyStoreAsync( + symbol: PolicyStoreRequestSymbol, + request: unknown, +): Promise { + const requestJson = JSON.stringify(request); + const native = loadMxcFfi(); + try { + const free = bindFree(native); + const call = bindRequest(native, symbol); + const result = {} as AbiPolicyStoreResult; + let filled = false; + try { + const status = await new Promise((resolve, reject) => { + call.async(requestJson, result, (error, nativeStatus) => { + if (error !== null) { + reject(error); + return; + } + filled = true; + resolve(nativeStatus); + }); + }); + return decode(status, result); + } finally { + if (filled) free(result); + } + } catch (error) { + if (error instanceof MxcError) throw error; + throw new MxcError('backend_error', error instanceof Error ? error.message : String(error)); + } finally { + native.handle.unload(); + } +} + +/** Calls an input-free policy store entry point and parses its JSON. */ +export function inspectPolicyStore(symbol: PolicyStoreInspectSymbol): unknown { + const native = loadMxcFfi(); + try { + const free = bindFree(native); + const result = {} as AbiPolicyStoreResult; + let filled = false; + try { + const status = bindNativeFunction(native.handle, { + symbol, + result: 'int32_t', + parameters: [koffi.out(koffi.pointer(AbiPolicyStoreResultType))], + })(result); + filled = true; + return decode(status, result); + } finally { + if (filled) free(result); + } + } finally { + native.handle.unload(); + } +} diff --git a/sdk/node/src/v1/index.ts b/sdk/node/src/v1/index.ts index ba6dfa167..38e9226b9 100644 --- a/sdk/node/src/v1/index.ts +++ b/sdk/node/src/v1/index.ts @@ -66,6 +66,42 @@ export type { MxcPtySize } from './mxc-pty-process.js'; export * as policy from './policy/index.js'; +// PROTOTYPE, pending API review: the policy store's tool-requirements lookup. +export { + resolveToolRequirements, + resolveToolRequirementsWithDiagnostics, + getCatalogInfo, + listCatalogEntries, +} from './tool-requirements.js'; +export type { + ContainerRequirements, + CatalogPlatform, + CatalogArchitecture, + PlatformVariantSelector, + EgressRule, + ToolCandidate, + ToolInput, + ResolveContext, + VersionStatus, + ToolResolutionStatus, + VersionSelection, + IntentSelection, + InputWarning, + ToolResolutionWarning, + WarningScope, + PathRequirement, + NetworkRequirement, + ResolutionDetailWarning, + PolicyResolutionWarning, + ToolRequirementsDiagnostics, + ToolRequirementsResolution, + CatalogIdentityMetadata, + CatalogIntentMetadata, + CatalogAdditionsMetadata, + CatalogEntryMetadata, + CatalogInfo, +} from './tool-requirements.js'; + export { SDK_CONTRACT_VERSION, LifecycleContainmentKind, diff --git a/sdk/node/src/v1/tool-requirements.ts b/sdk/node/src/v1/tool-requirements.ts new file mode 100644 index 000000000..13f8a19a6 --- /dev/null +++ b/sdk/node/src/v1/tool-requirements.ts @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * **PROTOTYPE, pending API review.** The MXC policy store: resolve known tools + * to command-free {@link ContainerRequirements} from the catalog bundled + * statically in this SDK's native library. + * + * The result is a best-effort floor, not a guarantee: the access a known tool + * typically needs, which a caller reviews, constrains, and combines with its + * own command (`{ ...requirements, command }`) to build a + * {@link ContainerRequest}. It is complementary to Learning Mode, not a + * replacement. Resolution never grants access, launches a container, contacts + * a network service, or writes state; the catalog is compiled into `mxc_ffi` + * and nothing is downloaded. + * + * Each catalog entry has one unversioned default plus additive platform, + * version (purl `vers` range), and intent overlays. A {@link ToolCandidate} + * may carry a `detectedVersion` and an `intent` (for example `fetch` + * versus `push` for git); each tool and intent pair resolves independently, + * and the pairs compose into one set of requirements. + * + * Names and shapes are proposed and may change before sign-off. This API is + * not part of MXC 1.0. Resolution returns a Promise because it examines host + * filesystem objects; metadata inspection is synchronous. Every function runs + * in-process through `mxc_ffi`. + * + * @module + */ + +import { callPolicyStoreAsync, inspectPolicyStore } from '../bindings/policy-store.js'; +import { MxcError } from './errors.js'; +import type { ContainerRequest, NetworkRuleConfig } from './types.js'; + +/** The access fields of a {@link ContainerRequest}, without a command. */ +export type ContainerRequirements = Pick< + ContainerRequest, + 'filesystem' | 'network' | 'ui' | 'timeoutMs' +>; + +/** Catalog platform selector values. */ +export type CatalogPlatform = 'windows' | 'linux' | 'macos'; + +/** Catalog architecture selector values. */ +export type CatalogArchitecture = 'x64' | 'arm64'; + +/** The closed selector of a catalog platform variant. */ +export interface PlatformVariantSelector { + platform: CatalogPlatform; + architecture?: CatalogArchitecture; +} + +/** A tool to look up, identified by invocation name and optional identity. */ +export interface ToolCandidate { + /** + * The command name, for example `git`. A weak identity, used only when + * `allowWeakIdentityFallback` is set. Matching is case-insensitive on + * Windows and macOS and exact on Linux. + */ + invocationName: string; + /** + * A Package URL, for example `pkg:npm/npm`. A strong identity matched on + * type, namespace, and name; a version, qualifiers, or subpath are ignored + * with a `purl_components_ignored` warning. An invalid PURL makes the pair + * `tool_unmatched` with a `purl_invalid` warning. + */ + packageUrl?: string; + /** + * The tool version the caller detected. It selects at most one reviewed + * version range; no other version evidence is inferred. + */ + detectedVersion?: string; + /** + * The intended operation, for example `fetch` or `push`. When omitted, + * the base plus every intent of the effective entry applies. An intent the + * effective entry does not define contributes nothing. + */ + intent?: string; +} + +/** A string is shorthand for `{ invocationName: tool }`. */ +export type ToolInput = string | ToolCandidate; + +/** Lookup context shared by every input in one lookup. */ +export interface ResolveContext { + /** Substituted for the `project_root` catalog symbol. */ + projectRoot?: string; + /** + * Catalog symbol values, for example `git_prefix`. They take precedence + * over host values, discovery, and contract defaults. + */ + symbols?: Record; + /** Defaults to the current host platform. */ + platform?: CatalogPlatform; + /** Defaults to the current host architecture. */ + architecture?: CatalogArchitecture; + /** Defaults to the bundled catalog's default revision. */ + catalogRevision?: string; + /** Allow entries matched only by invocation name. Defaults to `false`. */ + allowWeakIdentityFallback?: boolean; +} + +/** How a detected version selected the entry's version data. */ +export type VersionStatus = + | 'matched_default' + | 'matched_version' + | 'version_out_of_range' + | 'version_unparseable'; + +/** Per-input status: the version status, or why the pair contributes nothing. */ +export type ToolResolutionStatus = + | VersionStatus + | 'intent_unsupported' + | 'tool_unmatched' + | 'filesystem_identity_unresolved'; + +/** Version selection for one matched entry or dependency. */ +export interface VersionSelection { + status: VersionStatus; + detectedVersion?: string; + /** Present only for `matched_version`. */ + selectedVersionRange?: string; +} + +/** + * Intent selection for one matched entry or resolved dependency. A + * dependency reports `none` (base only) unless its reference names intents, + * which report `named`. + */ +export interface IntentSelection { + requested?: string; + mode: 'named' | 'all' | 'none' | 'unsupported'; + /** Selected intent names, sorted; empty when unsupported or none. */ + selected: string[]; +} + +/** Fields every per-input warning carries. */ +export interface InputWarning { + inputIndex: number; + /** Human-readable; not a parsing contract. */ + message: string; +} + +/** A structured warning about one input; `code` selects its fields. */ +export type ToolResolutionWarning = InputWarning & + ( + | { + code: 'version_out_of_range' | 'version_unparseable'; + entryId: string; + detectedVersion: string; + } + | { code: 'intent_unsupported'; entryId: string; intent: string } + | { code: 'tool_unmatched'; invocationName: string } + | { code: 'purl_invalid'; packageUrl: string } + | { + code: 'purl_components_ignored'; + packageUrl: string; + ignoredComponents: Array<'version' | 'qualifiers' | 'subpath'>; + } + | { code: 'weak_identity'; entryId: string; invocationName: string } + ); + +/** Fields every shared-contribution warning carries. */ +export interface WarningScope { + /** Sorted, distinct contributing input indexes. */ + inputIndexes: number[]; + /** Sorted, distinct contributing entry IDs. */ + entryIds: string[]; + /** Human-readable; not a parsing contract. */ + message: string; +} + +/** A resolved path with its access class and source entries. */ +export interface PathRequirement { + path: string; + access: 'denied' | 'readonly' | 'readwrite'; + entryIds: string[]; +} + +/** An outbound rule the composed requirements carry. */ +export type EgressRule = NetworkRuleConfig; + +/** An egress rule with its source entries. */ +export interface NetworkRequirement { + rule: EgressRule; + entryIds: string[]; +} + +/** A structured warning about shared contributions; `code` selects its fields. */ +export type ResolutionDetailWarning = WarningScope & + ( + | { code: 'architecture_default'; platform: CatalogPlatform; architecture: CatalogArchitecture } + | { + code: 'architecture_fallback'; + platform: CatalogPlatform; + architecture: CatalogArchitecture; + selected: 'platform' | 'default'; + } + | { + code: 'symbol_resolved'; + symbol: string; + value: string; + source: 'caller' | 'discovery' | 'host' | 'default'; + } + | { code: 'symbol_unresolved'; symbol: string } + | { code: 'filesystem_case_assumed'; paths: string[]; comparison: 'case_sensitive' } + | { code: 'filesystem_identity_unresolved'; paths: string[]; platform: CatalogPlatform } + | { code: 'readonly_superseded'; removed: PathRequirement; requiredBy: PathRequirement[] } + | { code: 'filesystem_deny_removed'; removed: PathRequirement; requiredBy: PathRequirement[] } + | { + code: 'network_deny_removed'; + removed: NetworkRequirement; + requiredBy: NetworkRequirement[]; + } + ); + +/** Any diagnostics warning. */ +export type PolicyResolutionWarning = ToolResolutionWarning | ResolutionDetailWarning; + +/** Match attribution and warnings from one resolution pass. */ +export interface ToolRequirementsDiagnostics { + catalogRevision: string; + tools: Array<{ + /** Position in the original input list, including repeated inputs. */ + inputIndex: number; + /** + * Whether this input's complete resolved requirements, including its + * selected dependencies, are in the returned requirements. Not unique + * access, authorization, or an execution guarantee. + */ + contributes: boolean; + status: ToolResolutionStatus; + /** + * The selected entry; absent when no entry was selected. It survives a + * version or intent failure, so its presence alone never means the input + * contributes. + */ + selection?: { + entryId: string; + entryRevision: number; + matchedIdentities: Array<{ kind: string; strength: 'strong' | 'weak' }>; + versionSelection: VersionSelection; + /** Absent when the version could not be parsed. */ + intentSelection?: IntentSelection; + }; + }>; + resolvedDependencies: Array<{ + entryId: string; + entryRevision: number; + /** Sorted, distinct indexes of every input that required this record. */ + inputIndexes: number[]; + requiredVersionRange?: string; + versionSelection: VersionSelection; + intentSelection: IntentSelection; + }>; + warnings: PolicyResolutionWarning[]; +} + +/** Result of {@link resolveToolRequirementsWithDiagnostics}. */ +export interface ToolRequirementsResolution { + /** The composed requirements, or `undefined` when none were resolved. */ + requirements: ContainerRequirements | undefined; + diagnostics: ToolRequirementsDiagnostics; +} + +/** One identity predicate of a catalog entry. */ +export type CatalogIdentityMetadata = + | { kind: 'purl'; value: string } + | { kind: 'invocation-name'; names: string[] }; + +/** An intent an entry or overlay defines or extends. */ +export interface CatalogIntentMetadata { + name: string; + exampleSubcommands?: string[]; + dependencyEntryIds: string[]; +} + +/** What an overlay adds. Overlays are additive only. */ +export interface CatalogAdditionsMetadata { + dependencyEntryIds: string[]; + /** Extensions of intents the default declares. */ + intentAdditions: CatalogIntentMetadata[]; + /** Intents the overlay introduces. */ + newIntents: CatalogIntentMetadata[]; +} + +/** Inspection metadata for one catalog entry. It never exposes a requirements body. */ +export interface CatalogEntryMetadata { + catalogRevision: string; + entryId: string; + entryRevision: number; + displayName: string; + versionScheme: 'npm' | 'semver' | 'pypi' | 'nuget' | 'intdot'; + identity: CatalogIdentityMetadata[]; + default: { + dependencyEntryIds: string[]; + intents: CatalogIntentMetadata[]; + }; + platformVariants: Array< + CatalogAdditionsMetadata & PlatformVariantSelector + >; + versionVariants: Array; + provenance: { method: string; sourceRevision: string }; +} + +/** The bundled catalog's schema version, default revision, and SDK contract version. */ +export interface CatalogInfo { + catalogSchemaVersion: string; + catalogRevision: string; + sdkContractVersion: string; +} + +/** + * **PROTOTYPE, pending API review.** Resolves one tool or a list of tools to + * composed, command-free container requirements from the bundled catalog. + * The result may cover only some of the requested tools; use + * {@link resolveToolRequirementsWithDiagnostics} to see which contributed. + * + * @returns The requirements, or `undefined` when none were resolved. + * @throws {MxcError} with the optional stable reason in `details.reason`: + * `malformed_request` (`invalid_context`), `unsupported_containment` + * (`unsupported_host`), `policy_validation` (`invalid_catalog`, + * `ambiguous_match`, `composition_conflict`), or `backend_error` + * (`integrity`, `revision_unavailable`). + */ +export function resolveToolRequirements( + tool: ToolInput, + context?: ResolveContext, +): Promise; +export function resolveToolRequirements( + tools: readonly ToolInput[], + context?: ResolveContext, +): Promise; +export async function resolveToolRequirements( + tools: ToolInput | readonly ToolInput[], + context: ResolveContext = {}, +): Promise { + const result = (await callPolicyStoreAsync('mxc_resolve_tool_requirements_json', { + tools, + context, + })) as { requirements?: ContainerRequirements }; + return result.requirements; +} + +/** + * **PROTOTYPE, pending API review.** Like {@link resolveToolRequirements}, + * and also reports per-input statuses, the catalog entries and dependencies + * that contributed, and structured warnings from the same pass. + */ +export function resolveToolRequirementsWithDiagnostics( + tool: ToolInput, + context?: ResolveContext, +): Promise; +export function resolveToolRequirementsWithDiagnostics( + tools: readonly ToolInput[], + context?: ResolveContext, +): Promise; +export async function resolveToolRequirementsWithDiagnostics( + tools: ToolInput | readonly ToolInput[], + context: ResolveContext = {}, +): Promise { + const result = (await callPolicyStoreAsync( + 'mxc_resolve_tool_requirements_with_diagnostics_json', + { tools, context }, + )) as { requirements?: ContainerRequirements; diagnostics: ToolRequirementsDiagnostics }; + validateResolution(result); + return { requirements: result.requirements, diagnostics: result.diagnostics }; +} + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Validates a diagnostics result before it is exposed (API spec §4): a + * missing or malformed required field, including a missing or non-boolean + * `contributes`, is a library failure rather than a valid partial result. + * Unfamiliar status strings remain descriptive and are not rejected. + * + * @internal Exported for tests; not part of the `v1` index. + */ +export function validateResolution(result: unknown): void { + const fail = (problem: string): never => { + throw new MxcError( + 'backend_error', + `native policy store returned a malformed diagnostics result: ${problem}`, + ); + }; + if (!isObject(result)) fail('the result is not an object'); + const { requirements, diagnostics } = result as Record; + if (requirements !== undefined && !isObject(requirements)) { + fail('requirements is not an object'); + } + if (!isObject(diagnostics)) return fail('diagnostics is not an object'); + if (typeof diagnostics.catalogRevision !== 'string') fail('catalogRevision is not a string'); + if (!Array.isArray(diagnostics.tools)) return fail('tools is not an array'); + let anyContributes = false; + diagnostics.tools.forEach((tool: unknown, index: number) => { + const at = `tools[${index}]`; + if (!isObject(tool)) return fail(`${at} is not an object`); + if (tool.inputIndex !== index) fail(`${at}.inputIndex is not ${index}`); + if (typeof tool.contributes !== 'boolean') fail(`${at}.contributes is not a boolean`); + if (typeof tool.status !== 'string') fail(`${at}.status is not a string`); + const selection = tool.selection; + if (selection !== undefined) { + if ( + !isObject(selection) || + typeof selection.entryId !== 'string' || + typeof selection.entryRevision !== 'number' || + !Array.isArray(selection.matchedIdentities) || + !isObject(selection.versionSelection) || + (selection.intentSelection !== undefined && !isObject(selection.intentSelection)) + ) { + fail(`${at}.selection is malformed`); + } + } + anyContributes ||= tool.contributes === true; + }); + if (!Array.isArray(diagnostics.resolvedDependencies)) fail('resolvedDependencies is not an array'); + if ( + !Array.isArray(diagnostics.warnings) || + !diagnostics.warnings.every( + (w: unknown) => isObject(w) && typeof w.code === 'string' && typeof w.message === 'string', + ) + ) { + fail('warnings are malformed'); + } + if ((requirements !== undefined) !== anyContributes) { + fail('requirements must be present exactly when at least one input contributes'); + } +} + +/** + * **PROTOTYPE, pending API review.** The bundled catalog's schema version, + * default revision, and SDK contract version. + */ +export function getCatalogInfo(): CatalogInfo { + return inspectPolicyStore('mxc_policy_catalog_info_json') as CatalogInfo; +} + +/** + * **PROTOTYPE, pending API review.** Metadata for every entry in the bundled + * catalog's default revision. It never exposes a requirements body. + */ +export function listCatalogEntries(): CatalogEntryMetadata[] { + return inspectPolicyStore('mxc_list_policy_catalog_entries_json') as CatalogEntryMetadata[]; +} diff --git a/sdk/node/tests/unit/policy-store.test.ts b/sdk/node/tests/unit/policy-store.test.ts new file mode 100644 index 000000000..a268b66c8 --- /dev/null +++ b/sdk/node/tests/unit/policy-store.test.ts @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { MxcError } from '../../src/v1/errors.js'; +import { findMxcFfiLibrary } from '../../src/native-library.js'; +import type { ContainerRequest } from '../../src/v1/types.js'; +import { + getCatalogInfo, + listCatalogEntries, + resolveToolRequirements, + resolveToolRequirementsWithDiagnostics, + type ContainerRequirements, + type ResolveContext, + type ToolCandidate, +} from '../../src/v1/index.js'; +import { validateResolution } from '../../src/v1/tool-requirements.js'; + +// The policy store runs in-process through mxc_ffi. Skip, rather than fail, +// where the native library has not been built. +const skip = findMxcFfiLibrary() === null ? 'mxc_ffi is not built' : false; + +// Lookups target the current host, whose filesystem object identity is +// examined, so every symbol names an existing directory. +const root = mkdtempSync(join(tmpdir(), 'mxc-policy-store-')); +const dir = (...parts: string[]): string => { + const path = join(root, ...parts); + mkdirSync(path, { recursive: true }); + return path; +}; +const paths = { + project: dir('repo'), + node: dir('node'), + npm: dir('npm'), + npmCache: dir('npm-cache'), + git: dir('git'), + ssh: dir('ssh'), + temp: dir('temp'), + programData: dir('pd'), +}; +dir('pd', 'Git'); + +const context: ResolveContext = { + projectRoot: paths.project, + symbols: { + node_prefix: paths.node, + npm_prefix: paths.npm, + npm_cache: paths.npmCache, + git_prefix: paths.git, + ssh_prefix: paths.ssh, + temp_dir: paths.temp, + programData: paths.programData, + }, +}; + +const git = (detectedVersion?: string, intent?: string): ToolCandidate => ({ + invocationName: 'git', + packageUrl: 'pkg:generic/git', + ...(detectedVersion === undefined ? {} : { detectedVersion }), + ...(intent === undefined ? {} : { intent }), +}); + +describe('policy store (prototype)', { skip }, () => { + after(() => rmSync(root, { recursive: true, force: true })); + + it('reports the bundled catalog and SDK contract version', () => { + const info = getCatalogInfo(); + assert.strictEqual(info.catalogSchemaVersion, '1'); + assert.strictEqual(info.sdkContractVersion, '1.0.0'); + assert.match(info.catalogRevision, /\S/); + }); + + it('lists entry metadata without requirements bodies', () => { + const entries = listCatalogEntries(); + const ids = entries.map((entry) => entry.entryId).sort(); + assert.ok(ids.includes('tool:npm'), `entries: ${ids.join(', ')}`); + for (const entry of entries) { + assert.ok(!('requirements' in entry.default)); + } + const gitEntry = entries.find((entry) => entry.entryId === 'tool:git'); + assert.ok(gitEntry !== undefined); + assert.strictEqual(gitEntry.versionScheme, 'intdot'); + assert.deepStrictEqual( + gitEntry.default.intents.map((intent) => intent.name).sort(), + ['fetch', 'local', 'push'], + ); + assert.deepStrictEqual( + gitEntry.versionVariants.map((variant) => variant.versionRange), + ['vers:intdot/>=2.40|<2.50', 'vers:intdot/>=2.50|<3'], + ); + assert.deepStrictEqual( + gitEntry.versionVariants[1].newIntents.map((intent) => intent.name), + ['bundle-fetch'], + ); + }); + + it('resolves command-free requirements that combine with a command', async () => { + const pending = resolveToolRequirements( + { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }, + context, + ); + assert.ok(pending instanceof Promise); + const requirements = await pending; + assert.ok(requirements !== undefined); + assert.ok(!('command' in requirements)); + assert.ok(!('version' in requirements)); + assert.ok((requirements.filesystem?.readonlyPaths?.length ?? 0) > 0); + const approved: ContainerRequirements = requirements; + const request: ContainerRequest = { ...approved, command: 'npm --version' }; + assert.strictEqual(request.command, 'npm --version'); + }); + + it('returns undefined for an unknown tool', async () => { + assert.strictEqual(await resolveToolRequirements('no-such-tool', context), undefined); + }); + + it('requires the weak-identity opt-in for name-only matches', async () => { + const withoutOptIn = await resolveToolRequirementsWithDiagnostics('git', context); + assert.strictEqual(withoutOptIn.requirements, undefined); + + const withOptIn = await resolveToolRequirementsWithDiagnostics(['git'], { + ...context, + allowWeakIdentityFallback: true, + }); + assert.ok(withOptIn.requirements !== undefined); + const [tool] = withOptIn.diagnostics.tools; + assert.strictEqual(tool.inputIndex, 0); + assert.strictEqual(tool.contributes, true); + assert.strictEqual(tool.selection?.entryId, 'tool:git'); + assert.strictEqual(tool.selection?.matchedIdentities[0].strength, 'weak'); + const weak = withOptIn.diagnostics.warnings.find((w) => w.code === 'weak_identity'); + assert.ok(weak !== undefined && weak.code === 'weak_identity'); + assert.strictEqual(weak.invocationName, 'git'); + }); + + it('selects a version range and an intent, attributing dependencies', async () => { + const push = await resolveToolRequirementsWithDiagnostics(git('2.45.1', 'push'), context); + const [tool] = push.diagnostics.tools; + assert.strictEqual(tool.status, 'matched_version'); + assert.strictEqual( + tool.selection?.versionSelection.selectedVersionRange, + 'vers:intdot/>=2.40|<2.50', + ); + assert.deepStrictEqual(tool.selection?.intentSelection, { + requested: 'push', + mode: 'named', + selected: ['push'], + }); + const [ssh] = push.diagnostics.resolvedDependencies; + assert.strictEqual(ssh.entryId, 'tool:ssh'); + assert.deepStrictEqual(ssh.inputIndexes, [0]); + assert.deepStrictEqual(ssh.intentSelection, { mode: 'none', selected: [] }); + assert.ok(push.requirements?.filesystem?.readonlyPaths?.includes(paths.ssh)); + assert.strictEqual(push.requirements?.network?.egress?.allow?.length, 1); + }); + + it('reports per-pair outcomes with structured warnings', async () => { + const outOfRange = await resolveToolRequirementsWithDiagnostics(git('2.30.0', 'fetch'), context); + assert.strictEqual(outOfRange.diagnostics.tools[0].status, 'version_out_of_range'); + assert.ok(outOfRange.requirements !== undefined); + const warning = outOfRange.diagnostics.warnings[0]; + assert.strictEqual(warning.code, 'version_out_of_range'); + assert.ok(warning.code === 'version_out_of_range'); + assert.strictEqual(warning.entryId, 'tool:git'); + assert.strictEqual(warning.detectedVersion, '2.30.0'); + + const unparseable = await resolveToolRequirementsWithDiagnostics(git('banana', 'fetch'), context); + assert.strictEqual(unparseable.diagnostics.tools[0].status, 'version_unparseable'); + assert.strictEqual(unparseable.requirements, undefined); + // Selection metadata survives the failure without implying contribution. + assert.strictEqual(unparseable.diagnostics.tools[0].contributes, false); + assert.strictEqual(unparseable.diagnostics.tools[0].selection?.entryId, 'tool:git'); + + const unsupported = await resolveToolRequirementsWithDiagnostics(git(undefined, 'bundle-fetch'), context); + assert.strictEqual(unsupported.diagnostics.tools[0].status, 'intent_unsupported'); + assert.strictEqual(unsupported.requirements, undefined); + + const bundle = await resolveToolRequirementsWithDiagnostics(git('2.55', 'bundle-fetch'), context); + assert.strictEqual(bundle.diagnostics.tools[0].status, 'matched_version'); + assert.deepStrictEqual(bundle.diagnostics.resolvedDependencies, []); + + const purl = await resolveToolRequirementsWithDiagnostics( + { invocationName: 'npm', packageUrl: 'not a purl' }, + context, + ); + assert.strictEqual(purl.diagnostics.tools[0].status, 'tool_unmatched'); + assert.strictEqual(purl.diagnostics.warnings[0].code, 'purl_invalid'); + + const ignored = await resolveToolRequirementsWithDiagnostics( + { invocationName: 'npm', packageUrl: 'pkg:npm/npm@10.0.0' }, + context, + ); + const first = ignored.diagnostics.warnings[0]; + assert.ok(first.code === 'purl_components_ignored'); + assert.deepStrictEqual(first.ignoredComponents, ['version']); + }); + + it('composes pairs: a tool without network does not veto another', async () => { + const resolution = await resolveToolRequirementsWithDiagnostics( + [git(undefined, 'local'), git(undefined, 'fetch'), 'no-such-tool'], + context, + ); + assert.strictEqual(resolution.requirements?.network?.egress?.allow?.length, 1); + assert.strictEqual(resolution.diagnostics.tools[2].status, 'tool_unmatched'); + assert.deepStrictEqual( + resolution.diagnostics.tools.map((tool) => tool.contributes), + [true, true, false], + ); + assert.strictEqual(resolution.diagnostics.tools[2].selection, undefined); + const unmatched = resolution.diagnostics.warnings.find((w) => w.code === 'tool_unmatched'); + assert.ok(unmatched !== undefined && unmatched.code === 'tool_unmatched'); + assert.strictEqual(unmatched.inputIndex, 2); + assert.strictEqual(unmatched.invocationName, 'no-such-tool'); + }); + + it('rejects with MxcError and a stable reason for an invalid context', async () => { + await assert.rejects( + resolveToolRequirements('npm', { ...context, platform: 'plan9' as never }), + (error: unknown) => { + assert.ok(error instanceof MxcError); + assert.strictEqual(error.code, 'malformed_request'); + assert.strictEqual(error.details?.reason, 'invalid_context'); + return true; + }, + ); + await assert.rejects( + resolveToolRequirements(git(undefined, ''), context), + (error: unknown) => error instanceof MxcError && error.details?.reason === 'invalid_context', + ); + }); + + it('binds project_root through either context form and rejects differing values', async () => { + const { projectRoot, ...rest } = context; + const viaSymbol = await resolveToolRequirements(git(undefined, 'local'), { + ...rest, + symbols: { ...rest.symbols, project_root: projectRoot ?? '' }, + }); + assert.deepStrictEqual(viaSymbol, await resolveToolRequirements(git(undefined, 'local'), context)); + await assert.rejects( + resolveToolRequirements([], { + ...context, + symbols: { ...context.symbols, project_root: `${paths.project}x` }, + }), + (error: unknown) => + error instanceof MxcError && + error.code === 'malformed_request' && + error.details?.reason === 'invalid_context', + ); + }); +}); + +describe('policy store diagnostics boundary validation (prototype)', () => { + const valid = () => ({ + requirements: {}, + diagnostics: { + catalogRevision: 'r', + tools: [{ inputIndex: 0, contributes: true, status: 'matched_default' }], + resolvedDependencies: [], + warnings: [], + }, + }); + + it('accepts a well-formed result, including an unfamiliar status', () => { + validateResolution(valid()); + const result = valid(); + result.diagnostics.tools[0].status = 'some_future_status'; + validateResolution(result); + }); + + it('rejects a missing or non-boolean contribution flag', () => { + for (const contributes of [undefined, 'true', 1]) { + const result = valid() as { diagnostics: { tools: Array> } }; + result.diagnostics.tools[0].contributes = contributes; + assert.throws( + () => validateResolution(result), + (error: unknown) => error instanceof MxcError && error.code === 'backend_error', + ); + } + }); + + it('rejects output that disagrees with the contribution flags', () => { + const withoutOutput = valid() as Record; + delete withoutOutput.requirements; + assert.throws(() => validateResolution(withoutOutput), MxcError); + const noContributor = valid(); + noContributor.diagnostics.tools[0].contributes = false; + assert.throws(() => validateResolution(noContributor), MxcError); + }); +}); diff --git a/src/ffi/mxc_ffi/build.rs b/src/ffi/mxc_ffi/build.rs index c528396f7..33aaf1bee 100644 --- a/src/ffi/mxc_ffi/build.rs +++ b/src/ffi/mxc_ffi/build.rs @@ -21,6 +21,7 @@ fn main() { println!("cargo:rerun-if-changed=src/lib.rs"); println!("cargo:rerun-if-changed=src/error_detail.rs"); + println!("cargo:rerun-if-changed=src/policy_store.rs"); println!("cargo:rerun-if-changed=src/pty.rs"); println!("cargo:rerun-if-changed=src/streaming.rs"); println!("cargo:rerun-if-changed=src/state_aware.rs"); @@ -53,6 +54,7 @@ fn generate_csharp_bindings() { if let Err(e) = csbindgen::Builder::default() .input_extern_file("src/lib.rs") .input_extern_file("src/error_detail.rs") + .input_extern_file("src/policy_store.rs") .input_extern_file("src/pty.rs") .input_extern_file("src/streaming.rs") .input_extern_file("src/state_aware.rs") diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 6e8f05a46..e48e7d88d 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -54,6 +54,10 @@ //! `wxc-wslc-daemon.exe` for the state-aware lifecycle, must sit beside this //! library rather than beside the application host, because both resolve //! against the loaded module. +//! - **Policy store** (`policy_store` module, PROTOTYPE pending API review) — +//! [`mxc_resolve_tool_requirements_json`] and its diagnostics variant look +//! tools up in the bundled catalog and return command-free container +//! requirements; the request is a lookup, not policy ingress. //! - **Telemetry consent** — [`mxc_telemetry_get_consent`], //! [`mxc_telemetry_get_consent_status`], [`mxc_telemetry_request_consent`], //! [`mxc_telemetry_withdraw_consent`], [`mxc_telemetry_needs_consent_prompt`], @@ -83,10 +87,12 @@ use mxc_sdk::v1::{probe, ProbeOutput}; use mxc_sdk::v1::{ExecutionResult, WaitResult}; mod error_detail; +mod policy_store; mod pty; mod state_aware; mod streaming; pub use error_detail::*; +pub use policy_store::*; pub use pty::*; pub use state_aware::*; pub use streaming::*; diff --git a/src/ffi/mxc_ffi/src/policy_store.rs b/src/ffi/mxc_ffi/src/policy_store.rs new file mode 100644 index 000000000..983e5a4ac --- /dev/null +++ b/src/ffi/mxc_ffi/src/policy_store.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! **PROTOTYPE, pending API review.** The policy store over the C ABI, for +//! the Node and C# SDKs. +//! +//! Each entry point fills a caller-owned [`MxcPolicyStoreResult`]: on success +//! `json_utf8` holds the result document; on failure `reason_utf8` holds the +//! stable sub-reason (`invalid_context`, `integrity`, ...) and `error` the +//! message. Release every result with [`mxc_policy_store_result_free`]. +//! +//! The resolve entry points take `{"tools": , "context"?}`. +//! This is a catalog lookup, not policy ingress: the result is never applied +//! here, and callers turn it into an exact versioned request through their +//! SDK's `ContainerRequest`. `mxc_resolve_tool_requirements_json` returns +//! `{"requirements"?}`, omitting `requirements` when nothing resolves; the +//! diagnostics variant returns `{"requirements"?, "diagnostics"}`. The +//! requirements JSON is the v1 `ContainerRequirements` shape (the +//! `filesystem`, `network`, `ui` and `timeoutMs` fields of a +//! `ContainerRequest`). + +use std::ffi::c_char; +use std::panic::catch_unwind; +use std::ptr; + +use mxc_sdk::__policy_store::sdk::binding; +use mxc_sdk::__policy_store::{ErrorCode, PolicyCatalogError}; + +use crate::{ + alloc_cstring, cstr_to_str, free_cstr, report_panic, MxcErrorDetail, MXC_STATUS_BACKEND_ERROR, + MXC_STATUS_INVALID_UTF8, MXC_STATUS_MALFORMED_REQUEST, MXC_STATUS_NULL_ARGUMENT, + MXC_STATUS_PANIC, MXC_STATUS_POLICY_VALIDATION, MXC_STATUS_SUCCESS, + MXC_STATUS_UNSUPPORTED_CONTAINMENT, +}; + +/// The result of a policy store entry point. +/// +/// All non-null pointers, including those inside `error`, are owned by the +/// caller and must be released with [`mxc_policy_store_result_free`]. +#[repr(C)] +pub struct MxcPolicyStoreResult { + /// `0` on success; otherwise one of the `MXC_STATUS_*` codes. + pub status: i32, + /// The result document (UTF-8 JSON) on success, or null. + pub json_utf8: *mut c_char, + /// The stable failure sub-reason (`details.reason`) on a store failure, + /// or null. + pub reason_utf8: *mut c_char, + /// Why the call failed, when `status != 0`; all-null otherwise. + pub error: MxcErrorDetail, +} + +impl MxcPolicyStoreResult { + fn success(json: &str) -> Self { + Self { + status: MXC_STATUS_SUCCESS, + json_utf8: alloc_cstring(json.as_bytes()), + reason_utf8: ptr::null_mut(), + error: MxcErrorDetail::none(), + } + } + + fn failure(status: i32, message: impl Into) -> Self { + Self { + status, + json_utf8: ptr::null_mut(), + reason_utf8: ptr::null_mut(), + error: MxcErrorDetail::from_message(message), + } + } + + fn from_store_error(error: &PolicyCatalogError) -> Self { + Self { + reason_utf8: alloc_cstring(error.reason().as_str().as_bytes()), + ..Self::failure(status_from_store_code(error.code()), error.message()) + } + } +} + +fn status_from_store_code(code: ErrorCode) -> i32 { + match code { + ErrorCode::PolicyValidation => MXC_STATUS_POLICY_VALIDATION, + ErrorCode::MalformedRequest => MXC_STATUS_MALFORMED_REQUEST, + ErrorCode::UnsupportedContainment => MXC_STATUS_UNSUPPORTED_CONTAINMENT, + ErrorCode::BackendError => MXC_STATUS_BACKEND_ERROR, + } +} + +fn complete(result: Result) -> MxcPolicyStoreResult { + match result { + Ok(json) => MxcPolicyStoreResult::success(&json), + Err(error) => MxcPolicyStoreResult::from_store_error(&error), + } +} + +/// Runs `body` panic-safely and writes its result to `out`. +/// +/// # Safety +/// `out` must be null or point to writable [`MxcPolicyStoreResult`] storage. +unsafe fn write_result( + operation: &str, + out: *mut MxcPolicyStoreResult, + body: impl FnOnce() -> MxcPolicyStoreResult + std::panic::UnwindSafe, +) -> i32 { + if out.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + let result = catch_unwind(body).unwrap_or_else(|panic| { + report_panic(operation, &*panic); + MxcPolicyStoreResult::failure(MXC_STATUS_PANIC, "the mxc policy store panicked") + }); + let status = result.status; + // SAFETY: `out` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out, result) }; + status +} + +/// Borrows the request, or produces the failure for a null or non-UTF-8 one. +/// +/// # Safety +/// `request` must be null or a valid NUL-terminated C string. +unsafe fn request_str<'a>(request: *const c_char) -> Result<&'a str, MxcPolicyStoreResult> { + match unsafe { cstr_to_str(request) } { + Some(value) => Ok(value), + None if request.is_null() => Err(MxcPolicyStoreResult::failure( + MXC_STATUS_NULL_ARGUMENT, + "request JSON pointer is null", + )), + None => Err(MxcPolicyStoreResult::failure( + MXC_STATUS_INVALID_UTF8, + "request JSON is not UTF-8", + )), + } +} + +/// Resolves tools to composed, command-free container requirements from the +/// bundled catalog. +/// +/// # Safety +/// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. +/// - `out` must be null or point to writable [`MxcPolicyStoreResult`] storage, +/// released afterwards with [`mxc_policy_store_result_free`]. +#[no_mangle] +pub unsafe extern "C" fn mxc_resolve_tool_requirements_json( + request_json_utf8: *const c_char, + out: *mut MxcPolicyStoreResult, +) -> i32 { + unsafe { + write_result( + "mxc_resolve_tool_requirements_json", + out, + || match request_str(request_json_utf8) { + Ok(request) => complete(binding::resolve_tool_requirements_json(request)), + Err(failure) => failure, + }, + ) + } +} + +/// Like [`mxc_resolve_tool_requirements_json`], with match attribution, +/// dependencies, and warnings. +/// +/// # Safety +/// Same contract as [`mxc_resolve_tool_requirements_json`]. +#[no_mangle] +pub unsafe extern "C" fn mxc_resolve_tool_requirements_with_diagnostics_json( + request_json_utf8: *const c_char, + out: *mut MxcPolicyStoreResult, +) -> i32 { + unsafe { + write_result( + "mxc_resolve_tool_requirements_with_diagnostics_json", + out, + || match request_str(request_json_utf8) { + Ok(request) => complete(binding::resolve_tool_requirements_with_diagnostics_json( + request, + )), + Err(failure) => failure, + }, + ) + } +} + +/// The bundled catalog's +/// `{"catalogSchemaVersion", "catalogRevision", "sdkContractVersion"}`. +/// +/// # Safety +/// `out` must be null or point to writable [`MxcPolicyStoreResult`] storage, +/// released afterwards with [`mxc_policy_store_result_free`]. +#[no_mangle] +pub unsafe extern "C" fn mxc_policy_catalog_info_json(out: *mut MxcPolicyStoreResult) -> i32 { + unsafe { + write_result("mxc_policy_catalog_info_json", out, || { + complete(binding::catalog_info_json()) + }) + } +} + +/// Metadata for every entry in the bundled catalog, as a JSON array. +/// +/// # Safety +/// `out` must be null or point to writable [`MxcPolicyStoreResult`] storage, +/// released afterwards with [`mxc_policy_store_result_free`]. +#[no_mangle] +pub unsafe extern "C" fn mxc_list_policy_catalog_entries_json( + out: *mut MxcPolicyStoreResult, +) -> i32 { + unsafe { + write_result("mxc_list_policy_catalog_entries_json", out, || { + complete(binding::list_catalog_entries_json()) + }) + } +} + +/// Frees the owned strings of an [`MxcPolicyStoreResult`]. Idempotent. +/// +/// # Safety +/// `r` must be null or point to a result filled by one of the policy store +/// entry points. +#[no_mangle] +pub unsafe extern "C" fn mxc_policy_store_result_free(r: *mut MxcPolicyStoreResult) { + if r.is_null() { + return; + } + if let Err(p) = catch_unwind(|| { + // SAFETY: caller guarantees `r` points to a valid result. + let r = unsafe { &mut *r }; + free_cstr(&mut r.json_utf8); + free_cstr(&mut r.reason_utf8); + r.error.free_strings(); + }) { + report_panic("mxc_policy_store_result_free", &*p); + } +} diff --git a/src/ffi/mxc_ffi/tests/policy_store.rs b/src/ffi/mxc_ffi/tests/policy_store.rs new file mode 100644 index 000000000..b644d2088 --- /dev/null +++ b/src/ffi/mxc_ffi/tests/policy_store.rs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The prototype policy store C ABI. Lookups target the current host so +//! filesystem object identity can be established for existing paths. + +use std::ffi::{CStr, CString}; +use std::mem::MaybeUninit; +use std::ptr; + +use mxc_ffi::*; + +fn take(result: &mut MxcPolicyStoreResult) -> (Option, Option, Option) { + let read = |p: *mut std::ffi::c_char| { + (!p.is_null()).then(|| unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()) + }; + let values = ( + read(result.json_utf8), + read(result.reason_utf8), + read(result.error.message_utf8), + ); + unsafe { + mxc_policy_store_result_free(result); + // Idempotent. + mxc_policy_store_result_free(result); + } + assert!(result.json_utf8.is_null() && result.reason_utf8.is_null()); + values +} + +fn call( + f: unsafe extern "C" fn(*const std::ffi::c_char, *mut MxcPolicyStoreResult) -> i32, + request: &str, +) -> (i32, Option, Option, Option) { + let request = CString::new(request).unwrap(); + let mut out = MaybeUninit::::uninit(); + let status = unsafe { f(request.as_ptr(), out.as_mut_ptr()) }; + let mut out = unsafe { out.assume_init() }; + assert_eq!(out.status, status); + let (json, reason, message) = take(&mut out); + (status, json, reason, message) +} + +/// Existing host directories for the git entry's symbols. +struct Dirs { + _root: tempfile::TempDir, + project: String, + git: String, + ssh: String, + program_data: String, +} + +fn dirs() -> Dirs { + let root = tempfile::tempdir().unwrap(); + let make = |name: &str| { + let path = root.path().join(name); + std::fs::create_dir_all(&path).unwrap(); + path.to_str().unwrap().to_owned() + }; + let (project, git, ssh, program_data) = (make("w"), make("git"), make("ssh"), make("pd")); + std::fs::create_dir_all(root.path().join("pd").join("Git")).unwrap(); + Dirs { + project, + git, + ssh, + program_data, + _root: root, + } +} + +fn request(tools: serde_json::Value, dirs: &Dirs, weak: bool) -> String { + serde_json::json!({ + "tools": tools, + "context": { + "allowWeakIdentityFallback": weak, + "projectRoot": dirs.project, + "symbols": { + "git_prefix": dirs.git, + "ssh_prefix": dirs.ssh, + "programData": dirs.program_data, + } + } + }) + .to_string() +} + +fn git(dirs: &Dirs) -> String { + request(serde_json::json!("git"), dirs, true) +} + +#[test] +fn resolves_requirements() { + let dirs = dirs(); + let (status, json, reason, message) = call(mxc_resolve_tool_requirements_json, &git(&dirs)); + assert_eq!(status, MXC_STATUS_SUCCESS, "{message:?}"); + assert_eq!(reason, None); + let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + let requirements = &json["requirements"]; + assert!(requirements.get("version").is_none()); + assert!(requirements.get("command").is_none()); + assert_eq!( + requirements["filesystem"]["readonlyPaths"][0], + dirs.git.as_str() + ); + assert_eq!( + requirements["filesystem"]["readwritePaths"][0], + dirs.project.as_str() + ); +} + +#[test] +fn absence_omits_the_requirements() { + let (status, json, _, _) = call( + mxc_resolve_tool_requirements_json, + r#"{"tools":"unknown-tool"}"#, + ); + assert_eq!(status, MXC_STATUS_SUCCESS); + assert_eq!(json.as_deref(), Some("{}")); +} + +#[test] +fn diagnostics_include_attribution() { + let dirs = dirs(); + let (status, json, _, _) = call( + mxc_resolve_tool_requirements_with_diagnostics_json, + &git(&dirs), + ); + assert_eq!(status, MXC_STATUS_SUCCESS); + let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + assert_eq!( + json["diagnostics"]["tools"][0]["selection"]["entryId"], + "tool:git" + ); + assert_eq!(json["diagnostics"]["tools"][0]["contributes"], true); + assert!(json["requirements"].is_object()); +} + +#[test] +fn intent_and_version_cross_the_boundary() { + let dirs = dirs(); + let (status, json, _, message) = call( + mxc_resolve_tool_requirements_with_diagnostics_json, + &request( + serde_json::json!({"invocationName":"git","packageUrl":"pkg:generic/git","detectedVersion":"2.30.0","intent":"fetch"}), + &dirs, + false, + ), + ); + assert_eq!(status, MXC_STATUS_SUCCESS, "{message:?}"); + let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + let tool = &json["diagnostics"]["tools"][0]; + assert_eq!(tool["status"], "version_out_of_range"); + assert_eq!(tool["selection"]["intentSelection"]["requested"], "fetch"); + assert_eq!(tool["contributes"], true); + let warning = &json["diagnostics"]["warnings"][0]; + assert_eq!(warning["code"], "version_out_of_range"); + assert_eq!(warning["inputIndex"], 0); + assert_eq!(warning["detectedVersion"], "2.30.0"); + assert_eq!( + json["requirements"]["network"]["egress"]["allow"][0]["ports"][0]["port"], + 443 + ); +} + +#[test] +fn dependency_intent_selection_crosses_the_boundary() { + let dirs = dirs(); + let (status, json, _, message) = call( + mxc_resolve_tool_requirements_with_diagnostics_json, + &request( + serde_json::json!({"invocationName":"git","packageUrl":"pkg:generic/git","detectedVersion":"2.45.1","intent":"push"}), + &dirs, + false, + ), + ); + assert_eq!(status, MXC_STATUS_SUCCESS, "{message:?}"); + let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + let dependency = &json["diagnostics"]["resolvedDependencies"][0]; + assert_eq!(dependency["entryId"], "tool:ssh"); + assert_eq!(dependency["inputIndexes"], serde_json::json!([0])); + assert_eq!(dependency["intentSelection"]["mode"], "none"); + assert_eq!( + dependency["intentSelection"]["selected"], + serde_json::json!([]) + ); +} + +#[test] +fn store_failures_carry_status_and_reason() { + let (status, json, reason, message) = call( + mxc_resolve_tool_requirements_json, + r#"{"tools":"git","context":{"platform":"solaris"}}"#, + ); + assert_eq!(status, MXC_STATUS_MALFORMED_REQUEST); + assert_eq!(json, None); + assert_eq!(reason.as_deref(), Some("invalid_context")); + assert!(message.unwrap().starts_with("[malformed_request] ")); + + let (status, _, reason, _) = call( + mxc_resolve_tool_requirements_json, + r#"{"tools":"git","context":{"catalogRevision":"1999-01-01.1"}}"#, + ); + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert_eq!(reason.as_deref(), Some("revision_unavailable")); + + let (status, _, reason, _) = call(mxc_resolve_tool_requirements_json, "not json"); + assert_eq!(status, MXC_STATUS_MALFORMED_REQUEST); + assert_eq!(reason.as_deref(), Some("invalid_context")); +} + +#[test] +fn null_and_non_utf8_arguments_are_rejected() { + let mut out = MaybeUninit::::uninit(); + let status = unsafe { mxc_resolve_tool_requirements_json(ptr::null(), out.as_mut_ptr()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + let mut out = unsafe { out.assume_init() }; + let (_, reason, _) = take(&mut out); + assert_eq!(reason, None); + + let bad = CString::new(vec![0xffu8, 0xfe]).unwrap(); + let mut out = MaybeUninit::::uninit(); + let status = unsafe { mxc_resolve_tool_requirements_json(bad.as_ptr(), out.as_mut_ptr()) }; + assert_eq!(status, MXC_STATUS_INVALID_UTF8); + take(&mut unsafe { out.assume_init() }); + + let request = CString::new("{}").unwrap(); + let status = unsafe { mxc_resolve_tool_requirements_json(request.as_ptr(), ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + unsafe { mxc_policy_store_result_free(ptr::null_mut()) }; +} + +#[test] +fn inspection_entry_points() { + let mut out = MaybeUninit::::uninit(); + assert_eq!( + unsafe { mxc_policy_catalog_info_json(out.as_mut_ptr()) }, + MXC_STATUS_SUCCESS + ); + let (json, _, _) = take(&mut unsafe { out.assume_init() }); + let info: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + assert_eq!(info["catalogSchemaVersion"], "1"); + assert_eq!(info["sdkContractVersion"], "1.0.0"); + + let mut out = MaybeUninit::::uninit(); + assert_eq!( + unsafe { mxc_list_policy_catalog_entries_json(out.as_mut_ptr()) }, + MXC_STATUS_SUCCESS + ); + let (json, _, _) = take(&mut unsafe { out.assume_init() }); + let entries: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + assert!(entries.as_array().unwrap().len() >= 3); +} diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index bc223c2bb..109ccd2f4 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -115,6 +115,33 @@ telemetry when supplied. Native `validate_provision`, `validate_start`, `validate_stop`, `validate_deprovision`, and `validate_process` use those operation options and return no execution output. +## Tool requirements lookup (prototype) + +> **PROTOTYPE, pending API review.** Not part of MXC 1.0; names and shapes +> may change. The result is a best-effort floor from the catalog bundled in +> the SDK, not a guarantee, and it never grants access by itself. See +> [`docs/policy-store/`](../../docs/policy-store/README.md). + +```rust,ignore +use mxc_sdk::v1::tool_requirements::{ResolveContext, ToolCandidate}; +use mxc_sdk::v1::{resolve_tool_requirements, ContainerRequest}; + +let git = ToolCandidate::new("git") + .with_package_url("pkg:generic/git") + .with_detected_version("2.45.1") + .with_intent("push"); +let context = ResolveContext::default(); +if let Some(requirements) = resolve_tool_requirements(&[git], Some(&context))? { + let request = ContainerRequest::from_requirements(requirements, "git push"); +} +``` + +`resolve_tool_requirements_with_diagnostics` adds per-input statuses, +dependency attribution, and structured warnings. Inputs are a slice of +`ToolCandidate` (`ToolCandidate::from("git")` for a bare name); `None` for +the context means `ResolveContext::default()`. The resolver is the internal +`policy_store` module, which embeds the catalog in `policy_store/catalog/`. + ## Public V1 types | Purpose | Rust type | diff --git a/src/mxc-sdk/build.rs b/src/mxc-sdk/build.rs index a3c26529d..92e5f3015 100644 --- a/src/mxc-sdk/build.rs +++ b/src/mxc-sdk/build.rs @@ -12,6 +12,8 @@ mod build_mxc_common; mod build_mxc_telemetry; #[path = "build/build_nanvix_binaries.rs"] mod build_nanvix_binaries; +#[path = "build/build_policy_store.rs"] +mod build_policy_store; #[cfg(feature = "link-wslcsdk")] #[path = "build/build_wslc_common.rs"] mod build_wslc_common; @@ -29,6 +31,7 @@ mod nanvix_common; fn main() -> Result<(), Box> { build_isolation_session_bindings::run(); build_mxc_telemetry::run(); + build_policy_store::run(); if let Some((bin_dir, use_prefetched_binaries)) = build_nanvix_binaries::run() { nanvix_build_common::stage_artifacts_next_to_exe(&bin_dir, !use_prefetched_binaries); } diff --git a/src/mxc-sdk/build/build_policy_store.rs b/src/mxc-sdk/build/build_policy_store.rs new file mode 100644 index 000000000..bce88e25a --- /dev/null +++ b/src/mxc-sdk/build/build_policy_store.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Embeds the V1 policy catalog into the crate. +//! +//! `catalog/` beside this file is the single source of truth. Every MXC SDK +//! reaches it through this crate: Rust directly, and Node and C# through the +//! `mxc_ffi` native library they already ship. + +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn run() { + let manifest_dir = + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let source = manifest_dir.join("policy_store").join("catalog"); + + println!("cargo:rerun-if-changed={}", source.display()); + + assert!( + source.join("manifest.json").is_file(), + "no catalog to embed: expected {}", + source.display() + ); + let source = fs::canonicalize(&source).expect("canonicalize catalog directory"); + let display = strip_verbatim(&source); + + let revisions_dir = source.join("revisions"); + let mut revisions: Vec = fs::read_dir(&revisions_dir) + .expect("read catalog/revisions") + .map(|e| { + e.expect("dir entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .filter(|name| name.ends_with(".json")) + .collect(); + revisions.sort(); + + let mut code = String::new(); + let lit = |p: &Path| format!("{:?}", p.to_string_lossy()); + for file in ["contract.v1.json", "manifest.json"] { + println!("cargo:rerun-if-changed={}", source.join(file).display()); + } + writeln!(code, "/// Directory the catalog was embedded from.").unwrap(); + writeln!(code, "pub const SOURCE_DIR: &str = {:?};", display).unwrap(); + writeln!( + code, + "pub const CONTRACT: &str = include_str!({});", + lit(&source.join("contract.v1.json")) + ) + .unwrap(); + writeln!( + code, + "pub const MANIFEST: &str = include_str!({});", + lit(&source.join("manifest.json")) + ) + .unwrap(); + writeln!(code, "pub const REVISIONS: &[(&str, &str)] = &[").unwrap(); + for name in &revisions { + let path = revisions_dir.join(name); + println!("cargo:rerun-if-changed={}", path.display()); + writeln!( + code, + " ({:?}, include_str!({})),", + format!("revisions/{name}"), + lit(&path) + ) + .unwrap(); + } + writeln!(code, "];").unwrap(); + + let out = + PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")).join("policy_store_catalog.rs"); + fs::write(out, code).expect("write policy_store_catalog.rs"); +} + +fn strip_verbatim(path: &Path) -> String { + let text = path.to_string_lossy().into_owned(); + if let Some(rest) = text.strip_prefix("\\\\?\\UNC\\") { + format!("\\\\{rest}") + } else if let Some(rest) = text.strip_prefix("\\\\?\\") { + rest.to_string() + } else { + text + } +} diff --git a/src/mxc-sdk/policy_store/README.md b/src/mxc-sdk/policy_store/README.md new file mode 100644 index 000000000..d76f9e3ad --- /dev/null +++ b/src/mxc-sdk/policy_store/README.md @@ -0,0 +1,56 @@ +# mxc-sdk policy_store — PROTOTYPE, pending API review + +> **Prototype.** This API is proposed and pending API review and sign-off +> before check-in. Names, shapes, and catalog contents may change. It is not +> part of MXC 1.0; a later SDK release is targeted. + +The MXC policy store resolves known tools to command-free +`ContainerRequirements`: a **best-effort floor** of the access a known tool +typically needs, which a caller reviews and constrains before adding its own +command. It is not a guarantee, and it is complementary to Learning Mode +rather than a replacement for it. + +This directory holds the store's data. The resolver is the internal +`policy_store` module in `../src/policy_store/`, which embeds the catalog at +build time; nothing is downloaded. + +| SDK | Entry point | +|-----|-------------| +| Rust (`mxc-sdk`) | `mxc_sdk::v1::{resolve_tool_requirements, resolve_tool_requirements_with_diagnostics, get_catalog_info, list_catalog_entries}` | +| Node (`@microsoft/mxc-sdk/v1`) | `resolveToolRequirements`, `resolveToolRequirementsWithDiagnostics` (promise-based), `getCatalogInfo`, `listCatalogEntries` through `mxc_ffi` | +| C# (`Microsoft.Mxc.Sdk.V1`) | `MxcContainer.ResolveToolRequirements`, `ResolveToolRequirementsWithDiagnostics` (plus `Async`), `GetCatalogInfo`, `ListCatalogEntries` through `mxc_ffi` | + +## Layout + +| Path | Contents | +|------|----------| +| `catalog/entries/` | Editable sources: one JSON file per tool with its full entry (subdirectories are organizational only) | +| `catalog/` | Contract and manifest; `revisions/` holds generated, immutable published snapshots assembled from `entries/`, and `views/` the generated reviewer view and exact MXC 1.0.0 requests | +| `schema/` | JSON Schemas for the catalog and manifest (editor validation) | +| `conformance/` | Language-neutral fixtures and path/canonical-JSON vectors | + +## Build and test + +From `src/`: + +```sh +cargo test -p mxc-sdk --lib policy_store +cargo test -p mxc-sdk --test policy_store_library --test policy_store_conformance --test policy_store_catalog_validation --test policy_store_vectors --test policy_store_sdk +cargo test -p mxc_ffi --test policy_store +``` + +`tests/policy_store_catalog_validation.rs` is the contribution gate: the +default revision snapshot matches the assembled `entries/` +(`MXC_POLICY_STORE_UPDATE_REVISION=1` regenerates it), contract, +entry-revision history, every materialized effective policy validated as an +exact MXC 1.0.0 request, deterministic resolution, conformance coverage, and +current views (`MXC_POLICY_STORE_UPDATE_VIEWS=1` regenerates `catalog/views/`). +Set `MXC_POLICY_STORE_BASE_REF=` (for example `origin/main`) to also +check that already-published revisions are unchanged. + +## Contributing a revision + +Published revisions are immutable. See +[`docs/policy-store/README.md`](../../../docs/policy-store/README.md#changing-the-catalog) +for the steps, and [`docs/policy-store/design.md`](../../../docs/policy-store/design.md) +for the prototype's implementation notes. diff --git a/src/mxc-sdk/policy_store/catalog/contract.v1.json b/src/mxc-sdk/policy_store/catalog/contract.v1.json new file mode 100644 index 000000000..63c0d8e7c --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/contract.v1.json @@ -0,0 +1,51 @@ +{ + "$comment": "Catalog contract v1: the symbol vocabulary entries may reference. Entries embed command-free MXC 1.0 request access fields; each revision records its SDK validation target (sdkContractVersion). 'context' symbols come only from ResolveContext.projectRoot; 'caller' symbols come from ResolveContext.symbols, then host-local discovery, then a platform default; 'host' symbols are OS well-known folders derived for the current host, which ResolveContext.symbols may override. A 'defaults' map gives a per-platform path template that may reference only host symbols.", + "catalogSchemaVersion": "1", + "symbols": { + "project_root": { + "source": "context", + "description": "The consumer's project root, supplied through ResolveContext.projectRoot." + }, + "git_prefix": { + "source": "caller", + "description": "Directory containing the git executable (discovered on PATH when omitted)." + }, + "node_prefix": { + "source": "caller", + "description": "Directory containing the node executable (discovered on PATH when omitted)." + }, + "npm_prefix": { + "source": "caller", + "description": "Directory containing the npm and npx launchers (discovered on PATH when omitted)." + }, + "npm_cache": { + "source": "caller", + "description": "The npm cache directory in effect for the invocation (discovered from npm_config_cache or ~/.npmrc when omitted).", + "defaults": { + "linux": "${user_home}/.npm", + "macos": "${user_home}/.npm", + "windows": "${localAppData}/npm-cache" + } + }, + "user_home": { + "source": "host", + "description": "The current user's home directory." + }, + "temp_dir": { + "source": "host", + "description": "The current user's temporary directory." + }, + "ssh_prefix": { + "source": "caller", + "description": "Directory containing the ssh executable (discovered on PATH when omitted)." + }, + "programData": { + "source": "host", + "description": "The Windows common application-data directory (%ProgramData%). Derived only on a Windows host." + }, + "localAppData": { + "source": "host", + "description": "The Windows per-user local application-data directory (%LOCALAPPDATA%). Derived only on a Windows host." + } + } +} diff --git a/src/mxc-sdk/policy_store/catalog/entries/git.json b/src/mxc-sdk/policy_store/catalog/entries/git.json new file mode 100644 index 000000000..e4fc811dd --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/entries/git.json @@ -0,0 +1,161 @@ +{ + "entryId": "tool:git", + "entryRevision": 3, + "displayName": "Git", + "versionScheme": "intdot", + "identity": [ + { + "kind": "purl", + "value": "pkg:generic/git" + }, + { + "kind": "invocation-name", + "names": [ + "git", + "git.exe" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}" + ], + "readwritePaths": [ + "${project_root}" + ] + } + }, + "intents": { + "local": { + "exampleSubcommands": [ + "status", + "diff" + ], + "policyAdditions": {} + }, + "fetch": { + "exampleSubcommands": [ + "fetch", + "pull" + ], + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + }, + "push": { + "exampleSubcommands": [ + "push" + ], + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + } + } + }, + "platformVariants": [ + { + "when": { + "platform": "windows" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${programData}/Git" + ] + } + } + } + ], + "versionVariants": [ + { + "versionRange": "vers:intdot/>=2.40|<2.50", + "intentAdditions": { + "push": { + "dependencies": [ + { + "entryId": "tool:ssh" + } + ] + } + } + }, + { + "versionRange": "vers:intdot/>=2.50|<3", + "newIntents": { + "bundle-fetch": { + "exampleSubcommands": [ + "clone --bundle-uri=" + ], + "policyAdditions": { + "filesystem": { + "readwritePaths": [ + "${temp_dir}/git-bundles" + ] + }, + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + } + } + } + ], + "provenance": { + "method": "design-example", + "sourceRevision": "docs/mxc-policy-store.md (Policy Store design, git example)" + } +} diff --git a/src/mxc-sdk/policy_store/catalog/entries/node.json b/src/mxc-sdk/policy_store/catalog/entries/node.json new file mode 100644 index 000000000..7b5f77337 --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/entries/node.json @@ -0,0 +1,28 @@ +{ + "entryId": "tool:node", + "entryRevision": 2, + "displayName": "Node.js", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "node", + "node.exe" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${node_prefix}" + ] + } + } + }, + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } +} diff --git a/src/mxc-sdk/policy_store/catalog/entries/npm.json b/src/mxc-sdk/policy_store/catalog/entries/npm.json new file mode 100644 index 000000000..7c20c3025 --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/entries/npm.json @@ -0,0 +1,43 @@ +{ + "entryId": "tool:npm", + "entryRevision": 2, + "displayName": "npm / npx", + "versionScheme": "npm", + "identity": [ + { + "kind": "purl", + "value": "pkg:npm/npm" + }, + { + "kind": "invocation-name", + "names": [ + "npm", + "npm.cmd", + "npx", + "npx.cmd" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${npm_prefix}" + ], + "readwritePaths": [ + "${project_root}", + "${npm_cache}" + ] + } + }, + "dependencies": [ + { + "entryId": "tool:node" + } + ] + }, + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } +} diff --git a/src/mxc-sdk/policy_store/catalog/entries/ssh.json b/src/mxc-sdk/policy_store/catalog/entries/ssh.json new file mode 100644 index 000000000..884adbfec --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/entries/ssh.json @@ -0,0 +1,28 @@ +{ + "entryId": "tool:ssh", + "entryRevision": 2, + "displayName": "OpenSSH client", + "versionScheme": "intdot", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "ssh", + "ssh.exe" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${ssh_prefix}" + ] + } + } + }, + "provenance": { + "method": "design-example", + "sourceRevision": "docs/mxc-policy-store.md (Policy Store design, git example)" + } +} diff --git a/src/mxc-sdk/policy_store/catalog/manifest.json b/src/mxc-sdk/policy_store/catalog/manifest.json new file mode 100644 index 000000000..eeb4ec4f2 --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/manifest.json @@ -0,0 +1,10 @@ +{ + "catalogSchemaVersion": "1", + "defaultRevision": "2026-10-06.1", + "revisions": [ + { + "catalogRevision": "2026-10-06.1", + "file": "revisions/2026-10-06.1.json" + } + ] +} diff --git a/src/mxc-sdk/policy_store/catalog/revisions/2026-10-06.1.json b/src/mxc-sdk/policy_store/catalog/revisions/2026-10-06.1.json new file mode 100644 index 000000000..e31049f0a --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/revisions/2026-10-06.1.json @@ -0,0 +1,267 @@ +{ + "catalogSchemaVersion": "1", + "catalogRevision": "2026-10-06.1", + "sdkContractVersion": "1.0.0", + "entries": [ + { + "entryId": "tool:git", + "entryRevision": 3, + "displayName": "Git", + "versionScheme": "intdot", + "identity": [ + { + "kind": "purl", + "value": "pkg:generic/git" + }, + { + "kind": "invocation-name", + "names": [ + "git", + "git.exe" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}" + ], + "readwritePaths": [ + "${project_root}" + ] + } + }, + "intents": { + "local": { + "exampleSubcommands": [ + "status", + "diff" + ], + "policyAdditions": {} + }, + "fetch": { + "exampleSubcommands": [ + "fetch", + "pull" + ], + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + }, + "push": { + "exampleSubcommands": [ + "push" + ], + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + } + } + }, + "platformVariants": [ + { + "when": { + "platform": "windows" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${programData}/Git" + ] + } + } + } + ], + "versionVariants": [ + { + "versionRange": "vers:intdot/>=2.40|<2.50", + "intentAdditions": { + "push": { + "dependencies": [ + { + "entryId": "tool:ssh" + } + ] + } + } + }, + { + "versionRange": "vers:intdot/>=2.50|<3", + "newIntents": { + "bundle-fetch": { + "exampleSubcommands": [ + "clone --bundle-uri=" + ], + "policyAdditions": { + "filesystem": { + "readwritePaths": [ + "${temp_dir}/git-bundles" + ] + }, + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + } + } + } + ], + "provenance": { + "method": "design-example", + "sourceRevision": "docs/mxc-policy-store.md (Policy Store design, git example)" + } + }, + { + "entryId": "tool:node", + "entryRevision": 2, + "displayName": "Node.js", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "node", + "node.exe" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${node_prefix}" + ] + } + } + }, + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } + }, + { + "entryId": "tool:npm", + "entryRevision": 2, + "displayName": "npm / npx", + "versionScheme": "npm", + "identity": [ + { + "kind": "purl", + "value": "pkg:npm/npm" + }, + { + "kind": "invocation-name", + "names": [ + "npm", + "npm.cmd", + "npx", + "npx.cmd" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${npm_prefix}" + ], + "readwritePaths": [ + "${project_root}", + "${npm_cache}" + ] + } + }, + "dependencies": [ + { + "entryId": "tool:node" + } + ] + }, + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } + }, + { + "entryId": "tool:ssh", + "entryRevision": 2, + "displayName": "OpenSSH client", + "versionScheme": "intdot", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "ssh", + "ssh.exe" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${ssh_prefix}" + ] + } + } + }, + "provenance": { + "method": "design-example", + "sourceRevision": "docs/mxc-policy-store.md (Policy Store design, git example)" + } + } + ] +} diff --git a/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.md b/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.md new file mode 100644 index 000000000..e46c98b2c --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.md @@ -0,0 +1,91 @@ + + +# Catalog revision 2026-10-06.1 — reviewer view + +Every effective policy each entry can produce (its command-free container requirements): the default, plus platform additions, at most one version variant, and the selected intent. `(all)` means no intent was requested. Paths are unsubstituted templates. *Added to default* lists what the row adds to the common default (no platform or version overlay) for the same intent, or to the default's base for an intent the default does not declare. + +## `tool:git` — Git (revision 3, `intdot` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default | +|---|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | `+ro ${programData}\Git` | +| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | `+ro ${programData}\Git` | +| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | `+ro ${programData}\Git` | +| windows | any | push | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | `+ro ${programData}\Git` | +| linux | any | (all) | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | — | +| linux | any | fetch | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | — | +| linux | any | local | `${git_prefix}` | `${project_root}` | — | — | — | — | +| linux | any | push | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | — | +| macos | any | (all) | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | — | +| macos | any | fetch | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | — | +| macos | any | local | `${git_prefix}` | `${project_root}` | — | — | — | — | +| macos | any | push | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | — | + +### Version `vers:intdot/>=2.40|<2.50` + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default | +|---|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${git_prefix}`
`${programData}\Git`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | `tool:ssh` | `+ro ${programData}\Git`
`+ro ${ssh_prefix}`
`+dependency tool:ssh` | +| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | `+ro ${programData}\Git` | +| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | `+ro ${programData}\Git` | +| windows | any | push | `${git_prefix}`
`${programData}\Git`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | `tool:ssh` | `+ro ${programData}\Git`
`+ro ${ssh_prefix}`
`+dependency tool:ssh` | +| linux | any | (all) | `${git_prefix}`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | `tool:ssh` | `+ro ${ssh_prefix}`
`+dependency tool:ssh` | +| linux | any | fetch | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | — | +| linux | any | local | `${git_prefix}` | `${project_root}` | — | — | — | — | +| linux | any | push | `${git_prefix}`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | `tool:ssh` | `+ro ${ssh_prefix}`
`+dependency tool:ssh` | +| macos | any | (all) | `${git_prefix}`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | `tool:ssh` | `+ro ${ssh_prefix}`
`+dependency tool:ssh` | +| macos | any | fetch | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | — | +| macos | any | local | `${git_prefix}` | `${project_root}` | — | — | — | — | +| macos | any | push | `${git_prefix}`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | `tool:ssh` | `+ro ${ssh_prefix}`
`+dependency tool:ssh` | + +### Version `vers:intdot/>=2.50|<3` + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default | +|---|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${git_prefix}`
`${programData}\Git` | `${project_root}`
`${temp_dir}\git-bundles` | — | `198.51.100.20/32 tcp/443`
`192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | `+ro ${programData}\Git`
`+rw ${temp_dir}\git-bundles`
`+allow 198.51.100.20/32 tcp/443` | +| windows | any | bundle-fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}`
`${temp_dir}\git-bundles` | — | `198.51.100.20/32 tcp/443` | — | `+ro ${programData}\Git`
`+rw ${temp_dir}\git-bundles`
`+allow 198.51.100.20/32 tcp/443` | +| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | `+ro ${programData}\Git` | +| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | `+ro ${programData}\Git` | +| windows | any | push | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | `+ro ${programData}\Git` | +| linux | any | (all) | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `198.51.100.20/32 tcp/443`
`192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | `+rw ${temp_dir}/git-bundles`
`+allow 198.51.100.20/32 tcp/443` | +| linux | any | bundle-fetch | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `198.51.100.20/32 tcp/443` | — | `+rw ${temp_dir}/git-bundles`
`+allow 198.51.100.20/32 tcp/443` | +| linux | any | fetch | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | — | +| linux | any | local | `${git_prefix}` | `${project_root}` | — | — | — | — | +| linux | any | push | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | — | +| macos | any | (all) | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `198.51.100.20/32 tcp/443`
`192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | `+rw ${temp_dir}/git-bundles`
`+allow 198.51.100.20/32 tcp/443` | +| macos | any | bundle-fetch | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `198.51.100.20/32 tcp/443` | — | `+rw ${temp_dir}/git-bundles`
`+allow 198.51.100.20/32 tcp/443` | +| macos | any | fetch | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | — | +| macos | any | local | `${git_prefix}` | `${project_root}` | — | — | — | — | +| macos | any | push | `${git_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | — | + +## `tool:node` — Node.js (revision 2, `semver` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default | +|---|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${node_prefix}` | — | — | — | — | — | +| linux | any | (all) | `${node_prefix}` | — | — | — | — | — | +| macos | any | (all) | `${node_prefix}` | — | — | — | — | — | + +## `tool:npm` — npm / npx (revision 2, `npm` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default | +|---|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${npm_prefix}`
`${node_prefix}` | `${project_root}`
`${npm_cache}` | — | — | `tool:node` | — | +| linux | any | (all) | `${npm_prefix}`
`${node_prefix}` | `${project_root}`
`${npm_cache}` | — | — | `tool:node` | — | +| macos | any | (all) | `${npm_prefix}`
`${node_prefix}` | `${project_root}`
`${npm_cache}` | — | — | `tool:node` | — | + +## `tool:ssh` — OpenSSH client (revision 2, `intdot` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default | +|---|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${ssh_prefix}` | — | — | — | — | — | +| linux | any | (all) | `${ssh_prefix}` | — | — | — | — | — | +| macos | any | (all) | `${ssh_prefix}` | — | — | — | — | — | diff --git a/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.requests.json b/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.requests.json new file mode 100644 index 000000000..8ea293633 --- /dev/null +++ b/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.requests.json @@ -0,0 +1,1071 @@ +{ + "$comment": "Generated by mxc_sdk policy_store::tooling::render_exact_requests. Do not edit. Each document is the exact SDK target request for the listed combinations, with fixture symbols and a validation-only command; scripts/versioning/validate-configs.js validates it against the registered schema.", + "catalogRevision": "2026-10-06.1", + "sdkContractVersion": "1.0.0", + "requests": [ + { + "combinations": [ + "tool:git windows/x64 default (all)", + "tool:git windows/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git windows/x64 default fetch", + "tool:git windows/x64 vers:intdot/>=2.40|<2.50 fetch", + "tool:git windows/x64 vers:intdot/>=2.50|<3 fetch", + "tool:git windows/arm64 default fetch", + "tool:git windows/arm64 vers:intdot/>=2.40|<2.50 fetch", + "tool:git windows/arm64 vers:intdot/>=2.50|<3 fetch" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git windows/x64 default local", + "tool:git windows/x64 vers:intdot/>=2.40|<2.50 local", + "tool:git windows/x64 vers:intdot/>=2.50|<3 local", + "tool:git windows/arm64 default local", + "tool:git windows/arm64 vers:intdot/>=2.40|<2.50 local", + "tool:git windows/arm64 vers:intdot/>=2.50|<3 local" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:git windows/x64 default push", + "tool:git windows/x64 vers:intdot/>=2.50|<3 push", + "tool:git windows/arm64 default push", + "tool:git windows/arm64 vers:intdot/>=2.50|<3 push" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git windows/x64 vers:intdot/>=2.40|<2.50 (all)", + "tool:git windows/arm64 vers:intdot/>=2.40|<2.50 (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git", + "C:\\mxc-policy-store-fixture\\ssh_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git windows/x64 vers:intdot/>=2.40|<2.50 push", + "tool:git windows/arm64 vers:intdot/>=2.40|<2.50 push" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git", + "C:\\mxc-policy-store-fixture\\ssh_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git windows/x64 vers:intdot/>=2.50|<3 (all)", + "tool:git windows/arm64 vers:intdot/>=2.50|<3 (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root", + "C:\\mxc-policy-store-fixture\\temp_dir\\git-bundles" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git windows/x64 vers:intdot/>=2.50|<3 bundle-fetch", + "tool:git windows/arm64 vers:intdot/>=2.50|<3 bundle-fetch" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root", + "C:\\mxc-policy-store-fixture\\temp_dir\\git-bundles" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\git_prefix", + "C:\\mxc-policy-store-fixture\\programData\\Git" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 default (all)", + "tool:git linux/arm64 default (all)", + "tool:git macos/x64 default (all)", + "tool:git macos/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 default fetch", + "tool:git linux/x64 vers:intdot/>=2.40|<2.50 fetch", + "tool:git linux/x64 vers:intdot/>=2.50|<3 fetch", + "tool:git linux/arm64 default fetch", + "tool:git linux/arm64 vers:intdot/>=2.40|<2.50 fetch", + "tool:git linux/arm64 vers:intdot/>=2.50|<3 fetch", + "tool:git macos/x64 default fetch", + "tool:git macos/x64 vers:intdot/>=2.40|<2.50 fetch", + "tool:git macos/x64 vers:intdot/>=2.50|<3 fetch", + "tool:git macos/arm64 default fetch", + "tool:git macos/arm64 vers:intdot/>=2.40|<2.50 fetch", + "tool:git macos/arm64 vers:intdot/>=2.50|<3 fetch" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 default local", + "tool:git linux/x64 vers:intdot/>=2.40|<2.50 local", + "tool:git linux/x64 vers:intdot/>=2.50|<3 local", + "tool:git linux/arm64 default local", + "tool:git linux/arm64 vers:intdot/>=2.40|<2.50 local", + "tool:git linux/arm64 vers:intdot/>=2.50|<3 local", + "tool:git macos/x64 default local", + "tool:git macos/x64 vers:intdot/>=2.40|<2.50 local", + "tool:git macos/x64 vers:intdot/>=2.50|<3 local", + "tool:git macos/arm64 default local", + "tool:git macos/arm64 vers:intdot/>=2.40|<2.50 local", + "tool:git macos/arm64 vers:intdot/>=2.50|<3 local" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:git linux/x64 default push", + "tool:git linux/x64 vers:intdot/>=2.50|<3 push", + "tool:git linux/arm64 default push", + "tool:git linux/arm64 vers:intdot/>=2.50|<3 push", + "tool:git macos/x64 default push", + "tool:git macos/x64 vers:intdot/>=2.50|<3 push", + "tool:git macos/arm64 default push", + "tool:git macos/arm64 vers:intdot/>=2.50|<3 push" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 vers:intdot/>=2.40|<2.50 (all)", + "tool:git linux/arm64 vers:intdot/>=2.40|<2.50 (all)", + "tool:git macos/x64 vers:intdot/>=2.40|<2.50 (all)", + "tool:git macos/arm64 vers:intdot/>=2.40|<2.50 (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix", + "/mxc-policy-store-fixture/ssh_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 vers:intdot/>=2.40|<2.50 push", + "tool:git linux/arm64 vers:intdot/>=2.40|<2.50 push", + "tool:git macos/x64 vers:intdot/>=2.40|<2.50 push", + "tool:git macos/arm64 vers:intdot/>=2.40|<2.50 push" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix", + "/mxc-policy-store-fixture/ssh_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 vers:intdot/>=2.50|<3 (all)", + "tool:git linux/arm64 vers:intdot/>=2.50|<3 (all)", + "tool:git macos/x64 vers:intdot/>=2.50|<3 (all)", + "tool:git macos/arm64 vers:intdot/>=2.50|<3 (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root", + "/mxc-policy-store-fixture/temp_dir/git-bundles" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:git linux/x64 vers:intdot/>=2.50|<3 bundle-fetch", + "tool:git linux/arm64 vers:intdot/>=2.50|<3 bundle-fetch", + "tool:git macos/x64 vers:intdot/>=2.50|<3 bundle-fetch", + "tool:git macos/arm64 vers:intdot/>=2.50|<3 bundle-fetch" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root", + "/mxc-policy-store-fixture/temp_dir/git-bundles" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/git_prefix" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + }, + { + "combinations": [ + "tool:node windows/x64 default (all)", + "tool:node windows/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\node_prefix" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:node linux/x64 default (all)", + "tool:node linux/arm64 default (all)", + "tool:node macos/x64 default (all)", + "tool:node macos/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [ + "/mxc-policy-store-fixture/node_prefix" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:npm windows/x64 default (all)", + "tool:npm windows/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "C:\\mxc-policy-store-fixture\\project_root", + "C:\\mxc-policy-store-fixture\\npm_cache" + ], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\npm_prefix", + "C:\\mxc-policy-store-fixture\\node_prefix" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:npm linux/x64 default (all)", + "tool:npm linux/arm64 default (all)", + "tool:npm macos/x64 default (all)", + "tool:npm macos/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [ + "/mxc-policy-store-fixture/project_root", + "/mxc-policy-store-fixture/npm_cache" + ], + "readonlyPaths": [ + "/mxc-policy-store-fixture/npm_prefix", + "/mxc-policy-store-fixture/node_prefix" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:ssh windows/x64 default (all)", + "tool:ssh windows/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [ + "C:\\mxc-policy-store-fixture\\ssh_prefix" + ], + "deniedPaths": [] + } + } + }, + { + "combinations": [ + "tool:ssh linux/x64 default (all)", + "tool:ssh linux/arm64 default (all)", + "tool:ssh macos/x64 default (all)", + "tool:ssh macos/arm64 default (all)" + ], + "document": { + "version": "1.0.0", + "containerId": "mxc-policy-store-validation", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "mxc-policy-store-validation", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [ + "/mxc-policy-store-fixture/ssh_prefix" + ], + "deniedPaths": [] + } + } + } + ] +} diff --git a/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json new file mode 100644 index 000000000..bd81af257 --- /dev/null +++ b/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json @@ -0,0 +1,3065 @@ +{ + "$comment": "Language-neutral conformance cases against the bundled catalog. Every binding (Rust, the C ABI, Node, .NET) must produce the same `expect` for each case from resolveToolRequirementsWithDiagnostics(tools, context), and resolveToolRequirements must return `expect.requirements` (absent when no requirements are resolved). `expectError` cases must fail with that code and reason. Expectations are recorded with `MXC_POLICY_STORE_UPDATE_FIXTURES= cargo test -p mxc-sdk --test policy_store_conformance` and reviewed by hand. An optional `host` describes the fixed test host (platform, nativeArchitecture, symbols, discovered, aliases, unknownPaths); bindings that run on the real host skip cases that set host facts other than platform and architecture.", + "catalog": "bundled", + "cases": [ + { + "name": "git by string shorthand with weak fallback on windows", + "tools": "git.exe", + "context": { + "platform": "windows", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "C:\\work\\repo", + "symbols": { + "git_prefix": "C:\\Program Files\\Git\\cmd", + "programData": "C:\\ProgramData" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "C:\\Program Files\\Git\\cmd", + "C:\\ProgramData\\Git" + ], + "readwritePaths": [ + "C:\\work\\repo" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:git", + "invocationName": "git.exe", + "message": "input 0 ('git.exe') matched tool:git only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "C:\\Program Files\\Git\\cmd", + "source": "caller", + "message": "symbol 'git_prefix' resolved to 'C:\\Program Files\\Git\\cmd' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "C:\\work\\repo", + "source": "caller", + "message": "symbol 'project_root' resolved to 'C:\\work\\repo' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "programData", + "value": "C:\\ProgramData", + "source": "caller", + "message": "symbol 'programData' resolved to 'C:\\ProgramData' from the caller" + } + ] + } + } + }, + { + "name": "node on linux with omitted architecture reports the native-architecture default", + "host": { + "platform": "linux", + "nativeArchitecture": "arm64" + }, + "tools": [ + { + "invocationName": "node" + } + ], + "context": { + "platform": "linux", + "allowWeakIdentityFallback": true, + "symbols": { + "node_prefix": "/usr/local/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/local/bin" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 0 ('node') matched tool:node only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/usr/local/bin", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/usr/local/bin' from the caller" + } + ] + } + } + }, + { + "name": "npm by package URL composes the node dependency on macos, with both npm predicates attributed; a purl-embedded version is ignored", + "tools": { + "invocationName": "npx", + "packageUrl": "pkg:npm/npm@10.9.0" + }, + "context": { + "platform": "macos", + "architecture": "arm64", + "allowWeakIdentityFallback": true, + "projectRoot": "/Users/dev/app/", + "symbols": { + "npm_prefix": "/opt/homebrew/bin", + "npm_cache": "/Users/dev/.npm", + "node_prefix": "/opt/homebrew/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/opt/homebrew/bin" + ], + "readwritePaths": [ + "/Users/dev/app", + "/Users/dev/.npm" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:node", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "purl_components_ignored", + "inputIndex": 0, + "packageUrl": "pkg:npm/npm@10.9.0", + "ignoredComponents": [ + "version" + ], + "message": "input 0 ('npx'): packageUrl version ignored for matching; it is not version evidence (supply detectedVersion)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "npm_prefix", + "value": "/opt/homebrew/bin", + "source": "caller", + "message": "symbol 'npm_prefix' resolved to '/opt/homebrew/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "project_root", + "value": "/Users/dev/app/", + "source": "caller", + "message": "symbol 'project_root' resolved to '/Users/dev/app/' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "npm_cache", + "value": "/Users/dev/.npm", + "source": "caller", + "message": "symbol 'npm_cache' resolved to '/Users/dev/.npm' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/opt/homebrew/bin", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/opt/homebrew/bin' from the caller" + } + ] + } + } + }, + { + "name": "an array of tools composes once per entry: shared dependency and repeated input contribute once", + "tools": [ + "git", + "npm", + "node", + "npm" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "npm_prefix": "/opt/node/bin", + "npm_cache": "/home/u/.npm", + "node_prefix": "/opt/node/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin", + "/opt/node/bin" + ], + "readwritePaths": [ + "/w", + "/home/u/.npm" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + }, + { + "inputIndex": 2, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + }, + { + "inputIndex": 3, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:node", + "entryRevision": 2, + "inputIndexes": [ + 1, + 3 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:git", + "invocationName": "git", + "message": "input 0 ('git') matched tool:git only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:npm", + "invocationName": "npm", + "message": "input 1 ('npm') matched tool:npm only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 2, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 2 ('node') matched tool:node only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 3, + "entryId": "tool:npm", + "invocationName": "npm", + "message": "input 3 ('npm') matched tool:npm only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1, + 3 + ], + "entryIds": [ + "tool:git", + "tool:npm" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1, + 3 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "npm_prefix", + "value": "/opt/node/bin", + "source": "caller", + "message": "symbol 'npm_prefix' resolved to '/opt/node/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1, + 3 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "npm_cache", + "value": "/home/u/.npm", + "source": "caller", + "message": "symbol 'npm_cache' resolved to '/home/u/.npm' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1, + 2, + 3 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/opt/node/bin", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/opt/node/bin' from the caller" + } + ] + } + } + }, + { + "name": "known and unknown inputs: the known requirement composes and the unknown one is reported", + "tools": [ + "cargo", + "git" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "tool_unmatched" + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "invocationName": "cargo", + "message": "input 0 ('cargo') matched no eligible catalog entry" + }, + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:git", + "invocationName": "git", + "message": "input 1 ('git') matched tool:git only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + } + ] + } + } + }, + { + "name": "invocation name alone is not accepted without opt-in", + "tools": "npm", + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "npm_prefix": "/p", + "npm_cache": "/c", + "node_prefix": "/n" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "tool_unmatched" + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "invocationName": "npm", + "message": "input 0 ('npm') matched no eligible catalog entry: tool:npm matched only by invocation name and allowWeakIdentityFallback is not enabled" + } + ] + } + } + }, + { + "name": "empty input array produces no policy and no per-input records", + "tools": [], + "context": { + "platform": "linux", + "architecture": "x64" + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [], + "resolvedDependencies": [], + "warnings": [] + } + } + }, + { + "name": "unresolved required symbol prevents policy output, with diagnostics", + "tools": "git", + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "symbols": { + "git_prefix": "/usr/bin" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:git", + "invocationName": "git", + "message": "input 0 ('git') matched tool:git only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_unresolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "message": "required symbol 'project_root' (needed by tool:git) is unresolved; supply ResolveContext.projectRoot; no requirements were returned" + } + ] + } + } + }, + { + "name": "unavailable explicit revision is an error, not a substitution", + "tools": "git", + "context": { + "platform": "linux", + "architecture": "x64", + "catalogRevision": "2099-01-01.1" + }, + "expectError": { + "code": "backend_error", + "reason": "revision_unavailable" + } + }, + { + "name": "relative symbol value is rejected", + "tools": "node", + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "symbols": { + "node_prefix": "bin" + } + }, + "expectError": { + "code": "malformed_request", + "reason": "invalid_context" + } + }, + { + "name": "read-write supersedes read-only when symbol values make access classes overlap", + "tools": [ + "git", + "node" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/opt", + "symbols": { + "git_prefix": "/usr/bin", + "node_prefix": "/opt/node" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/opt" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:git", + "invocationName": "git", + "message": "input 0 ('git') matched tool:git only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 1 ('node') matched tool:node only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/opt", + "source": "caller", + "message": "symbol 'project_root' resolved to '/opt' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/opt/node", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/opt/node' from the caller" + }, + { + "code": "readonly_superseded", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:git", + "tool:node" + ], + "removed": { + "path": "/opt/node", + "access": "readonly", + "entryIds": [ + "tool:node" + ] + }, + "requiredBy": [ + { + "path": "/opt", + "access": "readwrite", + "entryIds": [ + "tool:git" + ] + } + ], + "message": "read-only '/opt/node' (tool:node) is covered by read-write '/opt' (tool:git); the read-only entry was omitted" + } + ] + } + } + }, + { + "name": "git push without a detected version uses the default and the push intent", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "push" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + } + ] + } + } + }, + { + "name": "git push inside a version range adds the ssh dependency", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "detectedVersion": "2.45.1", + "intent": "push" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin", + "/usr/lib/ssh" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_version", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.45.1", + "selectedVersionRange": "vers:intdot/>=2.40|<2.50" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:ssh", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:ssh" + ], + "symbol": "ssh_prefix", + "value": "/usr/lib/ssh", + "source": "caller", + "message": "symbol 'ssh_prefix' resolved to '/usr/lib/ssh' from the caller" + } + ] + } + } + }, + { + "name": "git bundle-fetch is defined by the 2.50 range", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "detectedVersion": "2.51.0", + "intent": "bundle-fetch" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w", + "/tmp/git-bundles" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_version", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.51.0", + "selectedVersionRange": "vers:intdot/>=2.50|<3" + }, + "intentSelection": { + "requested": "bundle-fetch", + "mode": "named", + "selected": [ + "bundle-fetch" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "temp_dir", + "value": "/tmp", + "source": "caller", + "message": "symbol 'temp_dir' resolved to '/tmp' from the caller" + } + ] + } + } + }, + { + "name": "git bundle-fetch below 2.50 is intent_unsupported and contributes nothing", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "detectedVersion": "2.45.1", + "intent": "bundle-fetch" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "intent_unsupported", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.45.1", + "selectedVersionRange": "vers:intdot/>=2.40|<2.50" + }, + "intentSelection": { + "requested": "bundle-fetch", + "mode": "unsupported", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "intent_unsupported", + "inputIndex": 0, + "entryId": "tool:git", + "intent": "bundle-fetch", + "message": "input 0 ('git'): intent 'bundle-fetch' is not defined for the selected policy of tool:git (available: fetch, local, push); the input contributes nothing" + } + ] + } + } + }, + { + "name": "git out-of-range version with an unsupported intent keeps version_out_of_range in versionSelection", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "detectedVersion": "2.30.0", + "intent": "bundle-fetch" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "intent_unsupported", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "version_out_of_range", + "detectedVersion": "2.30.0" + }, + "intentSelection": { + "requested": "bundle-fetch", + "mode": "unsupported", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "version_out_of_range", + "inputIndex": 0, + "entryId": "tool:git", + "detectedVersion": "2.30.0", + "message": "input 0 ('git'): detected version '2.30.0' is outside every reviewed version range for tool:git; its unversioned default applies" + }, + { + "code": "intent_unsupported", + "inputIndex": 0, + "entryId": "tool:git", + "intent": "bundle-fetch", + "message": "input 0 ('git'): intent 'bundle-fetch' is not defined for the selected policy of tool:git (available: fetch, local, push); the input contributes nothing" + } + ] + } + } + }, + { + "name": "git unparseable version contributes nothing", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "detectedVersion": "banana", + "intent": "fetch" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "version_unparseable", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "version_unparseable", + "detectedVersion": "banana" + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "version_unparseable", + "inputIndex": 0, + "entryId": "tool:git", + "detectedVersion": "banana", + "message": "input 0 ('git'): detected version 'banana' is not a valid intdot version for tool:git; the input contributes nothing" + } + ] + } + } + }, + { + "name": "git without an intent includes base access and every intent of the effective policy", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "detectedVersion": "2.51.0" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w", + "/tmp/git-bundles" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.20/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_version", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.51.0", + "selectedVersionRange": "vers:intdot/>=2.50|<3" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "bundle-fetch", + "fetch", + "local", + "push" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "temp_dir", + "value": "/tmp", + "source": "caller", + "message": "symbol 'temp_dir' resolved to '/tmp' from the caller" + } + ] + } + } + }, + { + "name": "git local intent on windows adds the programData overlay", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "local" + }, + "context": { + "platform": "windows", + "architecture": "x64", + "projectRoot": "C:\\work", + "symbols": { + "git_prefix": "C:\\Program Files\\Git", + "programData": "C:\\ProgramData" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "C:\\Program Files\\Git", + "C:\\ProgramData\\Git" + ], + "readwritePaths": [ + "C:\\work" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "C:\\Program Files\\Git", + "source": "caller", + "message": "symbol 'git_prefix' resolved to 'C:\\Program Files\\Git' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "C:\\work", + "source": "caller", + "message": "symbol 'project_root' resolved to 'C:\\work' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "programData", + "value": "C:\\ProgramData", + "source": "caller", + "message": "symbol 'programData' resolved to 'C:\\ProgramData' from the caller" + } + ] + } + } + }, + { + "name": "pairs compose: local does not veto fetch or push network needs; an unmatched tool resolves alongside", + "tools": [ + { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "local" + }, + { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "fetch" + }, + { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "push" + }, + "cargo" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.10/32" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 22 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "fetch", + "mode": "named", + "selected": [ + "fetch" + ] + } + } + }, + { + "inputIndex": 2, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] + } + } + }, + { + "inputIndex": 3, + "contributes": false, + "status": "tool_unmatched" + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 3, + "invocationName": "cargo", + "message": "input 3 ('cargo') matched no eligible catalog entry" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1, + 2 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1, + 2 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + } + ] + } + } + }, + { + "name": "an empty intent is invalid", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "ssh_prefix": "/usr/lib/ssh", + "temp_dir": "/tmp" + } + }, + "expectError": { + "code": "malformed_request", + "reason": "invalid_context" + } + }, + { + "name": "an invalid candidate package URL leaves the pair unmatched, with no invocation-name retry", + "tools": [ + { + "invocationName": "git", + "packageUrl": "pkg:generic" + }, + { + "invocationName": "node" + } + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "node_prefix": "/opt/node/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/opt/node/bin" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "tool_unmatched" + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "purl_invalid", + "inputIndex": 0, + "packageUrl": "pkg:generic", + "message": "input 0 ('git'): 'pkg:generic' is not a valid package URL; the input was not matched by invocation name either" + }, + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 1 ('node') matched tool:node only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/opt/node/bin", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/opt/node/bin' from the caller" + } + ] + } + } + }, + { + "name": "package URL version, qualifiers, and subpath are ignored for matching, in that order", + "tools": { + "invocationName": "git", + "packageUrl": "pkg:generic/git@2.45.1?arch=x64#bin", + "intent": "local" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "purl_components_ignored", + "inputIndex": 0, + "packageUrl": "pkg:generic/git@2.45.1?arch=x64#bin", + "ignoredComponents": [ + "version", + "qualifiers", + "subpath" + ], + "message": "input 0 ('git'): packageUrl version, qualifiers, subpath ignored for matching; it is not version evidence (supply detectedVersion)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + } + ] + } + } + }, + { + "name": "a caller symbol falls back to discovery, then to the platform default, each reported", + "tools": { + "invocationName": "npm", + "packageUrl": "pkg:npm/npm" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w" + }, + "host": { + "platform": "linux", + "symbols": { + "user_home": "/home/u" + }, + "discovered": { + "npm_prefix": "/usr/lib/node_modules/npm/bin", + "node_prefix": "/usr/bin" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/lib/node_modules/npm/bin", + "/usr/bin" + ], + "readwritePaths": [ + "/w", + "/home/u/.npm" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:node", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "npm_prefix", + "value": "/usr/lib/node_modules/npm/bin", + "source": "discovery", + "message": "symbol 'npm_prefix' resolved to '/usr/lib/node_modules/npm/bin' from host-local discovery" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:npm" + ], + "symbol": "npm_cache", + "value": "/home/u/.npm", + "source": "default", + "message": "symbol 'npm_cache' resolved to '/home/u/.npm' from the contract's platform default" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/usr/bin", + "source": "discovery", + "message": "symbol 'node_prefix' resolved to '/usr/bin' from host-local discovery" + } + ] + } + } + }, + { + "name": "unknown filesystem identity drops every pair owning either side; other pairs still resolve", + "tools": [ + { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "local" + }, + "node", + "ssh" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "node_prefix": "/opt/node/bin", + "ssh_prefix": "/usr/lib/ssh" + } + }, + "host": { + "platform": "linux", + "unknownPaths": [ + "/opt/node/bin" + ] + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/lib/ssh" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "filesystem_identity_unresolved", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": false, + "status": "filesystem_identity_unresolved", + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + }, + { + "inputIndex": 2, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:ssh", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 1 ('node') matched tool:node only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 2, + "entryId": "tool:ssh", + "invocationName": "ssh", + "message": "input 2 ('ssh') matched tool:ssh only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/opt/node/bin", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/opt/node/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 2 + ], + "entryIds": [ + "tool:ssh" + ], + "symbol": "ssh_prefix", + "value": "/usr/lib/ssh", + "source": "caller", + "message": "symbol 'ssh_prefix' resolved to '/usr/lib/ssh' from the caller" + }, + { + "code": "filesystem_identity_unresolved", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:git", + "tool:node" + ], + "paths": [ + "/opt/node/bin", + "/w" + ], + "platform": "linux", + "message": "filesystem object identity of '/opt/node/bin' and '/w' could not be established on linux; the requesting tools contribute nothing" + } + ] + } + } + }, + { + "name": "a read-only alias of an object required read-write is retained with read-write access", + "tools": [ + { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "local" + }, + "node" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin", + "node_prefix": "/srv/alias-of-w" + } + }, + "host": { + "platform": "linux", + "aliases": [ + [ + "/srv/alias-of-w", + "/w" + ] + ] + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/usr/bin" + ], + "readwritePaths": [ + "/w", + "/srv/alias-of-w" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 1 ('node') matched tool:node only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "/usr/bin", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/usr/bin' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 1 + ], + "entryIds": [ + "tool:node" + ], + "symbol": "node_prefix", + "value": "/srv/alias-of-w", + "source": "caller", + "message": "symbol 'node_prefix' resolved to '/srv/alias-of-w' from the caller" + }, + { + "code": "readonly_superseded", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:git", + "tool:node" + ], + "removed": { + "path": "/srv/alias-of-w", + "access": "readonly", + "entryIds": [ + "tool:node" + ] + }, + "requiredBy": [ + { + "path": "/w", + "access": "readwrite", + "entryIds": [ + "tool:git" + ] + } + ], + "message": "read-only '/srv/alias-of-w' (tool:node) names the same filesystem object as read-write '/w' (tool:git) or one beneath it; the alias pathname was retained with read-write access" + } + ] + } + } + }, + { + "name": "a foreign target platform cannot establish filesystem identity and fails closed", + "tools": [ + { + "invocationName": "git", + "packageUrl": "pkg:generic/git", + "intent": "local" + } + ], + "context": { + "platform": "windows", + "architecture": "x64", + "projectRoot": "C:\\w", + "symbols": { + "git_prefix": "C:\\Git\\cmd", + "programData": "C:\\ProgramData" + } + }, + "host": { + "platform": "linux" + }, + "expect": { + "diagnostics": { + "catalogRevision": "2026-10-06.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "filesystem_identity_unresolved", + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "git_prefix", + "value": "C:\\Git\\cmd", + "source": "caller", + "message": "symbol 'git_prefix' resolved to 'C:\\Git\\cmd' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "project_root", + "value": "C:\\w", + "source": "caller", + "message": "symbol 'project_root' resolved to 'C:\\w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "symbol": "programData", + "value": "C:\\ProgramData", + "source": "caller", + "message": "symbol 'programData' resolved to 'C:\\ProgramData' from the caller" + }, + { + "code": "filesystem_identity_unresolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "paths": [ + "C:\\Git\\cmd", + "C:\\w" + ], + "platform": "windows", + "message": "filesystem object identity of 'C:\\Git\\cmd' and 'C:\\w' could not be established on windows; the requesting tools contribute nothing" + }, + { + "code": "filesystem_identity_unresolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:git" + ], + "paths": [ + "C:\\ProgramData\\Git", + "C:\\w" + ], + "platform": "windows", + "message": "filesystem object identity of 'C:\\ProgramData\\Git' and 'C:\\w' could not be established on windows; the requesting tools contribute nothing" + } + ] + } + } + } + ] +} diff --git a/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json new file mode 100644 index 000000000..dadfbeb7e --- /dev/null +++ b/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json @@ -0,0 +1,842 @@ +{ + "$comment": "Composition fixtures: dependency intent references and catalog egress-deny removal. Outcomes are recorded with MXC_POLICY_STORE_UPDATE_FIXTURES and reviewed by hand.", + "catalog": { + "catalogSchemaVersion": "1", + "catalogRevision": "2000-01-02.1", + "sdkContractVersion": "1.0.0", + "entries": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "displayName": "Credential helper", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "helper" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/helper" + ] + } + }, + "intents": { + "store": { + "exampleSubcommands": [ + "store" + ], + "policyAdditions": { + "filesystem": { + "readwritePaths": [ + "${temp_dir}/helper-store" + ] + } + } + }, + "sync": { + "exampleSubcommands": [ + "sync" + ], + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "203.0.113.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + } + } + }, + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } + }, + { + "entryId": "tool:fetcher", + "entryRevision": 1, + "displayName": "Fetcher", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "fetcher" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/fetcher" + ] + }, + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "dependencies": [ + { + "entryId": "tool:helper", + "intents": [ + "sync" + ] + } + ] + }, + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } + }, + { + "entryId": "tool:locker", + "entryRevision": 1, + "displayName": "Locker", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "locker" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/locker" + ] + }, + "network": { + "egress": { + "deny": [ + { + "to": [ + { + "cidr": "192.0.2.0/23" + } + ] + }, + { + "to": [ + { + "cidr": "198.51.100.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 25 + } + ] + } + ] + } + } + }, + "dependencies": [ + { + "entryId": "tool:helper" + } + ] + }, + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } + } + ] + }, + "cases": [ + { + "name": "a dependency reference that names intents contributes the dependency base plus those intents", + "tools": "fetcher", + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/fetcher", + "/g/helper" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "203.0.113.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-02.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:fetcher", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "named", + "selected": [ + "sync" + ] + } + } + ], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:fetcher", + "invocationName": "fetcher", + "message": "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:fetcher", + "tool:helper" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "a plain dependency reference contributes only the dependency base (intent mode none)", + "tools": "locker", + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/locker", + "/g/helper" + ] + }, + "network": { + "egress": { + "deny": [ + { + "to": [ + { + "cidr": "192.0.2.0/23" + } + ] + }, + { + "to": [ + { + "cidr": "198.51.100.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 25 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-02.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:locker", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:locker", + "invocationName": "locker", + "message": "input 0 ('locker') matched tool:locker only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:helper", + "tool:locker" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "the dependency requested directly without an intent contributes all of its intents", + "tools": [ + "helper", + "fetcher" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/helper", + "/g/fetcher" + ], + "readwritePaths": [ + "/tmp/helper-store" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "203.0.113.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-02.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:helper", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "store", + "sync" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:fetcher", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "inputIndexes": [ + 1 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "named", + "selected": [ + "sync" + ] + } + } + ], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:helper", + "invocationName": "helper", + "message": "input 0 ('helper') matched tool:helper only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:fetcher", + "invocationName": "fetcher", + "message": "input 1 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:fetcher", + "tool:helper" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:helper" + ], + "symbol": "temp_dir", + "value": "/tmp", + "source": "caller", + "message": "symbol 'temp_dir' resolved to '/tmp' from the caller" + } + ] + } + } + }, + { + "name": "a catalog egress deny that overlaps another tool's required allow is removed in full; a non-overlapping deny stays", + "tools": [ + "fetcher", + "locker" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/fetcher", + "/g/helper", + "/g/locker" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "203.0.113.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ], + "deny": [ + { + "to": [ + { + "cidr": "198.51.100.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 25 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-02.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:fetcher", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:locker", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "inputIndexes": [ + 1 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + }, + { + "entryId": "tool:helper", + "entryRevision": 1, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "named", + "selected": [ + "sync" + ] + } + } + ], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:fetcher", + "invocationName": "fetcher", + "message": "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + }, + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:locker", + "invocationName": "locker", + "message": "input 1 ('locker') matched tool:locker only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:fetcher", + "tool:helper", + "tool:locker" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + }, + { + "code": "network_deny_removed", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:fetcher", + "tool:locker" + ], + "removed": { + "rule": { + "to": [ + { + "cidr": "192.0.2.0/23" + } + ] + }, + "entryIds": [ + "tool:locker" + ] + }, + "requiredBy": [ + { + "rule": { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + "entryIds": [ + "tool:fetcher" + ] + } + ], + "message": "removed catalog egress deny [192.0.2.0/23; any protocol and port] (tool:locker) because it overlaps required egress allow [192.0.2.0/24; tcp/443] (tool:fetcher); the entire deny rule was removed, so other grants may now apply throughout [192.0.2.0/23; any protocol and port]" + } + ] + } + } + } + ] +} diff --git a/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json new file mode 100644 index 000000000..81506360f --- /dev/null +++ b/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json @@ -0,0 +1,1997 @@ +{ + "$comment": "Language-neutral conformance cases against an inline synthetic catalog revision (not published). Covers strong and weak identity, ambiguity, version selection (matched, out of range, unparseable), intent selection, native-architecture defaults, exact vs neutral overlays, dependency records, platform-cased invocation names, and floor composition. See bundled-catalog.json for the case format. `host` overrides the simulated host (default linux/x64).", + "catalog": { + "catalogSchemaVersion": "1", + "catalogRevision": "2000-01-01.1", + "sdkContractVersion": "1.0.0", + "entries": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "displayName": "Base runtime", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "base" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/share" + ] + } + } + }, + "platformVariants": [ + { + "when": { + "platform": "linux" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/lib" + ] + } + } + }, + { + "when": { + "platform": "windows", + "architecture": "arm64" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}\\arm64" + ] + } + } + }, + { + "when": { + "platform": "windows", + "architecture": "x64" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}\\x64" + ] + } + } + } + ], + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } + }, + { + "entryId": "tool:app-plugin", + "entryRevision": 1, + "displayName": "App plugin host", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "app" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/plugins" + ] + } + } + }, + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } + }, + { + "entryId": "tool:app", + "entryRevision": 5, + "displayName": "App", + "versionScheme": "npm", + "identity": [ + { + "kind": "purl", + "value": "pkg:npm/app" + }, + { + "kind": "invocation-name", + "names": [ + "app", + "app.cmd" + ] + } + ], + "default": { + "requirements": { + "filesystem": { + "readwritePaths": [ + "${project_root}" + ] + } + }, + "dependencies": [ + { + "entryId": "tool:base" + } + ], + "intents": { + "build": { + "exampleSubcommands": [ + "build" + ], + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/tools" + ] + } + } + }, + "serve": { + "exampleSubcommands": [ + "serve" + ], + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + } + } + }, + "platformVariants": [ + { + "when": { + "platform": "linux", + "architecture": "arm64" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/lib-arm64" + ] + } + } + } + ], + "versionVariants": [ + { + "versionRange": "vers:npm/>=2.0.0|<3.0.0", + "dependencies": [ + { + "entryId": "tool:base", + "versionRange": "vers:semver/>=22.0.0" + } + ], + "intentAdditions": { + "serve": { + "policyAdditions": { + "network": { + "egress": { + "allow": [ + { + "to": [ + { + "cidr": "198.51.100.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + } + } + } + }, + { + "versionRange": "vers:npm/>=3.0.0", + "newIntents": { + "publish": { + "exampleSubcommands": [ + "publish" + ], + "policyAdditions": { + "filesystem": { + "readwritePaths": [ + "${temp_dir}/app-publish" + ] + } + } + } + } + } + ], + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } + } + ] + }, + "cases": [ + { + "name": "version inside a range on an arm64 overlay, build intent, with the range's dependency record", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "detectedVersion": "2.4.1", + "intent": "build" + }, + "context": { + "platform": "linux", + "architecture": "arm64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib", + "/g/lib-arm64", + "/g/tools" + ], + "readwritePaths": [ + "/w" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_version", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.4.1", + "selectedVersionRange": "vers:npm/>=2.0.0|<3.0.0" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + }, + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "requiredVersionRange": "vers:semver/>=22.0.0", + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "no detected version and no intent: default base plus all default intents", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib", + "/g/tools" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "build", + "serve" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "architecture_fallback", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "valid version outside every range uses the default with a structured warning", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "detectedVersion": "1.0.0", + "intent": "serve" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "version_out_of_range", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "version_out_of_range", + "detectedVersion": "1.0.0" + }, + "intentSelection": { + "requested": "serve", + "mode": "named", + "selected": [ + "serve" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "version_out_of_range", + "inputIndex": 0, + "entryId": "tool:app", + "detectedVersion": "1.0.0", + "message": "input 0 ('app'): detected version '1.0.0' is outside every reviewed version range for tool:app; its unversioned default applies" + }, + { + "code": "architecture_fallback", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "unparseable version contributes nothing", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "detectedVersion": "not a version", + "intent": "serve" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "version_unparseable", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "version_unparseable", + "detectedVersion": "not a version" + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "version_unparseable", + "inputIndex": 0, + "entryId": "tool:app", + "detectedVersion": "not a version", + "message": "input 0 ('app'): detected version 'not a version' is not a valid npm version for tool:app; the input contributes nothing" + } + ] + } + } + }, + { + "name": "intent added by the 3.x range", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "detectedVersion": "3.1.0", + "intent": "publish" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib" + ], + "readwritePaths": [ + "/w", + "/tmp/app-publish" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_version", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "3.1.0", + "selectedVersionRange": "vers:npm/>=3.0.0" + }, + "intentSelection": { + "requested": "publish", + "mode": "named", + "selected": [ + "publish" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "architecture_fallback", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "temp_dir", + "value": "/tmp", + "source": "caller", + "message": "symbol 'temp_dir' resolved to '/tmp' from the caller" + } + ] + } + } + }, + { + "name": "intent the 2.x effective policy does not define is intent_unsupported", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "detectedVersion": "2.4.1", + "intent": "publish" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "intent_unsupported", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.4.1", + "selectedVersionRange": "vers:npm/>=2.0.0|<3.0.0" + }, + "intentSelection": { + "requested": "publish", + "mode": "unsupported", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "intent_unsupported", + "inputIndex": 0, + "entryId": "tool:app", + "intent": "publish", + "message": "input 0 ('app'): intent 'publish' is not defined for the selected policy of tool:app (available: build, serve); the input contributes nothing" + } + ] + } + } + }, + { + "name": "intentAdditions extend an existing intent inside the range", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "detectedVersion": "2.4.1", + "intent": "serve" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + }, + { + "to": [ + { + "cidr": "198.51.100.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_version", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_version", + "detectedVersion": "2.4.1", + "selectedVersionRange": "vers:npm/>=2.0.0|<3.0.0" + }, + "intentSelection": { + "requested": "serve", + "mode": "named", + "selected": [ + "serve" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + }, + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "requiredVersionRange": "vers:semver/>=22.0.0", + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "architecture_fallback", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "a version embedded in packageUrl is ignored with a warning", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app@2.4.1", + "intent": "build" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib", + "/g/tools" + ], + "readwritePaths": [ + "/w" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "purl_components_ignored", + "inputIndex": 0, + "packageUrl": "pkg:npm/app@2.4.1", + "ignoredComponents": [ + "version" + ], + "message": "input 0 ('app'): packageUrl version ignored for matching; it is not version evidence (supply detectedVersion)" + }, + { + "code": "architecture_fallback", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "equal-rank weak matches to different entries are ambiguous", + "tools": "app", + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + }, + "allowWeakIdentityFallback": true + }, + "expectError": { + "code": "policy_validation", + "reason": "ambiguous_match" + } + }, + { + "name": "omitted architecture on an ARM64 host selects the native ARM64 overlay", + "host": { + "platform": "windows", + "nativeArchitecture": "arm64" + }, + "tools": "base", + "context": { + "allowWeakIdentityFallback": true, + "symbols": { + "git_prefix": "C:\\g" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "C:\\g\\share", + "C:\\g\\arm64" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:base", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:base", + "invocationName": "base", + "message": "input 0 ('base') matched tool:base only by invocation name (weak identity)" + }, + { + "code": "architecture_default", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:base" + ], + "platform": "windows", + "architecture": "arm64", + "message": "architecture was not specified; overlays were selected for the native system architecture 'arm64'; the tool's architecture was not verified" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:base" + ], + "symbol": "git_prefix", + "value": "C:\\g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to 'C:\\g' from the caller" + } + ] + } + } + }, + { + "name": "explicit x64 on the same ARM64 host selects the x64 overlay", + "host": { + "platform": "windows", + "nativeArchitecture": "arm64" + }, + "tools": "base", + "context": { + "architecture": "x64", + "allowWeakIdentityFallback": true, + "symbols": { + "git_prefix": "C:\\g" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "C:\\g\\share", + "C:\\g\\x64" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:base", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:base", + "invocationName": "base", + "message": "input 0 ('base') matched tool:base only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:base" + ], + "symbol": "git_prefix", + "value": "C:\\g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to 'C:\\g' from the caller" + } + ] + } + } + }, + { + "name": "invocation names are case-insensitive on windows", + "tools": "APP.CMD", + "context": { + "platform": "windows", + "architecture": "arm64", + "allowWeakIdentityFallback": true, + "projectRoot": "C:\\p", + "symbols": { + "git_prefix": "C:\\g" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "C:\\g\\share", + "C:\\g\\arm64", + "C:\\g\\tools" + ], + "readwritePaths": [ + "C:\\p" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "build", + "serve" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:app", + "invocationName": "APP.CMD", + "message": "input 0 ('APP.CMD') matched tool:app only by invocation name (weak identity)" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "C:\\p", + "source": "caller", + "message": "symbol 'project_root' resolved to 'C:\\p' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "symbol": "git_prefix", + "value": "C:\\g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to 'C:\\g' from the caller" + } + ] + } + } + }, + { + "name": "invocation names are case-sensitive on linux", + "tools": "BASE", + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + }, + "allowWeakIdentityFallback": true + }, + "expect": { + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "tool_unmatched" + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "invocationName": "BASE", + "message": "input 0 ('BASE') matched no eligible catalog entry" + } + ] + } + } + }, + { + "name": "a tool without network does not veto another tool's network needs", + "tools": [ + { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "intent": "serve" + }, + { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "intent": "build" + } + ], + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib", + "/g/tools" + ], + "readwritePaths": [ + "/w" + ] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "192.0.2.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "serve", + "mode": "named", + "selected": [ + "serve" + ] + } + } + }, + { + "inputIndex": 1, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0, + 1 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "architecture_fallback", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/w", + "source": "caller", + "message": "symbol 'project_root' resolved to '/w' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0, + 1 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + } + ] + } + } + }, + { + "name": "read-write supersedes read-only when symbol values overlap", + "tools": { + "invocationName": "app", + "packageUrl": "pkg:npm/app", + "intent": "build" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/g", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "requirements": { + "filesystem": { + "readwritePaths": [ + "/g" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": true, + "status": "matched_default", + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] + } + } + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "inputIndexes": [ + 0 + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "architecture_fallback", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "platform": "linux", + "architecture": "x64", + "selected": "default", + "message": "tool:app has no x64-specific linux overlay; its architecture-neutral default data was used" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "symbol": "project_root", + "value": "/g", + "source": "caller", + "message": "symbol 'project_root' resolved to '/g' from the caller" + }, + { + "code": "symbol_resolved", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "symbol": "git_prefix", + "value": "/g", + "source": "caller", + "message": "symbol 'git_prefix' resolved to '/g' from the caller" + }, + { + "code": "readonly_superseded", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "removed": { + "path": "/g/share", + "access": "readonly", + "entryIds": [ + "tool:base" + ] + }, + "requiredBy": [ + { + "path": "/g", + "access": "readwrite", + "entryIds": [ + "tool:app" + ] + } + ], + "message": "read-only '/g/share' (tool:base) is covered by read-write '/g' (tool:app); the read-only entry was omitted" + }, + { + "code": "readonly_superseded", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app", + "tool:base" + ], + "removed": { + "path": "/g/lib", + "access": "readonly", + "entryIds": [ + "tool:base" + ] + }, + "requiredBy": [ + { + "path": "/g", + "access": "readwrite", + "entryIds": [ + "tool:app" + ] + } + ], + "message": "read-only '/g/lib' (tool:base) is covered by read-write '/g' (tool:app); the read-only entry was omitted" + }, + { + "code": "readonly_superseded", + "inputIndexes": [ + 0 + ], + "entryIds": [ + "tool:app" + ], + "removed": { + "path": "/g/tools", + "access": "readonly", + "entryIds": [ + "tool:app" + ] + }, + "requiredBy": [ + { + "path": "/g", + "access": "readwrite", + "entryIds": [ + "tool:app" + ] + } + ], + "message": "read-only '/g/tools' (tool:app) is covered by read-write '/g' (tool:app); the read-only entry was omitted" + } + ] + } + } + }, + { + "name": "a candidate without packageUrl needs the weak-identity opt-in", + "tools": { + "invocationName": "app", + "intent": "build" + }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + } + }, + "expect": { + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "contributes": false, + "status": "tool_unmatched" + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "invocationName": "app", + "message": "input 0 ('app') matched no eligible catalog entry: tool:app matched only by invocation name and allowWeakIdentityFallback is not enabled; tool:app-plugin matched only by invocation name and allowWeakIdentityFallback is not enabled" + } + ] + } + } + } + ] +} diff --git a/src/mxc-sdk/policy_store/conformance/vectors/canonical-json.json b/src/mxc-sdk/policy_store/conformance/vectors/canonical-json.json new file mode 100644 index 000000000..0de6b68cf --- /dev/null +++ b/src/mxc-sdk/policy_store/conformance/vectors/canonical-json.json @@ -0,0 +1,41 @@ +{ + "$comment": "Canonical JSON (originally generated by the removed TypeScript prototype): object keys sorted by UTF-16 code units, arrays in order, no whitespace, JSON.stringify string escaping, and ECMAScript Number::toString for every number. Parse `json`, canonicalize, and compare with `canonical`.", + "cases": [ + { + "json": "{\"b\":1,\"a\":[3,2,{\"d\":null,\"c\":true}]}", + "canonical": "{\"a\":[3,2,{\"c\":true,\"d\":null}],\"b\":1}" + }, + { + "json": "{\"\\u00e9\":1,\"z\":2,\"Z\":3,\"\\ue000\":4,\"\\ud83d\\ude00\":5,\"\":6}", + "canonical": "{\"\":6,\"Z\":3,\"z\":2,\"é\":1,\"😀\":5,\"\":4}" + }, + { + "json": "[\"tab\\t\",\"nl\\n\",\"q\\\"\",\"bs\\\\\",\"ctl\\u0001\\u001f\\u007f\",\"uni \\u00e9 \\ud83d\\ude00\",\"/\",\"\\u2028\"]", + "canonical": "[\"tab\\t\",\"nl\\n\",\"q\\\"\",\"bs\\\\\",\"ctl\\u0001\\u001f\",\"uni é 😀\",\"/\",\"
\"]" + }, + { + "json": "[0,-0,1,1.0,-1,1.5,0.1,1e21,1e-7,123456789012345680000,12345678901234567890,5e-324,1.7976931348623157e308,100,1e20,0.000001,2.5e-7,-1234.5678,1E2,-0.0,0.30000000000000004]", + "canonical": "[0,0,1,1,-1,1.5,0.1,1e+21,1e-7,123456789012345680000,12345678901234567000,5e-324,1.7976931348623157e+308,100,100000000000000000000,0.000001,2.5e-7,-1234.5678,100,0,0.30000000000000004]" + }, + { + "json": "{\"nested\":{\"k\":[],\"o\":{}}}", + "canonical": "{\"nested\":{\"k\":[],\"o\":{}}}" + }, + { + "json": "\"plain\"", + "canonical": "\"plain\"" + }, + { + "json": "42", + "canonical": "42" + }, + { + "json": "false", + "canonical": "false" + }, + { + "json": "null", + "canonical": "null" + } + ] +} diff --git a/src/mxc-sdk/policy_store/conformance/vectors/paths.json b/src/mxc-sdk/policy_store/conformance/vectors/paths.json new file mode 100644 index 000000000..9c9088127 --- /dev/null +++ b/src/mxc-sdk/policy_store/conformance/vectors/paths.json @@ -0,0 +1,475 @@ +{ + "$comment": "Generated by scripts/generate-vectors.mjs. Path rules every binding reproduces exactly: Node path.win32/path.posix isAbsolute and normalize, then trailing-separator removal unless only the root remains. keySegments applies the per-OS casing rule (fold on windows and macos). normalized is null for a relative input; the resolver rejects those before normalizing.", + "windows": [ + { + "input": "C:\\Tools", + "absolute": true, + "normalized": "C:\\Tools", + "keySegments": [ + "c:", + "tools" + ] + }, + { + "input": "c:\\tools\\", + "absolute": true, + "normalized": "c:\\tools", + "keySegments": [ + "c:", + "tools" + ] + }, + { + "input": "C:\\a\\.\\b\\..\\c", + "absolute": true, + "normalized": "C:\\a\\c", + "keySegments": [ + "c:", + "a", + "b", + "..", + "c" + ] + }, + { + "input": "C:/a//b/", + "absolute": true, + "normalized": "C:\\a\\b", + "keySegments": [ + "c:", + "a", + "b" + ] + }, + { + "input": "C:\\", + "absolute": true, + "normalized": "C:\\", + "keySegments": [ + "c:" + ] + }, + { + "input": "C:\\\\", + "absolute": true, + "normalized": "C:\\", + "keySegments": [ + "c:" + ] + }, + { + "input": "C:\\..\\x", + "absolute": true, + "normalized": "C:\\x", + "keySegments": [ + "c:", + "..", + "x" + ] + }, + { + "input": "\\\\server\\share\\x\\", + "absolute": true, + "normalized": "\\\\server\\share\\x", + "keySegments": [ + "", + "server", + "share", + "x" + ] + }, + { + "input": "\\\\server\\share", + "absolute": true, + "normalized": "\\\\server\\share\\", + "keySegments": [ + "", + "server", + "share" + ] + }, + { + "input": "D:\\src", + "absolute": true, + "normalized": "D:\\src", + "keySegments": [ + "d:", + "src" + ] + }, + { + "input": "C:\\Program Files\\Git\\cmd", + "absolute": true, + "normalized": "C:\\Program Files\\Git\\cmd", + "keySegments": [ + "c:", + "program files", + "git", + "cmd" + ] + }, + { + "input": "C:\\a\\..\\..\\b", + "absolute": true, + "normalized": "C:\\b", + "keySegments": [ + "c:", + "a", + "..", + "..", + "b" + ] + }, + { + "input": "C:\\x\\y\\.", + "absolute": true, + "normalized": "C:\\x\\y", + "keySegments": [ + "c:", + "x", + "y" + ] + }, + { + "input": "/usr/bin", + "absolute": true, + "normalized": "\\usr\\bin", + "keySegments": [ + "", + "usr", + "bin" + ] + }, + { + "input": "\\rooted\\", + "absolute": true, + "normalized": "\\rooted", + "keySegments": [ + "", + "rooted" + ] + }, + { + "input": "C:\\Été", + "absolute": true, + "normalized": "C:\\Été", + "keySegments": [ + "c:", + "été" + ] + }, + { + "input": "C:rel", + "absolute": false, + "normalized": null, + "keySegments": [ + "c:rel" + ] + }, + { + "input": "rel\\x", + "absolute": false, + "normalized": null, + "keySegments": [ + "rel", + "x" + ] + }, + { + "input": "", + "absolute": false, + "normalized": null, + "keySegments": [ + "" + ] + } + ], + "linux": [ + { + "input": "/usr/bin", + "absolute": true, + "normalized": "/usr/bin", + "keySegments": [ + "", + "usr", + "bin" + ] + }, + { + "input": "/usr//bin/", + "absolute": true, + "normalized": "/usr/bin", + "keySegments": [ + "", + "usr", + "bin" + ] + }, + { + "input": "/a/./b/../c", + "absolute": true, + "normalized": "/a/c", + "keySegments": [ + "", + "a", + "b", + "..", + "c" + ] + }, + { + "input": "/", + "absolute": true, + "normalized": "/", + "keySegments": [ + "" + ] + }, + { + "input": "//", + "absolute": true, + "normalized": "/", + "keySegments": [ + "" + ] + }, + { + "input": "///x", + "absolute": true, + "normalized": "/x", + "keySegments": [ + "", + "x" + ] + }, + { + "input": "/Users/dev/app/", + "absolute": true, + "normalized": "/Users/dev/app", + "keySegments": [ + "", + "Users", + "dev", + "app" + ] + }, + { + "input": "/a/../../b", + "absolute": true, + "normalized": "/b", + "keySegments": [ + "", + "a", + "..", + "..", + "b" + ] + }, + { + "input": "/x/y/.", + "absolute": true, + "normalized": "/x/y", + "keySegments": [ + "", + "x", + "y" + ] + }, + { + "input": "/Tools", + "absolute": true, + "normalized": "/Tools", + "keySegments": [ + "", + "Tools" + ] + }, + { + "input": "/..", + "absolute": true, + "normalized": "/", + "keySegments": [ + "", + ".." + ] + }, + { + "input": "/Été", + "absolute": true, + "normalized": "/Été", + "keySegments": [ + "", + "Été" + ] + }, + { + "input": "rel/x", + "absolute": false, + "normalized": null, + "keySegments": [ + "rel", + "x" + ] + }, + { + "input": "./x", + "absolute": false, + "normalized": null, + "keySegments": [ + "x" + ] + }, + { + "input": "", + "absolute": false, + "normalized": null, + "keySegments": [ + "" + ] + } + ], + "macos": [ + { + "input": "/usr/bin", + "absolute": true, + "normalized": "/usr/bin", + "keySegments": [ + "", + "usr", + "bin" + ] + }, + { + "input": "/usr//bin/", + "absolute": true, + "normalized": "/usr/bin", + "keySegments": [ + "", + "usr", + "bin" + ] + }, + { + "input": "/a/./b/../c", + "absolute": true, + "normalized": "/a/c", + "keySegments": [ + "", + "a", + "b", + "..", + "c" + ] + }, + { + "input": "/", + "absolute": true, + "normalized": "/", + "keySegments": [ + "" + ] + }, + { + "input": "//", + "absolute": true, + "normalized": "/", + "keySegments": [ + "" + ] + }, + { + "input": "///x", + "absolute": true, + "normalized": "/x", + "keySegments": [ + "", + "x" + ] + }, + { + "input": "/Users/dev/app/", + "absolute": true, + "normalized": "/Users/dev/app", + "keySegments": [ + "", + "users", + "dev", + "app" + ] + }, + { + "input": "/a/../../b", + "absolute": true, + "normalized": "/b", + "keySegments": [ + "", + "a", + "..", + "..", + "b" + ] + }, + { + "input": "/x/y/.", + "absolute": true, + "normalized": "/x/y", + "keySegments": [ + "", + "x", + "y" + ] + }, + { + "input": "/Tools", + "absolute": true, + "normalized": "/Tools", + "keySegments": [ + "", + "tools" + ] + }, + { + "input": "/..", + "absolute": true, + "normalized": "/", + "keySegments": [ + "", + ".." + ] + }, + { + "input": "/Été", + "absolute": true, + "normalized": "/Été", + "keySegments": [ + "", + "été" + ] + }, + { + "input": "rel/x", + "absolute": false, + "normalized": null, + "keySegments": [ + "rel", + "x" + ] + }, + { + "input": "./x", + "absolute": false, + "normalized": null, + "keySegments": [ + "x" + ] + }, + { + "input": "", + "absolute": false, + "normalized": null, + "keySegments": [ + "" + ] + } + ] +} diff --git a/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json b/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json new file mode 100644 index 000000000..a36309002 --- /dev/null +++ b/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json @@ -0,0 +1,499 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.invalid/policy-catalog/catalog.v1.schema.json", + "title": "Known-tool policy catalog revision (catalogSchemaVersion 1)", + "description": "Shape of one published catalog revision. Semantic rules (exactly one unversioned default per entry, additive-only overlays, non-overlapping version ranges, identity uniqueness, dependency closure, cycle freedom, symbol validity, unsafe paths, materialization of every effective policy and its validation against the SDK target contract, composition limits) are enforced by the catalog validator, not by this schema.", + "type": "object", + "additionalProperties": false, + "required": [ + "catalogSchemaVersion", + "catalogRevision", + "sdkContractVersion", + "entries" + ], + "properties": { + "catalogSchemaVersion": { + "const": "1" + }, + "catalogRevision": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*$" + }, + "sdkContractVersion": { + "const": "1.0.0", + "description": "The published SDK validation target of this revision (catalog metadata, not a caller-selected wire version)." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + } + }, + "$defs": { + "entry": { + "type": "object", + "additionalProperties": false, + "required": [ + "entryId", + "entryRevision", + "displayName", + "versionScheme", + "identity", + "default", + "provenance" + ], + "properties": { + "entryId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$" + }, + "entryRevision": { + "type": "integer", + "minimum": 1 + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "versionScheme": { + "enum": [ + "npm", + "semver", + "pypi", + "nuget", + "intdot" + ] + }, + "identity": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/identity" + } + }, + "default": { + "$ref": "#/$defs/default" + }, + "platformVariants": { + "type": "array", + "items": { + "$ref": "#/$defs/platformVariant" + } + }, + "versionVariants": { + "type": "array", + "items": { + "$ref": "#/$defs/versionVariant" + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "sourceRevision" + ], + "properties": { + "method": { + "type": "string", + "minLength": 1 + }, + "sourceRevision": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "identity": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "const": "purl" + }, + "value": { + "type": "string", + "pattern": "^pkg:", + "description": "A package URL without a version; versions select versionVariants." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "names" + ], + "properties": { + "kind": { + "const": "invocation-name" + }, + "names": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + ] + }, + "default": { + "type": "object", + "additionalProperties": false, + "required": [ + "requirements" + ], + "properties": { + "requirements": { + "$ref": "#/$defs/requirements" + }, + "dependencies": { + "$ref": "#/$defs/dependencies" + }, + "intents": { + "$ref": "#/$defs/intents" + } + } + }, + "platformVariant": { + "type": "object", + "additionalProperties": false, + "required": [ + "when" + ], + "properties": { + "when": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform" + ], + "properties": { + "platform": { + "enum": [ + "windows", + "linux", + "macos" + ] + }, + "architecture": { + "enum": [ + "x64", + "arm64" + ] + } + } + }, + "policyAdditions": { + "$ref": "#/$defs/policyAdditions" + }, + "dependencies": { + "$ref": "#/$defs/dependencies" + }, + "intentAdditions": { + "description": "Additions to intents the default declares.", + "$ref": "#/$defs/intents" + }, + "newIntents": { + "description": "Intents the default does not declare.", + "$ref": "#/$defs/intents" + } + } + }, + "versionVariant": { + "type": "object", + "additionalProperties": false, + "required": [ + "versionRange" + ], + "properties": { + "versionRange": { + "type": "string", + "pattern": "^vers:", + "description": "A vers range in the entry's versionScheme; ranges must not overlap." + }, + "policyAdditions": { + "$ref": "#/$defs/policyAdditions" + }, + "dependencies": { + "$ref": "#/$defs/dependencies" + }, + "intentAdditions": { + "description": "Additions to intents the default declares.", + "$ref": "#/$defs/intents" + }, + "newIntents": { + "description": "Intents the default does not declare.", + "$ref": "#/$defs/intents" + } + } + }, + "intents": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/intent" + } + }, + "intent": { + "type": "object", + "additionalProperties": false, + "properties": { + "exampleSubcommands": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "policyAdditions": { + "$ref": "#/$defs/policyAdditions" + }, + "dependencies": { + "$ref": "#/$defs/dependencies" + } + } + }, + "policyAdditions": { + "description": "Additive policy data. v1 allows only read-only and read-write paths and outbound allow rules.", + "type": "object", + "additionalProperties": false, + "properties": { + "filesystem": { + "type": "object", + "additionalProperties": false, + "properties": { + "readonlyPaths": { + "$ref": "#/$defs/pathList" + }, + "readwritePaths": { + "$ref": "#/$defs/pathList" + } + } + }, + "network": { + "type": "object", + "additionalProperties": false, + "properties": { + "egress": { + "type": "object", + "additionalProperties": false, + "properties": { + "allow": { + "type": "array", + "items": { + "$ref": "#/$defs/networkRule" + } + } + } + } + } + } + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "entryId" + ], + "properties": { + "entryId": { + "type": "string" + }, + "versionRange": { + "type": "string", + "pattern": "^vers:", + "description": "A vers range in the target entry's versionScheme. Recorded in diagnostics; it does not select a version variant." + }, + "intents": { + "description": "Intents of the target to add to its base. Without it, a dependency contributes its base only.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + } + } + } + } + }, + "pathList": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "networkAction": { + "enum": [ + "allow", + "deny" + ] + }, + "networkRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "to": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "cidr" + ], + "properties": { + "cidr": { + "type": "string" + }, + "except": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "ports": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "protocol": { + "enum": [ + "tcp", + "udp", + "icmp", + "any" + ] + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "endPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + } + } + }, + "requirements": { + "description": "Command-free MXC 1.0 request access fields (ContainerRequirements): the three filesystem path lists, directional network policy, v1 UI fields, and an unsigned 32-bit timeoutMs. Commands, wire versions, backend configuration, runtime proxy values, lifecycle, and environment data are rejected.", + "type": "object", + "additionalProperties": false, + "properties": { + "filesystem": { + "type": "object", + "additionalProperties": false, + "properties": { + "deniedPaths": { + "$ref": "#/$defs/pathList" + }, + "readonlyPaths": { + "$ref": "#/$defs/pathList" + }, + "readwritePaths": { + "$ref": "#/$defs/pathList" + } + } + }, + "network": { + "type": "object", + "additionalProperties": false, + "properties": { + "egress": { + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/networkAction" + }, + "allow": { + "type": "array", + "items": { + "$ref": "#/$defs/networkRule" + } + }, + "deny": { + "type": "array", + "items": { + "$ref": "#/$defs/networkRule" + } + } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/networkAction" + }, + "hostLoopback": { + "$ref": "#/$defs/networkAction" + } + } + } + } + }, + "ui": { + "type": "object", + "additionalProperties": false, + "required": [ + "disable" + ], + "properties": { + "disable": { + "type": "boolean" + }, + "clipboard": { + "enum": [ + "none", + "read", + "write", + "all" + ] + }, + "allowInputInjection": { + "type": "boolean" + } + } + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + } + } + } + } +} diff --git a/src/mxc-sdk/policy_store/schema/manifest.v1.schema.json b/src/mxc-sdk/policy_store/schema/manifest.v1.schema.json new file mode 100644 index 000000000..8ccd228c5 --- /dev/null +++ b/src/mxc-sdk/policy_store/schema/manifest.v1.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.invalid/policy-catalog/manifest.v1.schema.json", + "title": "Published catalog revision manifest (catalogSchemaVersion 1)", + "description": "Append-only list of published, immutable catalog revisions. V1 has no separate catalog digest; the data is compiled into the MXC native library and inherits MXC package signing.", + "type": "object", + "additionalProperties": false, + "required": ["catalogSchemaVersion", "defaultRevision", "revisions"], + "properties": { + "catalogSchemaVersion": { "const": "1" }, + "defaultRevision": { "type": "string" }, + "revisions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["catalogRevision", "file"], + "properties": { + "catalogRevision": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*$" }, + "file": { "type": "string", "pattern": "^revisions/[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*\\.json$" } + } + } + } + } +} diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index 99e3d1c2c..12bd0e0d1 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -202,6 +202,7 @@ pub use core_modules::process_security_environment_spec; mod configs; mod options; mod policy; +mod policy_store; mod sandbox; mod state_aware_sdk; @@ -296,9 +297,29 @@ pub mod v1 { } } + /// **Prototype, pending API review.** Policy-store lookup inputs, + /// diagnostics, and catalog metadata for [`resolve_tool_requirements`]. + pub mod tool_requirements { + pub use crate::policy_store::{ + Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, + CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, + DependencyRecord, DetailWarningKind, Diagnostics, IdentityStrength, IntentMode, + IntentSelection, MatchedIdentity, NetworkRequirement, PathAccess, PathRequirement, + Platform, PlatformVariantMetadata, Provenance, PurlComponent, ResolutionDetailWarning, + ResolveContext, SymbolMap, SymbolValueSource, ToolCandidate, ToolRecord, + ToolResolutionStatus, ToolResolutionWarning, ToolSelection, ToolWarningKind, + VersionScheme, VersionSelection, VersionStatus, VersionVariantMetadata, Warning, + }; + } + + pub use crate::policy_store::sdk::{ + get_catalog_info, list_catalog_entries, resolve_tool_requirements, + resolve_tool_requirements_with_diagnostics, ToolRequirementsResolution, + }; + pub use crate::policy::{ - ClipboardPolicy, ContainerRequest, Containment, FilesystemPolicy, NetworkAction, - NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeerPolicy, NetworkPolicy, + ClipboardPolicy, ContainerRequest, ContainerRequirements, Containment, FilesystemPolicy, + NetworkAction, NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeerPolicy, NetworkPolicy, NetworkPortPolicy, NetworkProtocol, NetworkRulePolicy, NetworkRuntimeConfig, UiPolicy, }; pub use crate::sandbox::{ @@ -374,6 +395,13 @@ pub mod v1 { } } +/// **Prototype, pending API review.** Internal policy-store surface for the C +/// ABI and the catalog contribution tests; not an SDK authoring API. +#[doc(hidden)] +pub mod __policy_store { + pub use crate::policy_store::*; +} + /// Internal adapters for the C ABI and native contract tests, not SDK authoring APIs. #[doc(hidden)] pub mod __ffi { diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index b10f81382..85b98cd2f 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -645,6 +645,25 @@ pub(crate) struct ContainerPolicy { pub timeout_ms: Option, } +/// The command-free access fields of a [`ContainerRequest`]: filesystem, +/// network, UI, and timeout, using the same section types. +/// +/// **Prototype, pending API review.** Returned by the policy store +/// ([`crate::v1::resolve_tool_requirements`]) as a best-effort floor that +/// callers review, constrain, and then combine with a command through +/// [`ContainerRequest::from_requirements`]. +#[derive(Debug, Clone, Default)] +pub struct ContainerRequirements { + /// Cross-backend filesystem restrictions. + pub filesystem: Option, + /// Cross-backend network restrictions. + pub network: Option, + /// Cross-backend UI restrictions. + pub ui: Option, + /// Execution timeout in milliseconds. + pub timeout_ms: Option, +} + /// A complete one-shot request with shared restrictions and backend settings. #[derive(Debug, Clone)] pub struct ContainerRequest { @@ -687,6 +706,66 @@ impl ContainerRequest { inherit_default_environment: None, } } + + /// Create a one-shot request for `command` with the given access fields, + /// without converting their nested types. + pub fn from_requirements( + requirements: ContainerRequirements, + command: impl Into, + ) -> Self { + Self { + filesystem: requirements.filesystem, + network: requirements.network, + ui: requirements.ui, + timeout_ms: requirements.timeout_ms, + ..Self::new(command) + } + } +} + +/// Validates requirements against the SDK target contract (design §4.2 of the +/// policy store): the typed sections build the exact `OneShotRequest` +/// through the SDK hook, a disposable copy is normalized, and the independent +/// exact JSON serialization `wire` must parse under its declared version to +/// the same normalized request. +pub(crate) fn check_requirements_exact( + requirements: &ContainerRequirements, + wire: &str, + command: &str, + container_id: &str, +) -> Result<(), String> { + use crate::mxc_common::config_parser::load_mxc_request_from_json; + use crate::mxc_common::state_aware_request::MxcRequest; + let policy = ContainerPolicy { + filesystem: requirements.filesystem.clone(), + network: requirements.network.clone(), + ui: requirements.ui.clone(), + timeout_ms: requirements.timeout_ms, + }; + let exact = exact::build_exact_one_shot(&policy, &Containment::Process, command, container_id) + .map_err(|e| format!("the SDK cannot build the exact request: {e}"))?; + let built = exact::normalize_exact_one_shot(exact) + .map_err(|e| format!("the exact request fails normalization: {e}"))?; + let mut logger = + crate::mxc_common::logger::Logger::new(crate::mxc_common::logger::Mode::Buffer); + let parsed = match load_mxc_request_from_json(wire, &mut logger) { + Ok(MxcRequest::OneShot(request)) => request, + Ok(_) => return Err("the exact serialization is not a one-shot request".to_string()), + Err(e) => return Err(format!("the exact serialization is rejected: {e:?}")), + }; + let intent = |request: &ExecutionRequest| { + let mut value = serde_json::to_value(request).map_err(|e| e.to_string())?; + if let Some(object) = value.as_object_mut() { + object.remove("source_contract"); + } + Ok::<_, String>(value) + }; + if intent(&built)? != intent(&parsed)? { + return Err( + "the exact serialization and the typed SDK request normalize differently".to_string(), + ); + } + Ok(()) } /// Internal normalized request consumed by the execution engine. diff --git a/src/mxc-sdk/src/policy/exact/mod.rs b/src/mxc-sdk/src/policy/exact/mod.rs index 81b463215..b3d41ad20 100644 --- a/src/mxc-sdk/src/policy/exact/mod.rs +++ b/src/mxc-sdk/src/policy/exact/mod.rs @@ -93,6 +93,36 @@ pub(super) fn build_request( }) } +/// The SDK target's exact `OneShotRequest` before normalization: the +/// validation hook catalog tooling uses (`build_request` returns +/// normalized data). +pub(super) fn build_exact_one_shot( + policy: &ContainerPolicy, + containment: &Containment, + script: &str, + container_id: &str, +) -> Result { + if script.is_empty() { + return Err(error("script parameter is required")); + } + v1_0::build(&PreparedInput { + policy, + containment, + script, + container_id: container_id.to_string(), + }) +} + +/// Runs shared semantic normalization on an exact request, which the caller +/// passes by value so the original stays untouched. +pub(super) fn normalize_exact_one_shot( + request: crate::mxc_contract::published::v1_0_0::OneShotRequest, +) -> Result { + let mut logger = Logger::new(Mode::Buffer); + load_one_shot_request_from_contract(ExactOneShotContract::V1_0(Box::new(request)), &mut logger) + .map_err(|error| MxcError::malformed_request(error.to_string())) +} + #[cfg(test)] mod tests { use crate::configs::ProcessContainerNetwork; diff --git a/src/mxc-sdk/src/policy_store/assemble.rs b/src/mxc-sdk/src/policy_store/assemble.rs new file mode 100644 index 000000000..710fb40c4 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/assemble.rs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Catalog source assembly (design §6.3). Authors edit one JSON file per tool +//! under `catalog/entries/`; this module assembles those files into the +//! complete candidate revision that `revisions/.json` +//! snapshots. Paths and file order never affect the result: entries are +//! ordered by `entryId`, and a path only appears in error messages. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use crate::policy_store::catalog::SDK_CONTRACT_VERSION; +use crate::policy_store::json::{Json, JsonObject}; + +/// One editable entry source: a display path (for errors only) and its text. +#[derive(Clone, Debug)] +pub struct EntrySource { + pub path: String, + pub text: String, +} + +/// Reads every `*.json` file under `dir`, recursively. Category +/// subdirectories are organizational only. +pub fn collect_entry_sources(dir: &Path) -> std::io::Result> { + fn walk(root: &Path, dir: &Path, out: &mut Vec) -> std::io::Result<()> { + for item in std::fs::read_dir(dir)? { + let path: PathBuf = item?.path(); + if path.is_dir() { + walk(root, &path, out)?; + } else if path.extension().is_some_and(|e| e == "json") { + let display = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + out.push(EntrySource { + path: display, + text: std::fs::read_to_string(&path)?, + }); + } + } + Ok(()) + } + let mut out = Vec::new(); + walk(dir, dir, &mut out)?; + Ok(out) +} + +/// Every `dependencies[].entryId` anywhere in an entry: the default, intents, +/// and every overlay. +fn dependency_references(value: &Json, out: &mut BTreeSet) { + match value { + Json::Object(object) => { + for (key, child) in object.iter() { + if key == "dependencies" { + for item in child.as_array().into_iter().flatten() { + if let Some(id) = item.get("entryId").and_then(Json::as_str) { + out.insert(id.to_string()); + } + } + } + dependency_references(child, out); + } + } + Json::Array(items) => items + .iter() + .for_each(|item| dependency_references(item, out)), + _ => {} + } +} + +/// Assembles entry sources into the revision document for `catalog_revision`. +/// +/// Checks that every source is one entry object with a string `entryId`, that +/// `entryId` values are globally unique, and that every dependency reference +/// names an assembled entry. Full semantic validation of the result remains +/// `validate_catalog_revision`'s job. Errors are sorted for stable output. +pub fn assemble_revision( + sources: &[EntrySource], + catalog_schema_version: &str, + catalog_revision: &str, +) -> Result> { + let mut errors = Vec::new(); + let mut entries: BTreeMap = BTreeMap::new(); + for source in sources { + let value = match Json::parse(&source.text) { + Ok(value) => value, + Err(error) => { + errors.push(format!("{}: invalid JSON: {error}", source.path)); + continue; + } + }; + let Some(id) = value + .as_object() + .and_then(|o| o.get("entryId")) + .and_then(Json::as_str) + .map(str::to_string) + else { + errors.push(format!( + "{}: must be one catalog entry object with a string 'entryId'", + source.path + )); + continue; + }; + if let Some((first, _)) = entries.get(&id) { + let (a, b) = if first.as_str() <= source.path.as_str() { + (first.as_str(), source.path.as_str()) + } else { + (source.path.as_str(), first.as_str()) + }; + errors.push(format!("entryId '{id}' is defined in both {a} and {b}")); + continue; + } + entries.insert(id, (source.path.clone(), value)); + } + for (id, (path, value)) in &entries { + let mut references = BTreeSet::new(); + dependency_references(value, &mut references); + for reference in references { + if !entries.contains_key(&reference) { + errors.push(format!( + "{path}: entry '{id}' depends on '{reference}', which no entry file defines" + )); + } + } + } + if !errors.is_empty() { + errors.sort(); + return Err(errors); + } + let mut root = JsonObject::new(); + root.insert("catalogSchemaVersion", catalog_schema_version.into()); + root.insert("catalogRevision", catalog_revision.into()); + root.insert("sdkContractVersion", SDK_CONTRACT_VERSION.into()); + root.insert( + "entries", + Json::Array(entries.into_values().map(|(_, value)| value).collect()), + ); + Ok(Json::Object(root)) +} + +/// The generated snapshot text for an assembled revision. +pub fn render_revision(revision: &Json) -> String { + format!("{}\n", revision.to_pretty_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source(path: &str, text: &str) -> EntrySource { + EntrySource { + path: path.into(), + text: text.into(), + } + } + + fn ids(revision: &Json) -> Vec { + revision + .get("entries") + .and_then(Json::as_array) + .unwrap() + .iter() + .map(|e| e.get("entryId").and_then(Json::as_str).unwrap().to_string()) + .collect() + } + + const A: &str = r#"{"entryId":"tool:a","default":{"dependencies":[{"entryId":"tool:b"}]}}"#; + const B: &str = r#"{"entryId":"tool:b","default":{}}"#; + + #[test] + fn orders_entries_by_id_whatever_the_paths() { + let forward = assemble_revision(&[source("a.json", A), source("b.json", B)], "1", "r"); + let moved = assemble_revision( + &[source("x/z/b.json", B), source("y/0-a.json", A)], + "1", + "r", + ); + let forward = render_revision(&forward.unwrap()); + assert_eq!(forward, render_revision(&moved.unwrap())); + let revision = Json::parse(&forward).unwrap(); + assert_eq!(ids(&revision), ["tool:a", "tool:b"]); + assert_eq!( + revision.get("catalogRevision").and_then(Json::as_str), + Some("r") + ); + assert_eq!( + revision.get("sdkContractVersion").and_then(Json::as_str), + Some(SDK_CONTRACT_VERSION) + ); + } + + #[test] + fn rejects_a_duplicate_entry_id() { + let errors = assemble_revision( + &[ + source("b.json", B), + source("dev/b.json", B), + source("a.json", A), + ], + "1", + "r", + ) + .unwrap_err(); + assert_eq!( + errors, + ["entryId 'tool:b' is defined in both b.json and dev/b.json"] + ); + } + + #[test] + fn rejects_a_dangling_dependency_anywhere_in_an_entry() { + let errors = assemble_revision(&[source("a.json", A)], "1", "r").unwrap_err(); + assert_eq!( + errors, + ["a.json: entry 'tool:a' depends on 'tool:b', which no entry file defines"] + ); + let nested = r#"{"entryId":"tool:c","versionVariants":[{"intentAdditions": + {"push":{"dependencies":[{"entryId":"tool:missing","intents":["x"]}]}}}]}"#; + let errors = assemble_revision(&[source("c.json", nested)], "1", "r").unwrap_err(); + assert_eq!( + errors, + ["c.json: entry 'tool:c' depends on 'tool:missing', which no entry file defines"] + ); + } + + #[test] + fn rejects_a_file_that_is_not_one_entry() { + let errors = assemble_revision( + &[source("list.json", "[]"), source("bad.json", "{")], + "1", + "r", + ) + .unwrap_err(); + assert_eq!(errors.len(), 2); + assert!(errors[0].starts_with("bad.json: invalid JSON")); + assert!(errors[1].starts_with("list.json: must be one catalog entry object")); + } +} diff --git a/src/mxc-sdk/src/policy_store/catalog.rs b/src/mxc-sdk/src/policy_store/catalog.rs new file mode 100644 index 000000000..aa4a2dcff --- /dev/null +++ b/src/mxc-sdk/src/policy_store/catalog.rs @@ -0,0 +1,1403 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Catalog contract, entry model (one unversioned default plus additive +//! platform and version overlays), and contract/revision validation. The +//! effective-policy materialization lives in [`crate::policy_store::effective`]. + +use crate::policy_store::errors::{invalid_catalog, Result}; +use crate::policy_store::json::{js_number_to_string, js_to_string, Json, JsonObject}; +use crate::policy_store::model::{Architecture, IdentityStrength, Platform}; +use crate::policy_store::paths::is_absolute_path; +use crate::policy_store::purl::parse_purl; +use crate::policy_store::text::{is_symbol_name, js_to_lower, replace_symbols, symbol_matches}; +use crate::policy_store::vers::{VersRange, VersionScheme}; +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; + +pub const CATALOG_SCHEMA_VERSION: &str = "1"; + +/// Fields the v1 contract can compose across entries (design §4.5), in order. +pub const COMPOSABLE_FILESYSTEM_FIELDS: [&str; 3] = + ["deniedPaths", "readonlyPaths", "readwritePaths"]; + +const BACKEND_KEYS: [&str; 8] = [ + "containment", + "processContainer", + "appContainer", + "lxc", + "seatbelt", + "wslc", + "hyperlight", + "bwrap", +]; + +/// Where a symbol's value comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SymbolSource { + Context, + Caller, + Host, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SymbolDefinition { + pub source: SymbolSource, + pub description: String, + /// Documented per-platform default templates (design §4.2). They may + /// reference only host-known symbols. + pub defaults: Vec<(Platform, String)>, +} + +impl SymbolDefinition { + pub fn default_for(&self, platform: Platform) -> Option<&str> { + self.defaults + .iter() + .find(|(p, _)| *p == platform) + .map(|(_, template)| template.as_str()) + } +} + +/// The SDK contract version this SDK build emits for `ContainerRequest` +/// (`schemas/schema-version.json` `sdkMajorTargets`). Every catalog +/// revision this SDK bundles must target it. +pub const SDK_CONTRACT_VERSION: &str = "1.0.0"; + +/// Validated catalog contract. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogContract { + pub catalog_schema_version: String, + symbols: Vec<(String, SymbolDefinition)>, +} + +impl CatalogContract { + /// Own-key symbol lookup. + pub fn symbol(&self, name: &str) -> Option<&SymbolDefinition> { + self.symbols.iter().find(|(n, _)| n == name).map(|(_, d)| d) + } + + pub fn symbols(&self) -> impl Iterator { + self.symbols.iter().map(|(n, d)| (n.as_str(), d)) + } +} + +/// Embedded, validated `default.requirements` (the four access fields of a +/// v1 `ContainerRequest`). The reviewed JSON object is kept as written so +/// field order and values are preserved exactly. +#[derive(Clone, Debug, PartialEq)] +pub struct CatalogPolicy { + raw: JsonObject, +} + +impl CatalogPolicy { + pub fn raw(&self) -> &JsonObject { + &self.raw + } + + pub fn has_filesystem(&self) -> bool { + self.raw.contains_key("filesystem") + } + + /// The templates of one filesystem access class (empty when absent). + pub fn filesystem_field(&self, field: &str) -> Vec<&str> { + self.raw + .get("filesystem") + .and_then(|fs| fs.get(field)) + .and_then(Json::as_array) + .map(|items| items.iter().filter_map(Json::as_str).collect()) + .unwrap_or_default() + } + + pub fn field(&self, key: &str) -> Option<&Json> { + self.raw.get(key) + } +} + +/// One identity predicate of an entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IdentityPredicate { + Purl { value: String }, + InvocationName { names: Vec }, +} + +impl IdentityPredicate { + pub fn kind(&self) -> &'static str { + match self { + IdentityPredicate::Purl { .. } => "purl", + IdentityPredicate::InvocationName { .. } => "invocation-name", + } + } + + pub fn strength(&self) -> IdentityStrength { + match self { + IdentityPredicate::Purl { .. } => IdentityStrength::Strong, + IdentityPredicate::InvocationName { .. } => IdentityStrength::Weak, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Dependency { + pub entry_id: String, + /// A `vers` range in the target entry's scheme. It is recorded in + /// diagnostics; it does not select a version overlay. + pub version_range: Option, + /// Intents of the target whose additions the reference adds to the + /// target's base. `None` contributes the base only. + pub intents: Option>, +} + +/// Additive policy data (`policyAdditions`): v1 allows only read-only and +/// read-write paths and outbound allow rules. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Additions { + pub readonly_paths: Vec, + pub readwrite_paths: Vec, + pub egress_allow: Vec, +} + +impl Additions { + pub fn is_empty(&self) -> bool { + self.readonly_paths.is_empty() + && self.readwrite_paths.is_empty() + && self.egress_allow.is_empty() + } +} + +/// An intent declaration, or an extension of an inherited intent. +#[derive(Clone, Debug, PartialEq)] +pub struct IntentDefinition { + pub example_subcommands: Option>, + pub additions: Additions, + pub dependencies: Vec, +} + +/// The additions a platform or version overlay makes to the default. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Overlay { + pub policy_additions: Additions, + pub dependencies: Vec, + /// Extensions of intents the default declares. + pub intent_additions: Vec<(String, IntentDefinition)>, + /// Intents the default does not declare (`newIntents`). + pub new_intents: Vec<(String, IntentDefinition)>, +} + +/// The unversioned default every entry has exactly once. +#[derive(Clone, Debug, PartialEq)] +pub struct EntryDefault { + pub requirements: CatalogPolicy, + pub dependencies: Vec, + pub intents: Vec<(String, IntentDefinition)>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PlatformVariant { + pub platform: Platform, + pub architecture: Option, + pub overlay: Overlay, +} + +#[derive(Clone, Debug)] +pub struct VersionVariant { + pub version_range: VersRange, + pub overlay: Overlay, +} + +impl PartialEq for VersionVariant { + fn eq(&self, other: &Self) -> bool { + self.version_range.as_str() == other.version_range.as_str() && self.overlay == other.overlay + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatalogEntry { + pub entry_id: String, + pub entry_revision: f64, + pub display_name: String, + pub version_scheme: VersionScheme, + pub identity: Vec, + pub default: EntryDefault, + pub platform_variants: Vec, + pub version_variants: Vec, + pub provenance_method: String, + pub provenance_source_revision: String, + /// The validated entry as JSON (history comparisons use its canonical form). + pub(crate) raw: Json, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatalogRevision { + pub catalog_schema_version: String, + pub catalog_revision: String, + /// The SDK `ContainerRequest` contract version the revision targets. + pub sdk_contract_version: String, + pub entries: Vec, +} + +pub(crate) fn fail(message: impl AsRef) -> Result { + Err(invalid_catalog(message)) +} + +fn record(value: Option<&Json>) -> Option<&JsonObject> { + value.and_then(Json::as_object) +} + +fn only_fields(value: &JsonObject, allowed: &[&str], at: &str) -> Result<()> { + for key in value.keys() { + if !allowed.contains(&key) { + return fail(format!("unsupported field '{at}.{key}'")); + } + } + Ok(()) +} + +fn non_empty_string(value: Option<&Json>, at: &str) -> Result { + match value.and_then(Json::as_str) { + Some(s) if !s.is_empty() => Ok(s.to_string()), + _ => fail(format!("'{at}' must be a non-empty string")), + } +} + +fn string_array(value: Option<&Json>, at: &str, min_items: usize) -> Result> { + let items = match value.and_then(Json::as_array) { + Some(items) if items.len() >= min_items => items, + _ => { + return fail(format!( + "'{at}' must be an array with at least {min_items} item(s)" + )) + } + }; + items + .iter() + .enumerate() + .map(|(index, item)| non_empty_string(Some(item), &format!("{at}[{index}]"))) + .collect() +} + +fn is_integer(value: f64) -> bool { + value.is_finite() && value.fract() == 0.0 +} + +/// `/^(\d{4}-\d{2}-\d{2})\.([1-9]\d*)$/` → (date, sequence). +fn parse_revision_id(value: &str) -> Option<(&str, &str)> { + let b = value.as_bytes(); + if b.len() < 12 { + return None; + } + let digit = |i: usize| b[i].is_ascii_digit(); + let date_ok = (0..4).all(digit) + && b[4] == b'-' + && (5..7).all(digit) + && b[7] == b'-' + && (8..10).all(digit); + if !date_ok + || b[10] != b'.' + || !(b'1'..=b'9').contains(&b[11]) + || !b[12..].iter().all(u8::is_ascii_digit) + { + return None; + } + Some((&value[..10], &value[11..])) +} + +/// Compares two catalog revision identifiers (`YYYY-MM-DD.N`). +pub fn compare_catalog_revisions(left: &str, right: &str) -> Result { + match (parse_revision_id(left), parse_revision_id(right)) { + (Some((ld, ln)), Some((rd, rn))) => { + if ld != rd { + return Ok(ld.cmp(rd)); + } + let (l, r): (f64, f64) = ( + ln.parse().unwrap_or(f64::NAN), + rn.parse().unwrap_or(f64::NAN), + ); + Ok(l.partial_cmp(&r).unwrap_or(Ordering::Equal)) + } + _ => fail(format!( + "cannot compare malformed catalog revisions '{left}' and '{right}'" + )), + } +} + +pub fn is_catalog_revision_id(value: &str) -> bool { + parse_revision_id(value).is_some() +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +pub fn validate_contract(raw: &Json) -> Result { + let Some(raw) = raw.as_object() else { + return fail("contract root must be an object"); + }; + only_fields( + raw, + &["$comment", "catalogSchemaVersion", "symbols"], + "contract", + )?; + if raw.get("catalogSchemaVersion").and_then(Json::as_str) != Some(CATALOG_SCHEMA_VERSION) { + return fail(format!( + "contract.catalogSchemaVersion must be '{CATALOG_SCHEMA_VERSION}'" + )); + } + let Some(raw_symbols) = record(raw.get("symbols")) else { + return fail("contract.symbols must be an object"); + }; + let mut symbols = Vec::new(); + for (name, definition) in raw_symbols.iter() { + let definition = match definition.as_object() { + Some(d) if is_symbol_name(name) => d, + _ => return fail(format!("contract.symbols.{name} is malformed")), + }; + only_fields( + definition, + &["source", "description", "defaults"], + &format!("contract.symbols.{name}"), + )?; + let source = match definition.get("source").and_then(Json::as_str) { + Some("context") => SymbolSource::Context, + Some("caller") => SymbolSource::Caller, + Some("host") => SymbolSource::Host, + _ => return fail(format!("contract.symbols.{name}.source is unsupported")), + }; + let description = non_empty_string( + definition.get("description"), + &format!("contract.symbols.{name}.description"), + )?; + let mut defaults = Vec::new(); + if let Some(raw_defaults) = definition.get("defaults") { + let at = format!("contract.symbols.{name}.defaults"); + let Some(raw_defaults) = raw_defaults.as_object() else { + return fail(format!("{at} must be an object")); + }; + if source == SymbolSource::Context { + return fail(format!( + "{at}: a context symbol comes only from ResolveContext.projectRoot" + )); + } + for (platform, template) in raw_defaults.iter() { + let Some(parsed) = Platform::parse(platform) else { + return fail(format!("{at}.{platform} is not a catalog platform")); + }; + let template = non_empty_string(Some(template), &format!("{at}.{platform}"))?; + defaults.push((parsed, template)); + } + } + symbols.push(( + name.to_string(), + SymbolDefinition { + source, + description, + defaults, + }, + )); + } + let contract = CatalogContract { + catalog_schema_version: CATALOG_SCHEMA_VERSION.to_string(), + symbols, + }; + // Default templates may reference only host-known symbols and must form + // an absolute path on their platform once those are substituted. + for (name, definition) in contract.symbols() { + for (platform, template) in &definition.defaults { + let at = format!("contract.symbols.{name}.defaults.{platform}"); + validate_template_path(template, &at, &contract)?; + for (_, _, referenced) in symbol_matches(template) { + if contract.symbol(referenced).map(|d| d.source) != Some(SymbolSource::Host) { + return fail(format!( + "'{at}' references '{referenced}', which is not a host-known symbol" + )); + } + } + let probe = replace_symbols(template, |_| match platform { + Platform::Windows => "C:\\x".to_string(), + _ => "/x".to_string(), + }); + if !is_absolute_path(&probe, *platform) { + return fail(format!("'{at}' does not form an absolute {platform} path")); + } + } + } + Ok(contract) +} + +// --------------------------------------------------------------------------- +// Paths and symbols +// --------------------------------------------------------------------------- + +pub(crate) fn validate_template_path( + value: &str, + at: &str, + contract: &CatalogContract, +) -> Result<()> { + if value.contains(['*', '?']) { + return fail(format!("'{at}' contains a wildcard")); + } + if replace_symbols(value, |_| String::new()).contains("${") { + return fail(format!("'{at}' contains malformed symbol syntax")); + } + let matches = symbol_matches(value); + for (_, _, name) in &matches { + if contract.symbol(name).is_none() { + return fail(format!("'{at}' references unknown symbol '{name}'")); + } + } + let anchored = matches.first().is_some_and(|(start, end, _)| { + *start == 0 && (value.len() == *end || value[*end..].starts_with(['/', '\\'])) + }); + if !anchored { + return fail(format!( + "'{at}' must start with a declared symbol; literal paths are not allowed" + )); + } + if value.split(['/', '\\']).any(|segment| segment == "..") { + return fail(format!("'{at}' must not contain '..' segments")); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Embedded requirements (v1 ContainerRequest access fields) +// --------------------------------------------------------------------------- + +fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> { + let Some(rules) = value.as_array() else { + return fail(format!("'{at}' must be an array")); + }; + for (index, rule) in rules.iter().enumerate() { + let rule_at = format!("{at}[{index}]"); + let Some(rule) = rule.as_object() else { + return fail(format!("'{rule_at}' must be an object")); + }; + only_fields(rule, &["to", "ports"], &rule_at)?; + if !rule.contains_key("to") && !deny_list { + return fail(format!( + "'{rule_at}' has no 'to'; wildcard network grants are not allowed" + )); + } + if let Some(to) = rule.get("to") { + let peers = match to.as_array() { + Some(peers) if !peers.is_empty() => peers, + _ => return fail(format!("'{rule_at}.to' must be a non-empty array")), + }; + for (peer_index, peer) in peers.iter().enumerate() { + let peer_at = format!("{rule_at}.to[{peer_index}]"); + let Some(peer) = peer.as_object() else { + return fail(format!("'{peer_at}' must be an object")); + }; + only_fields(peer, &["cidr", "except"], &peer_at)?; + let cidr = non_empty_string(peer.get("cidr"), &format!("{peer_at}.cidr"))?; + let Some(block) = crate::policy_store::netrule::parse_cidr(&cidr) else { + return fail(format!("'{peer_at}.cidr' '{cidr}' is not a valid CIDR")); + }; + if !deny_list && cidr.ends_with("/0") { + return fail(format!("'{peer_at}.cidr' is a wildcard network grant")); + } + if let Some(except) = peer.get("except") { + let except_at = format!("{peer_at}.except"); + for (i, value) in string_array(Some(except), &except_at, 0)? + .iter() + .enumerate() + { + let inside = crate::policy_store::netrule::parse_cidr(value) + .is_some_and(|excluded| block.contains(excluded)); + if !inside { + return fail(format!( + "'{except_at}[{i}]' '{value}' must be a CIDR of the same family within '{cidr}'" + )); + } + } + } + } + } + if let Some(ports) = rule.get("ports") { + let ports = match ports.as_array() { + Some(ports) if !ports.is_empty() => ports, + _ => return fail(format!("'{rule_at}.ports' must be a non-empty array")), + }; + for (port_index, port) in ports.iter().enumerate() { + let port_at = format!("{rule_at}.ports[{port_index}]"); + let Some(port) = port.as_object() else { + return fail(format!("'{port_at}' must be an object")); + }; + only_fields(port, &["protocol", "port", "endPort"], &port_at)?; + if let Some(protocol) = port.get("protocol") { + if !matches!(protocol.as_str(), Some("tcp" | "udp" | "icmp" | "any")) { + return fail(format!("'{port_at}.protocol' is unsupported")); + } + if protocol.as_str() == Some("icmp") + && (port.contains_key("port") || port.contains_key("endPort")) + { + return fail(format!("'{port_at}' must not set a port for icmp")); + } + } + for key in ["port", "endPort"] { + if let Some(n) = port.get(key) { + let ok = n + .as_f64() + .is_some_and(|n| is_integer(n) && (1.0..=65535.0).contains(&n)); + if !ok { + return fail(format!( + "'{port_at}.{key}' must be an integer in 1..65535" + )); + } + } + } + if let Some(end) = port.get("endPort").and_then(Json::as_f64) { + let bad = match port.get("port").and_then(Json::as_f64) { + None => true, + Some(start) => end < start, + }; + if bad { + return fail(format!( + "'{port_at}.endPort' requires a lower or equal 'port'" + )); + } + } + } + } + } + Ok(()) +} + +pub(crate) fn validate_requirements( + raw: Option<&Json>, + at: &str, + contract: &CatalogContract, +) -> Result { + let Some(raw) = record(raw) else { + return fail(format!("'{at}' must be an object")); + }; + for key in BACKEND_KEYS { + if raw.contains_key(key) { + return fail(format!( + "'{at}.{key}' names a containment backend; platform variants must stay backend-neutral" + )); + } + } + for (key, reason) in [ + ("command", "commands are supplied by the caller"), + ("version", "the SDK owns the wire version"), + ("lifecycle", "lifecycle settings are caller-owned"), + ("environment", "environment data is caller-owned"), + ("env", "environment data is caller-owned"), + ] { + if raw.contains_key(key) { + return fail(format!("'{at}.{key}' is not an access field; {reason}")); + } + } + only_fields(raw, &["filesystem", "network", "ui", "timeoutMs"], at)?; + if let Some(filesystem) = raw.get("filesystem") { + let Some(filesystem) = filesystem.as_object() else { + return fail(format!("'{at}.filesystem' must be an object")); + }; + only_fields( + filesystem, + &COMPOSABLE_FILESYSTEM_FIELDS, + &format!("{at}.filesystem"), + )?; + for field in COMPOSABLE_FILESYSTEM_FIELDS { + if let Some(values) = filesystem.get(field) { + let field_at = format!("{at}.filesystem.{field}"); + for (index, value) in string_array(Some(values), &field_at, 0)?.iter().enumerate() { + validate_template_path(value, &format!("{field_at}[{index}]"), contract)?; + } + } + } + } + if let Some(network) = raw.get("network") { + let Some(network) = network.as_object() else { + return fail(format!("'{at}.network' must be an object")); + }; + if network.contains_key("runtimeConfig") || network.contains_key("proxy") { + return fail(format!( + "'{at}.network' must not carry runtime proxy values; they are caller-owned" + )); + } + only_fields(network, &["egress", "ingress"], &format!("{at}.network"))?; + if let Some(egress) = network.get("egress") { + let Some(egress) = egress.as_object() else { + return fail(format!("'{at}.network.egress' must be an object")); + }; + only_fields( + egress, + &["default", "allow", "deny"], + &format!("{at}.network.egress"), + )?; + if let Some(default) = egress.get("default") { + if default.as_str() != Some("deny") { + return fail(format!( + "'{at}.network.egress.default' must be 'deny'; a default-allow grant is a wildcard" + )); + } + } + if let Some(allow) = egress.get("allow") { + validate_network_rules(allow, &format!("{at}.network.egress.allow"), false)?; + } + if let Some(deny) = egress.get("deny") { + validate_network_rules(deny, &format!("{at}.network.egress.deny"), true)?; + } + } + if let Some(ingress) = network.get("ingress") { + let Some(ingress) = ingress.as_object() else { + return fail(format!("'{at}.network.ingress' must be an object")); + }; + only_fields( + ingress, + &["default", "hostLoopback"], + &format!("{at}.network.ingress"), + )?; + if let Some(default) = ingress.get("default") { + if default.as_str() != Some("deny") { + return fail(format!( + "'{at}.network.ingress.default' must be 'deny'; a default-allow grant is a wildcard" + )); + } + } + if let Some(loopback) = ingress.get("hostLoopback") { + if !matches!(loopback.as_str(), Some("allow" | "deny")) { + return fail(format!( + "'{at}.network.ingress.hostLoopback' is unsupported" + )); + } + } + } + } + if let Some(ui) = raw.get("ui") { + let Some(ui) = ui.as_object() else { + return fail(format!("'{at}.ui' must be an object")); + }; + only_fields( + ui, + &["disable", "clipboard", "allowInputInjection"], + &format!("{at}.ui"), + )?; + if ui.get("disable").and_then(Json::as_bool).is_none() { + return fail(format!( + "'{at}.ui.disable' is required and must be a boolean" + )); + } + if let Some(value) = ui.get("allowInputInjection") { + if value.as_bool().is_none() { + return fail(format!("'{at}.ui.allowInputInjection' must be a boolean")); + } + } + if let Some(clipboard) = ui.get("clipboard") { + if !matches!(clipboard.as_str(), Some("none" | "read" | "write" | "all")) { + return fail(format!("'{at}.ui.clipboard' is unsupported")); + } + } + } + if let Some(timeout) = raw.get("timeoutMs") { + let valid = timeout + .as_f64() + .is_some_and(|n| is_integer(n) && (1.0..=f64::from(u32::MAX)).contains(&n)); + if !valid { + return fail(format!( + "'{at}.timeoutMs' must be a positive unsigned 32-bit integer" + )); + } + } + Ok(CatalogPolicy { raw: raw.clone() }) +} + +// --------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------- + +/// Stable comparison keys for one predicate; invocation names always fold. +fn identity_keys(predicate: &IdentityPredicate) -> Vec { + match predicate { + IdentityPredicate::Purl { value } => { + vec![format!( + "purl:{}", + parse_purl(value).map(|p| p.key).unwrap_or_default() + )] + } + IdentityPredicate::InvocationName { names } => names + .iter() + .map(|name| format!("invocation-name:{}", js_to_lower(name))) + .collect(), + } +} + +fn validate_identity(raw: Option<&Json>, at: &str) -> Result> { + let items = match raw.and_then(Json::as_array) { + Some(items) if !items.is_empty() => items, + _ => return fail(format!("'{at}' must be a non-empty array")), + }; + let mut predicates = Vec::new(); + for (index, item) in items.iter().enumerate() { + let item_at = format!("{at}[{index}]"); + let Some(item) = item.as_object() else { + return fail(format!("'{item_at}' must be an object")); + }; + match item.get("kind").and_then(Json::as_str) { + Some("purl") => { + only_fields(item, &["kind", "value"], &item_at)?; + let value = non_empty_string(item.get("value"), &format!("{item_at}.value"))?; + let Some(parsed) = parse_purl(&value) else { + return fail(format!("'{item_at}.value' is not a valid package URL")); + }; + if parsed.version.is_some() || parsed.has_qualifiers || parsed.has_subpath { + return fail(format!( + "'{item_at}.value' must name only type, namespace, and name; versions select 'versionVariants', and qualifiers and subpaths are not identity" + )); + } + predicates.push(IdentityPredicate::Purl { value }); + } + Some("invocation-name") => { + only_fields(item, &["kind", "names"], &item_at)?; + let names = string_array(item.get("names"), &format!("{item_at}.names"), 1)?; + for name in &names { + if name.contains(['/', '\\']) { + return fail(format!( + "'{item_at}.names' entry '{name}' must be a bare invocation name, not a path" + )); + } + } + predicates.push(IdentityPredicate::InvocationName { names }); + } + _ => return fail(format!("'{item_at}.kind' is not a supported identity kind")), + } + } + let mut seen = HashSet::new(); + for key in predicates.iter().flat_map(identity_keys) { + if !seen.insert(key.clone()) { + let shown: Vec<&str> = key.split('\0').filter(|s| !s.is_empty()).collect(); + return fail(format!("'{at}' repeats identity '{}'", shown.join("/"))); + } + } + Ok(predicates) +} + +fn validate_dependencies(raw: Option<&Json>, at: &str) -> Result> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let Some(items) = raw.as_array() else { + return fail(format!("'{at}' must be an array")); + }; + let mut seen = HashSet::new(); + let mut list = Vec::new(); + for (index, dependency) in items.iter().enumerate() { + let dep_at = format!("{at}[{index}]"); + let Some(dependency) = dependency.as_object() else { + return fail(format!("'{dep_at}' must be an object")); + }; + only_fields(dependency, &["entryId", "versionRange", "intents"], &dep_at)?; + let entry_id = non_empty_string(dependency.get("entryId"), &format!("{dep_at}.entryId"))?; + if !seen.insert(entry_id.clone()) { + return fail(format!("'{dep_at}.entryId' '{entry_id}' is listed twice")); + } + let mut version_range = None; + if let Some(range) = dependency.get("versionRange") { + let range = non_empty_string(Some(range), &format!("{dep_at}.versionRange"))?; + if let Err(reason) = VersRange::parse(&range) { + return fail(format!( + "'{dep_at}.versionRange' is not a valid vers range: {reason}" + )); + } + version_range = Some(range); + } + let intents = match dependency.get("intents") { + None => None, + Some(value) => { + let names = string_array(Some(value), &format!("{dep_at}.intents"), 1)?; + let mut unique = HashSet::new(); + for name in &names { + if !is_intent_name(name) { + return fail(format!( + "'{dep_at}.intents' entry '{name}' is not a valid intent name" + )); + } + if !unique.insert(name.as_str()) { + return fail(format!("'{dep_at}.intents' lists '{name}' twice")); + } + } + Some(names) + } + }; + list.push(Dependency { + entry_id, + version_range, + intents, + }); + } + Ok(list) +} + +const ADDITIONS_RULE: &str = + "additions may only use filesystem.readonlyPaths, filesystem.readwritePaths, and network.egress.allow"; + +fn validate_additions( + raw: Option<&Json>, + at: &str, + contract: &CatalogContract, +) -> Result { + let Some(raw) = raw else { + return Ok(Additions::default()); + }; + let Some(object) = raw.as_object() else { + return fail(format!("'{at}' must be an object")); + }; + let mut additions = Additions::default(); + for (key, value) in object.iter() { + match key { + "filesystem" => { + let Some(filesystem) = value.as_object() else { + return fail(format!("'{at}.filesystem' must be an object")); + }; + for (field, values) in filesystem.iter() { + let field_at = format!("{at}.filesystem.{field}"); + let target = match field { + "readonlyPaths" => &mut additions.readonly_paths, + "readwritePaths" => &mut additions.readwrite_paths, + _ => { + return fail(format!("'{field_at}' is not additive; {ADDITIONS_RULE}")) + } + }; + for (index, path) in string_array(Some(values), &field_at, 0)? + .into_iter() + .enumerate() + { + validate_template_path(&path, &format!("{field_at}[{index}]"), contract)?; + target.push(path); + } + } + } + "network" => { + let Some(network) = value.as_object() else { + return fail(format!("'{at}.network' must be an object")); + }; + for (field, value) in network.iter() { + if field != "egress" { + return fail(format!( + "'{at}.network.{field}' is not additive; {ADDITIONS_RULE}" + )); + } + let Some(egress) = value.as_object() else { + return fail(format!("'{at}.network.egress' must be an object")); + }; + for (field, rules) in egress.iter() { + let rules_at = format!("{at}.network.egress.{field}"); + if field != "allow" { + return fail(format!("'{rules_at}' is not additive; {ADDITIONS_RULE}")); + } + validate_network_rules(rules, &rules_at, false)?; + additions + .egress_allow + .extend(rules.as_array().into_iter().flatten().cloned()); + } + } + } + _ => return fail(format!("'{at}.{key}' is not additive; {ADDITIONS_RULE}")), + } + } + Ok(additions) +} + +/// `/^[a-z][a-z0-9_-]*$/` +fn is_intent_name(value: &str) -> bool { + let mut chars = value.chars(); + chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +fn validate_intents( + raw: Option<&Json>, + at: &str, + contract: &CatalogContract, +) -> Result> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let Some(object) = raw.as_object() else { + return fail(format!("'{at}' must be an object")); + }; + let mut intents = Vec::new(); + for (name, definition) in object.iter() { + let intent_at = format!("{at}.{name}"); + if !is_intent_name(name) { + return fail(format!( + "'{intent_at}' is not a valid intent name (lower-case letters, digits, '-', '_')" + )); + } + let Some(definition) = definition.as_object() else { + return fail(format!("'{intent_at}' must be an object")); + }; + only_fields( + definition, + &["exampleSubcommands", "policyAdditions", "dependencies"], + &intent_at, + )?; + let example_subcommands = match definition.get("exampleSubcommands") { + None => None, + Some(value) => Some(string_array( + Some(value), + &format!("{intent_at}.exampleSubcommands"), + 1, + )?), + }; + intents.push(( + name.to_string(), + IntentDefinition { + example_subcommands, + additions: validate_additions( + definition.get("policyAdditions"), + &format!("{intent_at}.policyAdditions"), + contract, + )?, + dependencies: validate_dependencies( + definition.get("dependencies"), + &format!("{intent_at}.dependencies"), + )?, + }, + )); + } + Ok(intents) +} + +fn validate_overlay( + item: &JsonObject, + at: &str, + default_intents: &[(String, IntentDefinition)], + contract: &CatalogContract, +) -> Result { + let intent_additions = validate_intents( + item.get("intentAdditions"), + &format!("{at}.intentAdditions"), + contract, + )?; + for (name, _) in &intent_additions { + if !default_intents.iter().any(|(n, _)| n == name) { + return fail(format!( + "'{at}.intentAdditions.{name}' extends an intent the default does not declare; use 'newIntents' to declare a new one" + )); + } + } + let new_intents = validate_intents( + item.get("newIntents"), + &format!("{at}.newIntents"), + contract, + )?; + for (name, _) in &new_intents { + if default_intents.iter().any(|(n, _)| n == name) { + return fail(format!( + "'{at}.newIntents.{name}' redeclares an inherited intent; use 'intentAdditions' to extend it" + )); + } + } + Ok(Overlay { + policy_additions: validate_additions( + item.get("policyAdditions"), + &format!("{at}.policyAdditions"), + contract, + )?, + dependencies: validate_dependencies( + item.get("dependencies"), + &format!("{at}.dependencies"), + )?, + intent_additions, + new_intents, + }) +} + +const OVERLAY_FIELDS: [&str; 4] = [ + "policyAdditions", + "dependencies", + "intentAdditions", + "newIntents", +]; + +fn validate_default( + raw: Option<&Json>, + at: &str, + contract: &CatalogContract, +) -> Result { + let Some(item) = record(raw) else { + return fail(format!( + "'{at}' must be an object; every entry has exactly one unversioned default" + )); + }; + only_fields(item, &["requirements", "dependencies", "intents"], at)?; + Ok(EntryDefault { + requirements: validate_requirements( + item.get("requirements"), + &format!("{at}.requirements"), + contract, + )?, + dependencies: validate_dependencies( + item.get("dependencies"), + &format!("{at}.dependencies"), + )?, + intents: validate_intents(item.get("intents"), &format!("{at}.intents"), contract)?, + }) +} + +fn validate_platform_variants( + raw: Option<&Json>, + at: &str, + default: &EntryDefault, + contract: &CatalogContract, +) -> Result> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let Some(items) = raw.as_array() else { + return fail(format!("'{at}' must be an array")); + }; + let mut selectors = HashSet::new(); + let mut variants = Vec::new(); + for (index, item) in items.iter().enumerate() { + let item_at = format!("{at}[{index}]"); + let Some(item) = item.as_object() else { + return fail(format!("'{item_at}' must be an object")); + }; + let mut allowed = vec!["when"]; + allowed.extend(OVERLAY_FIELDS); + only_fields(item, &allowed, &item_at)?; + let Some(when) = record(item.get("when")) else { + return fail(format!("'{item_at}.when' must be an object")); + }; + only_fields( + when, + &["platform", "architecture"], + &format!("{item_at}.when"), + )?; + let Some(platform) = when + .get("platform") + .and_then(Json::as_str) + .and_then(Platform::parse) + else { + return fail(format!( + "'{item_at}.when.platform' must be one of windows, linux, macos" + )); + }; + let architecture = match when.get("architecture") { + None => None, + Some(value) => match value.as_str().and_then(Architecture::parse) { + Some(arch) => Some(arch), + None => { + return fail(format!( + "'{item_at}.when.architecture' must be one of x64, arm64" + )) + } + }, + }; + let selector = format!( + "{platform}/{}", + architecture.map_or("*", Architecture::as_str) + ); + if !selectors.insert(selector.clone()) { + return fail(if architecture.is_none() { + format!("'{item_at}' is a second architecture-neutral variant for '{platform}'") + } else { + format!("'{item_at}' duplicates selector '{selector}'") + }); + } + variants.push(PlatformVariant { + platform, + architecture, + overlay: validate_overlay(item, &item_at, &default.intents, contract)?, + }); + } + Ok(variants) +} + +fn validate_version_variants( + raw: Option<&Json>, + at: &str, + scheme: VersionScheme, + default: &EntryDefault, + contract: &CatalogContract, +) -> Result> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let Some(items) = raw.as_array() else { + return fail(format!("'{at}' must be an array")); + }; + let mut variants: Vec = Vec::new(); + for (index, item) in items.iter().enumerate() { + let item_at = format!("{at}[{index}]"); + let Some(item) = item.as_object() else { + return fail(format!("'{item_at}' must be an object")); + }; + let mut allowed = vec!["versionRange"]; + allowed.extend(OVERLAY_FIELDS); + only_fields(item, &allowed, &item_at)?; + let text = non_empty_string(item.get("versionRange"), &format!("{item_at}.versionRange"))?; + let range = match VersRange::parse(&text) { + Ok(range) => range, + Err(reason) => { + return fail(format!( + "'{item_at}.versionRange' is not a valid vers range: {reason}" + )) + } + }; + if range.scheme() != scheme { + return fail(format!( + "'{item_at}.versionRange' uses '{}', but the entry's versionScheme is '{scheme}'", + range.scheme() + )); + } + for (other_index, other) in variants.iter().enumerate() { + if range.overlaps(&other.version_range) { + return fail(format!( + "'{item_at}.versionRange' '{text}' overlaps '{at}[{other_index}].versionRange' '{}'; version ranges must not overlap", + other.version_range + )); + } + } + variants.push(VersionVariant { + version_range: range, + overlay: validate_overlay(item, &item_at, &default.intents, contract)?, + }); + } + Ok(variants) +} + +/// `/^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$/` +fn is_entry_id(value: &str) -> bool { + let Some((namespace, name)) = value.split_once(':') else { + return false; + }; + let mut ns = namespace.chars(); + let mut nm = name.chars(); + ns.next().is_some_and(|c| c.is_ascii_lowercase()) + && ns.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && nm + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + && nm.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')) +} + +fn validate_entry(raw: &Json, at: &str, contract: &CatalogContract) -> Result { + let Some(object) = raw.as_object() else { + return fail(format!("'{at}' must be an object")); + }; + only_fields( + object, + &[ + "entryId", + "entryRevision", + "displayName", + "versionScheme", + "identity", + "default", + "platformVariants", + "versionVariants", + "provenance", + ], + at, + )?; + let entry_id = non_empty_string(object.get("entryId"), &format!("{at}.entryId"))?; + if !is_entry_id(&entry_id) { + return fail(format!( + "'{at}.entryId' '{entry_id}' must be namespaced, e.g. 'tool:name'" + )); + } + let entry_revision = match object.get("entryRevision").and_then(Json::as_f64) { + Some(n) if is_integer(n) && n >= 1.0 => n, + _ => return fail(format!("'{at}.entryRevision' must be a positive integer")), + }; + let Some(provenance) = record(object.get("provenance")) else { + return fail(format!("'{at}.provenance' must be an object")); + }; + only_fields( + provenance, + &["method", "sourceRevision"], + &format!("{at}.provenance"), + )?; + let display_name = non_empty_string(object.get("displayName"), &format!("{at}.displayName"))?; + let Some(version_scheme) = object + .get("versionScheme") + .and_then(Json::as_str) + .and_then(VersionScheme::parse) + else { + return fail(format!( + "'{at}.versionScheme' must be one of npm, semver, pypi, nuget, intdot" + )); + }; + let identity = validate_identity(object.get("identity"), &format!("{at}.identity"))?; + let default = validate_default(object.get("default"), &format!("{at}.default"), contract)?; + let platform_variants = validate_platform_variants( + object.get("platformVariants"), + &format!("{at}.platformVariants"), + &default, + contract, + )?; + let version_variants = validate_version_variants( + object.get("versionVariants"), + &format!("{at}.versionVariants"), + version_scheme, + &default, + contract, + )?; + let provenance_method = + non_empty_string(provenance.get("method"), &format!("{at}.provenance.method"))?; + let provenance_source_revision = non_empty_string( + provenance.get("sourceRevision"), + &format!("{at}.provenance.sourceRevision"), + )?; + Ok(CatalogEntry { + entry_id, + entry_revision, + display_name, + version_scheme, + identity, + default, + platform_variants, + version_variants, + provenance_method, + provenance_source_revision, + raw: raw.clone(), + }) +} + +impl CatalogEntry { + /// Every dependency edge the entry declares anywhere, with where it is. + pub(crate) fn all_dependencies(&self) -> Vec<&Dependency> { + fn intents(list: &[(String, IntentDefinition)]) -> impl Iterator { + list.iter().flat_map(|(_, d)| &d.dependencies) + } + let mut out: Vec<&Dependency> = Vec::new(); + out.extend(&self.default.dependencies); + out.extend(intents(&self.default.intents)); + let overlays = self + .platform_variants + .iter() + .map(|v| &v.overlay) + .chain(self.version_variants.iter().map(|v| &v.overlay)); + for overlay in overlays { + out.extend(&overlay.dependencies); + out.extend(intents(&overlay.intent_additions)); + out.extend(intents(&overlay.new_intents)); + } + out + } +} + +pub type EntryIndex<'a> = HashMap<&'a str, &'a CatalogEntry>; + +pub fn entry_index(entries: &[CatalogEntry]) -> EntryIndex<'_> { + entries.iter().map(|e| (e.entry_id.as_str(), e)).collect() +} + +// --------------------------------------------------------------------------- +// Revision-level validation +// --------------------------------------------------------------------------- + +/// Validates one catalog revision against the v1 contract (design §7), +/// including every effective policy each entry can produce. +pub fn validate_catalog_revision( + raw: &Json, + contract: &CatalogContract, +) -> Result { + let Some(object) = raw.as_object() else { + return fail("catalog root must be an object"); + }; + only_fields( + object, + &[ + "catalogSchemaVersion", + "catalogRevision", + "sdkContractVersion", + "entries", + ], + "catalog", + )?; + if object.get("catalogSchemaVersion").and_then(Json::as_str) + != Some(contract.catalog_schema_version.as_str()) + { + return fail(format!( + "catalog.catalogSchemaVersion must be '{}'", + contract.catalog_schema_version + )); + } + let catalog_revision = + non_empty_string(object.get("catalogRevision"), "catalog.catalogRevision")?; + if !is_catalog_revision_id(&catalog_revision) { + return fail(format!( + "catalog.catalogRevision '{catalog_revision}' must match YYYY-MM-DD.N" + )); + } + let sdk_contract_version = non_empty_string( + object.get("sdkContractVersion"), + "catalog.sdkContractVersion", + )?; + if sdk_contract_version != SDK_CONTRACT_VERSION { + return fail(format!( + "catalog.sdkContractVersion '{sdk_contract_version}' is not this SDK's ContainerRequest contract version '{SDK_CONTRACT_VERSION}'" + )); + } + let Some(raw_entries) = object.get("entries").and_then(Json::as_array) else { + return fail("catalog.entries must be an array"); + }; + let entries = raw_entries + .iter() + .enumerate() + .map(|(index, entry)| validate_entry(entry, &format!("entries[{index}]"), contract)) + .collect::>>()?; + + let mut by_id: EntryIndex<'_> = HashMap::new(); + for entry in &entries { + if by_id.insert(&entry.entry_id, entry).is_some() { + return fail(format!("duplicate entryId '{}'", entry.entry_id)); + } + } + + for entry in &entries { + for dependency in entry.all_dependencies() { + let Some(target) = by_id.get(dependency.entry_id.as_str()) else { + return fail(format!( + "'{}' depends on unknown entry '{}' in this revision", + entry.entry_id, dependency.entry_id + )); + }; + if dependency.entry_id == entry.entry_id { + return fail(format!("'{}' depends on itself", entry.entry_id)); + } + if let Some(range) = &dependency.version_range { + let scheme = VersRange::parse(range).map(|r| r.scheme()).ok(); + if scheme != Some(target.version_scheme) { + return fail(format!( + "'{}' requires '{}' with range '{range}', which does not use that entry's versionScheme '{}'", + entry.entry_id, dependency.entry_id, target.version_scheme + )); + } + } + } + } + + crate::policy_store::effective::validate_materializations(&entries, &by_id, contract)?; + + Ok(CatalogRevision { + catalog_schema_version: contract.catalog_schema_version.clone(), + catalog_revision, + sdk_contract_version, + entries, + }) +} + +/// Canonical JSON of an entry without `entryRevision` (history comparisons). +pub(crate) fn entry_semantic_key(entry: &CatalogEntry) -> String { + let mut raw = entry.raw.clone(); + if let Json::Object(object) = &mut raw { + object.remove("entryRevision"); + } + crate::policy_store::json::canonical_json(&raw) +} + +/// Formats an entry revision number as TypeScript would interpolate it. +pub(crate) fn revision_number(value: f64) -> String { + js_number_to_string(value) +} + +#[allow(dead_code)] +pub(crate) fn describe(value: &Json) -> String { + js_to_string(value) +} diff --git a/src/mxc-sdk/src/policy_store/compose.rs b/src/mxc-sdk/src/policy_store/compose.rs new file mode 100644 index 000000000..8f6e8a0b8 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/compose.rs @@ -0,0 +1,735 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Floor composition of selected layers (design §4.5): every selected base, +//! its selected additions, and its dependencies combine into one +//! `ContainerRequirements` that preserves the access each requested pair +//! needs. + +use crate::policy_store::catalog::{Additions, CatalogPolicy}; +use crate::policy_store::effective::LayerBody; +use crate::policy_store::host::ObjectRelation; +use crate::policy_store::json::{canonical_json, cmp_utf16, Json, JsonObject}; +use crate::policy_store::model::{ + DetailWarningKind, FilesystemRequirements, NetworkRequirement, PathAccess, PathRequirement, + Platform, Requirements, ResolutionDetailWarning, Warning, +}; +use crate::policy_store::netrule::{describe_rule, rules_overlap}; +use crate::policy_store::paths::{normalize_path, path_exact_segments, path_key_segments}; +use crate::policy_store::text::symbol_matches; + +/// One selected layer and the input indexes that require it. +#[derive(Clone, Debug)] +pub struct Contribution<'a> { + pub entry_id: &'a str, + pub body: LayerBody<'a>, + pub owners: Vec, +} + +impl Contribution<'_> { + fn paths(&self, class: PathAccess) -> Vec<&str> { + match self.body { + LayerBody::Base(base) => base.filesystem_field(field(class)), + LayerBody::Additions(additions) => match class { + PathAccess::Denied => Vec::new(), + PathAccess::Readonly => additions + .readonly_paths + .iter() + .map(String::as_str) + .collect(), + PathAccess::Readwrite => additions + .readwrite_paths + .iter() + .map(String::as_str) + .collect(), + }, + } + } +} + +fn field(class: PathAccess) -> &'static str { + match class { + PathAccess::Denied => "deniedPaths", + PathAccess::Readonly => "readonlyPaths", + PathAccess::Readwrite => "readwritePaths", + } +} + +fn label(class: PathAccess) -> &'static str { + match class { + PathAccess::Denied => "denied", + PathAccess::Readonly => "read-only", + PathAccess::Readwrite => "read-write", + } +} + +const CLASSES: [PathAccess; 3] = [ + PathAccess::Denied, + PathAccess::Readonly, + PathAccess::Readwrite, +]; + +/// The contributions of one entry. +struct Group<'a> { + entry_id: &'a str, + base: Option<&'a CatalogPolicy>, + additions: Vec<&'a Additions>, +} + +impl Group<'_> { + fn has_additions(&self) -> bool { + self.additions.iter().any(|a| !a.is_empty()) + } + + fn needs_network(&self) -> bool { + self.base.is_some_and(|b| b.field("network").is_some()) + || self.additions.iter().any(|a| !a.egress_allow.is_empty()) + } +} + +fn groups<'a>(contributions: &[Contribution<'a>]) -> Vec> { + let mut out: Vec> = Vec::new(); + for contribution in contributions { + let index = match out.iter().position(|g| g.entry_id == contribution.entry_id) { + Some(index) => index, + None => { + out.push(Group { + entry_id: contribution.entry_id, + base: None, + additions: Vec::new(), + }); + out.len() - 1 + } + }; + match contribution.body { + LayerBody::Base(base) => out[index].base = Some(base), + LayerBody::Additions(additions) => out[index].additions.push(additions), + } + } + out +} + +/// The symbols the contributions reference, first-seen order, with the +/// entries and input indexes that need each. +pub fn contribution_symbols( + contributions: &[Contribution<'_>], +) -> Vec<(String, Vec, Vec)> { + let mut out: Vec<(String, Vec, Vec)> = Vec::new(); + for contribution in contributions { + for class in CLASSES { + for template in contribution.paths(class) { + for (_, _, name) in symbol_matches(template) { + let index = match out.iter().position(|(n, _, _)| n == name) { + Some(index) => index, + None => { + out.push((name.to_string(), Vec::new(), Vec::new())); + out.len() - 1 + } + }; + let (_, ids, owners) = &mut out[index]; + add_id(ids, contribution.entry_id); + add_owners(owners, &contribution.owners); + } + } + } + } + out +} + +fn add_id(ids: &mut Vec, id: &str) { + if !ids.iter().any(|x| x == id) { + ids.push(id.to_string()); + ids.sort_by(|a, b| cmp_utf16(a, b)); + } +} + +fn add_owners(owners: &mut Vec, extra: &[usize]) { + owners.extend_from_slice(extra); + owners.sort_unstable(); + owners.dedup(); +} + +/// Rejects combinations the v1 composition rules cannot express (design +/// §4.5), rather than approximating them with broader access. +pub fn compose_check(contributions: &[Contribution<'_>]) -> Result<(), String> { + let groups = groups(contributions); + if is_passthrough(&groups) { + return Ok(()); + } + for group in &groups { + for key in group.base.into_iter().flat_map(|b| b.raw().keys()) { + if !matches!(key, "filesystem" | "network" | "ui" | "timeoutMs") { + return Err(format!( + "'{}' uses '{key}', which has no v1 cross-policy composition rule", + group.entry_id + )); + } + } + } + // API spec §4: one source's `ui`/`timeoutMs` is kept; two distinct + // sources supplying the same field conflict, even with equal values. + for field in SINGLE_SOURCE_FIELDS { + let sources: Vec<&str> = groups + .iter() + .filter(|g| g.base.is_some_and(|b| b.field(field).is_some())) + .map(|g| g.entry_id) + .collect(); + if sources.len() > 1 { + return Err(format!( + "'{field}' is supplied by {}; distinct sources cannot both supply it", + sources.join(" and ") + )); + } + } + if groups.iter().filter(|g| g.needs_network()).count() <= 1 { + return Ok(()); + } + for group in &groups { + let Some(network) = group + .base + .and_then(|b| b.field("network")) + .and_then(Json::as_object) + else { + continue; + }; + for (key, value) in network.iter() { + if key != "egress" { + return Err(format!( + "'{}' uses 'network.{key}', which cannot be combined with other network requirements", + group.entry_id + )); + } + for (field, _) in value.as_object().into_iter().flat_map(|o| o.iter()) { + if !matches!(field, "default" | "allow" | "deny") { + return Err(format!( + "'{}' uses 'network.egress.{field}', which cannot be combined with other network requirements", + group.entry_id + )); + } + } + } + } + Ok(()) +} + +/// Fields kept from at most one deduplicated source, never merged. +const SINGLE_SOURCE_FIELDS: [&str; 2] = ["ui", "timeoutMs"]; + +/// The one base that supplies `field`, if any (`compose_check` rejects more). +fn single_source<'a>(groups: &[Group<'a>], field: &str) -> Option<&'a Json> { + groups + .iter() + .find_map(|g| g.base.and_then(|b| b.field(field))) +} + +/// A single selected policy without additions or dependencies keeps every +/// catalog-supported field without cross-policy composition. +fn is_passthrough(groups: &[Group<'_>]) -> bool { + groups.len() == 1 && !groups[0].has_additions() +} + +/// Filesystem object identity used during composition. +pub trait IdentityOracle { + /// Whether `inner` names `outer`'s object or an object beneath it. + fn within(&self, inner: &str, outer: &str) -> ObjectRelation; +} + +/// Lexical rules only (symbolic catalog validation and the reviewer view): +/// paths that are not lexically related are treated as distinct objects. +pub struct LexicalIdentity; + +impl IdentityOracle for LexicalIdentity { + fn within(&self, _inner: &str, _outer: &str) -> ObjectRelation { + ObjectRelation::NotWithin + } +} + +/// An access relationship whose object identity could not be established. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IdentityFailure { + pub paths: Vec, + pub owners: Vec, + pub entry_ids: Vec, +} + +/// The composed requirements, composition diagnostics, and any relation +/// whose identity is unknown. When `identity_failures` is non-empty the +/// caller drops their owners and composes again. +#[derive(Clone, Debug)] +pub struct Composed { + pub requirements: Requirements, + pub warnings: Vec, + pub identity_failures: Vec, +} + +#[derive(Clone, Debug)] +struct PathItem { + path: String, + exact: Vec, + folded: Vec, + entry_ids: Vec, + owners: Vec, +} + +impl PathItem { + fn requirement(&self, access: PathAccess) -> PathRequirement { + PathRequirement { + path: self.path.clone(), + access, + entry_ids: self.entry_ids.clone(), + } + } +} + +fn is_same_or_nested(left: &[String], right: &[String]) -> bool { + let (shorter, longer) = if left.len() <= right.len() { + (left, right) + } else { + (right, left) + }; + shorter.iter().zip(longer).all(|(a, b)| a == b) +} + +/// `inner` equals `outer` or lies beneath it. +fn is_within(inner: &[String], outer: &[String]) -> bool { + inner.len() >= outer.len() && outer.iter().zip(inner).all(|(a, b)| a == b) +} + +fn ids(item: &PathItem) -> String { + item.entry_ids.join(", ") +} + +fn detail( + owners: &[&[usize]], + entry_ids: &[&[String]], + kind: DetailWarningKind, + message: String, +) -> Warning { + let mut input_indexes = Vec::new(); + for o in owners { + add_owners(&mut input_indexes, o); + } + let mut all_ids = Vec::new(); + for list in entry_ids { + for id in list.iter() { + add_id(&mut all_ids, id); + } + } + Warning::Detail(ResolutionDetailWarning { + input_indexes, + entry_ids: all_ids, + kind, + message, + }) +} + +fn failure(a: &PathItem, b: &PathItem) -> IdentityFailure { + let mut owners = a.owners.clone(); + add_owners(&mut owners, &b.owners); + let mut entry_ids = a.entry_ids.clone(); + for id in &b.entry_ids { + add_id(&mut entry_ids, id); + } + IdentityFailure { + paths: vec![a.path.clone(), b.path.clone()], + owners, + entry_ids, + } +} + +/// Composes contributions that passed [`compose_check`]. `resolve` maps a +/// path template to its substituted value (identity for symbolic validation). +pub fn compose( + contributions: &[Contribution<'_>], + resolve: &dyn Fn(&str) -> String, + platform: Platform, + identity: &dyn IdentityOracle, +) -> Composed { + let mut warnings = Vec::new(); + let mut failures = Vec::new(); + let mut classes: Vec> = Vec::new(); + for class in CLASSES { + let mut items: Vec = Vec::new(); + for contribution in contributions { + for template in contribution.paths(class) { + let path = normalize_path(&resolve(template), platform); + let exact = path_exact_segments(&path, platform); + let index = match items.iter().position(|i| i.exact == exact) { + Some(index) => index, + None => { + items.push(PathItem { + folded: path_key_segments(&path, Platform::Windows), + path, + exact, + entry_ids: Vec::new(), + owners: Vec::new(), + }); + items.len() - 1 + } + }; + add_id(&mut items[index].entry_ids, contribution.entry_id); + add_owners(&mut items[index].owners, &contribution.owners); + } + } + classes.push(items); + } + + // Case sensitivity of the target filesystem is not determined, so paths + // compare case-sensitively; report pairs that differ only by case. + let all: Vec<(PathAccess, &PathItem)> = CLASSES + .iter() + .zip(&classes) + .flat_map(|(class, items)| items.iter().map(move |i| (*class, i))) + .collect(); + for (i, (left_class, left)) in all.iter().enumerate() { + for (right_class, right) in &all[i + 1..] { + if is_same_or_nested(&left.folded, &right.folded) + && !is_same_or_nested(&left.exact, &right.exact) + { + warnings.push(detail( + &[&left.owners, &right.owners], + &[&left.entry_ids, &right.entry_ids], + DetailWarningKind::FilesystemCaseAssumed { + paths: vec![left.path.clone(), right.path.clone()], + }, + format!( + "{} '{}' ({}) and {} '{}' ({}) differ only by case; filesystem case sensitivity was not determined, so they were compared case-sensitively and kept distinct", + label(*left_class), + left.path, + ids(left), + label(*right_class), + right.path, + ids(right) + ), + )); + } + } + } + + let [denied, readonly, mut readwrite] = <[Vec; 3]>::try_from(classes) + .unwrap_or_else(|_| unreachable!("three access classes")); + + // Read-only requirements equal to or within a read-write subtree are + // satisfied by it. A read-only alias of a read-write object keeps its + // pathname with read-write access. + let mut kept_readonly = Vec::new(); + let mut promoted = Vec::new(); + for ro in readonly { + let covering: Vec<&PathItem> = readwrite + .iter() + .filter(|rw| is_within(&ro.exact, &rw.exact)) + .collect(); + if !covering.is_empty() { + let owners: Vec<&[usize]> = std::iter::once(ro.owners.as_slice()) + .chain(covering.iter().map(|rw| rw.owners.as_slice())) + .collect(); + let entry_ids: Vec<&[String]> = std::iter::once(ro.entry_ids.as_slice()) + .chain(covering.iter().map(|rw| rw.entry_ids.as_slice())) + .collect(); + warnings.push(detail( + &owners, + &entry_ids, + DetailWarningKind::ReadonlySuperseded { + removed: ro.requirement(PathAccess::Readonly), + required_by: covering + .iter() + .map(|rw| rw.requirement(PathAccess::Readwrite)) + .collect(), + }, + format!( + "read-only '{}' ({}) is covered by read-write '{}' ({}); the read-only entry was omitted", + ro.path, + ids(&ro), + covering[0].path, + ids(covering[0]) + ), + )); + continue; + } + let mut aliases: Vec<&PathItem> = Vec::new(); + for rw in &readwrite { + match identity.within(&ro.path, &rw.path) { + ObjectRelation::Within => aliases.push(rw), + ObjectRelation::Unknown => failures.push(failure(&ro, rw)), + ObjectRelation::NotWithin => {} + } + } + if aliases.is_empty() { + kept_readonly.push(ro); + continue; + } + let owners: Vec<&[usize]> = std::iter::once(ro.owners.as_slice()) + .chain(aliases.iter().map(|rw| rw.owners.as_slice())) + .collect(); + let entry_ids: Vec<&[String]> = std::iter::once(ro.entry_ids.as_slice()) + .chain(aliases.iter().map(|rw| rw.entry_ids.as_slice())) + .collect(); + warnings.push(detail( + &owners, + &entry_ids, + DetailWarningKind::ReadonlySuperseded { + removed: ro.requirement(PathAccess::Readonly), + required_by: aliases + .iter() + .map(|rw| rw.requirement(PathAccess::Readwrite)) + .collect(), + }, + format!( + "read-only '{}' ({}) names the same filesystem object as read-write '{}' ({}) or one beneath it; the alias pathname was retained with read-write access", + ro.path, + ids(&ro), + aliases[0].path, + ids(aliases[0]) + ), + )); + promoted.push(ro); + } + readwrite.extend(promoted); + let readonly = kept_readonly; + + // A catalog deny overlapping any required read-only or read-write path is + // removed in full. + let mut kept_denied = Vec::new(); + for deny in denied { + let grants: Vec<(PathAccess, &PathItem)> = readwrite + .iter() + .map(|g| (PathAccess::Readwrite, g)) + .chain(readonly.iter().map(|g| (PathAccess::Readonly, g))) + .collect(); + let mut overlapping: Vec<(PathAccess, &PathItem)> = grants + .iter() + .filter(|(_, g)| is_same_or_nested(&deny.exact, &g.exact)) + .copied() + .collect(); + if overlapping.is_empty() { + for (access, grant) in &grants { + let forward = identity.within(&deny.path, &grant.path); + let backward = identity.within(&grant.path, &deny.path); + if forward == ObjectRelation::Within || backward == ObjectRelation::Within { + overlapping.push((*access, grant)); + } else if forward == ObjectRelation::Unknown || backward == ObjectRelation::Unknown + { + failures.push(failure(&deny, grant)); + } + } + } + if overlapping.is_empty() { + kept_denied.push(deny); + continue; + } + let owners: Vec<&[usize]> = std::iter::once(deny.owners.as_slice()) + .chain(overlapping.iter().map(|(_, g)| g.owners.as_slice())) + .collect(); + let entry_ids: Vec<&[String]> = std::iter::once(deny.entry_ids.as_slice()) + .chain(overlapping.iter().map(|(_, g)| g.entry_ids.as_slice())) + .collect(); + let (first_access, first) = overlapping[0]; + warnings.push(detail( + &owners, + &entry_ids, + DetailWarningKind::FilesystemDenyRemoved { + removed: deny.requirement(PathAccess::Denied), + required_by: overlapping + .iter() + .map(|(access, g)| g.requirement(*access)) + .collect(), + }, + format!( + "removed catalog deny '{}' ({}) because it overlaps required {} '{}' ({}); the entire deny scope '{}' was removed, so other grants may now apply throughout it", + deny.path, + ids(&deny), + label(first_access), + first.path, + ids(first), + deny.path + ), + )); + } + + let has_filesystem = contributions.iter().any(|c| match c.body { + LayerBody::Base(base) => base.has_filesystem(), + LayerBody::Additions(a) => !a.readonly_paths.is_empty() || !a.readwrite_paths.is_empty(), + }); + let pick = |items: Vec| { + Some(items.into_iter().map(|i| i.path).collect::>()).filter(|v| !v.is_empty()) + }; + let filesystem = has_filesystem.then(|| FilesystemRequirements { + denied_paths: pick(kept_denied), + readonly_paths: pick(readonly), + readwrite_paths: pick(readwrite), + }); + + let groups = groups(contributions); + let network = compose_network(contributions, &groups, &mut warnings); + Composed { + requirements: Requirements { + filesystem, + network, + ui: single_source(&groups, "ui").cloned(), + timeout_ms: single_source(&groups, "timeoutMs") + .and_then(Json::as_f64) + .map(|n| n as u32), + }, + warnings, + identity_failures: failures, + } +} + +/// Outbound rules keyed by canonical JSON, with their contributors. +#[derive(Default)] +struct Rules { + items: Vec, +} + +struct RuleItem { + key: String, + rule: Json, + entry_ids: Vec, + owners: Vec, +} + +impl RuleItem { + fn requirement(&self) -> NetworkRequirement { + NetworkRequirement { + rule: self.rule.clone(), + entry_ids: self.entry_ids.clone(), + } + } +} + +impl Rules { + fn add(&mut self, rule: &Json, contribution: &Contribution<'_>) { + let key = canonical_json(rule); + let index = match self.items.iter().position(|i| i.key == key) { + Some(index) => index, + None => { + self.items.push(RuleItem { + key, + rule: rule.clone(), + entry_ids: Vec::new(), + owners: Vec::new(), + }); + self.items.len() - 1 + } + }; + add_id(&mut self.items[index].entry_ids, contribution.entry_id); + add_owners(&mut self.items[index].owners, &contribution.owners); + } + + fn json(&self) -> Option { + (!self.items.is_empty()) + .then(|| Json::Array(self.items.iter().map(|i| i.rule.clone()).collect())) + } +} + +fn egress_rules<'j>(network: Option<&'j Json>, field: &str) -> impl Iterator { + network + .and_then(|n| n.get("egress")) + .and_then(|e| e.get(field)) + .and_then(Json::as_array) + .into_iter() + .flatten() +} + +/// Composes network requirements (design §4.5). A single entry needing +/// network keeps its own network section, plus its outbound additions. +/// Several such entries union their outbound allow rules and catalog egress +/// denies under a deny-by-default egress. Either way, a catalog deny +/// overlapping any required allow rule is removed in full, with a warning. +fn compose_network( + contributions: &[Contribution<'_>], + groups: &[Group<'_>], + warnings: &mut Vec, +) -> Option { + let needing: Vec<&Group<'_>> = groups.iter().filter(|g| g.needs_network()).collect(); + if needing.is_empty() { + return None; + } + let mut allow = Rules::default(); + let mut deny = Rules::default(); + for contribution in contributions { + match contribution.body { + LayerBody::Base(base) => { + let network = base.field("network"); + for rule in egress_rules(network, "allow") { + allow.add(rule, contribution); + } + for rule in egress_rules(network, "deny") { + deny.add(rule, contribution); + } + } + LayerBody::Additions(additions) => { + for rule in &additions.egress_allow { + allow.add(rule, contribution); + } + } + } + } + deny.items.retain(|denied| { + let required: Vec<&RuleItem> = allow + .items + .iter() + .filter(|a| rules_overlap(&a.rule, &denied.rule)) + .collect(); + if required.is_empty() { + return true; + } + let owners: Vec<&[usize]> = std::iter::once(denied.owners.as_slice()) + .chain(required.iter().map(|a| a.owners.as_slice())) + .collect(); + let entry_ids: Vec<&[String]> = std::iter::once(denied.entry_ids.as_slice()) + .chain(required.iter().map(|a| a.entry_ids.as_slice())) + .collect(); + warnings.push(detail( + &owners, + &entry_ids, + DetailWarningKind::NetworkDenyRemoved { + removed: denied.requirement(), + required_by: required.iter().map(|a| a.requirement()).collect(), + }, + format!( + "removed catalog egress deny {} ({}) because it overlaps required egress allow {} ({}); the entire deny rule was removed, so other grants may now apply throughout {}", + describe_rule(&denied.rule), + denied.entry_ids.join(", "), + describe_rule(&required[0].rule), + required[0].entry_ids.join(", "), + describe_rule(&denied.rule) + ), + )); + false + }); + + let single_base = match needing.as_slice() { + [only] => only + .base + .and_then(|b| b.field("network")) + .and_then(Json::as_object), + _ => None, + }; + let mut egress = JsonObject::new(); + match single_base.and_then(|b| b.get("egress")) { + Some(base_egress) => { + if let Some(default) = base_egress.get("default") { + egress.insert("default", default.clone()); + } + } + None if single_base.is_some() => {} + None => egress.insert("default", "deny".into()), + } + if let Some(rules) = allow.json() { + egress.insert("allow", rules); + } + if let Some(rules) = deny.json() { + egress.insert("deny", rules); + } + let mut network = JsonObject::new(); + if !egress.is_empty() || single_base.is_some_and(|b| b.contains_key("egress")) { + network.insert("egress", Json::Object(egress)); + } + for (key, value) in single_base.into_iter().flat_map(|b| b.iter()) { + if key != "egress" { + network.insert(key, value.clone()); + } + } + Some(Json::Object(network)) +} diff --git a/src/mxc-sdk/src/policy_store/effective.rs b/src/mxc-sdk/src/policy_store/effective.rs new file mode 100644 index 000000000..d574a6978 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/effective.rs @@ -0,0 +1,769 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Effective policies (design §4.3–§4.5): the default plus the selected +//! platform overlay, at most one version variant, and the selected intents, +//! kept as independently de-duplicated source layers; the dependency closure; +//! and the build-time materialization of every effective policy the catalog +//! can produce. + +use crate::policy_store::catalog::{ + fail, validate_requirements, Additions, CatalogContract, CatalogEntry, CatalogPolicy, + Dependency, EntryIndex, IntentDefinition, Overlay, PlatformVariant, VersionVariant, +}; +use crate::policy_store::compose::{compose, compose_check, Contribution, LexicalIdentity}; +use crate::policy_store::errors::{invalid_catalog, Result}; +use crate::policy_store::json::{cmp_utf16, Json}; +use crate::policy_store::model::{ + Architecture, DependencyRecord, FilesystemRequirements, IntentMode, IntentSelection, Platform, + Requirements, VersionSelection, +}; +use std::collections::{HashMap, HashSet}; + +/// The platform overlay selected for an entry. +#[derive(Clone, Copy, Debug)] +pub struct PlatformSelection<'a> { + pub index: usize, + pub variant: &'a PlatformVariant, + /// False for the architecture-neutral fallback. + pub exact: bool, +} + +/// Exact architecture first, then the platform's neutral overlay, else none +/// (the common default). Another architecture's overlay is never selected. +/// `architecture` is consulted only when the platform has +/// architecture-specific overlays. +pub fn select_platform_variant<'a>( + entry: &'a CatalogEntry, + platform: Platform, + architecture: &dyn Fn() -> Result, +) -> Result>> { + let candidates: Vec<(usize, &PlatformVariant)> = entry + .platform_variants + .iter() + .enumerate() + .filter(|(_, v)| v.platform == platform) + .collect(); + if candidates.iter().any(|(_, v)| v.architecture.is_some()) { + let arch = architecture()?; + if let Some((index, variant)) = candidates + .iter() + .find(|(_, v)| v.architecture == Some(arch)) + { + return Ok(Some(PlatformSelection { + index: *index, + variant, + exact: true, + })); + } + } + Ok(candidates + .iter() + .find(|(_, v)| v.architecture.is_none()) + .map(|(index, variant)| PlatformSelection { + index: *index, + variant, + exact: false, + })) +} + +/// Whether the platform has architecture-specific overlays (so the +/// architecture was consulted, and a neutral selection is a fallback). +pub fn has_arch_specific(entry: &CatalogEntry, platform: Platform) -> bool { + entry + .platform_variants + .iter() + .any(|v| v.platform == platform && v.architecture.is_some()) +} + +/// Which part of an entry a layer comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum LayerSource { + Default, + /// A platform overlay, by index in `platformVariants`. + Platform(usize), + /// A version overlay, by index in `versionVariants`. + Version(usize), +} + +/// The de-duplication key of one source contribution layer (design §4.5): +/// the default base once; each platform base once; default intents by +/// intent; platform intents by selector and intent; version bases by range; +/// and version intents by range and intent. A requesting pair's version or +/// intent is never part of a shared base-layer key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LayerKey { + pub entry_id: String, + pub source: LayerSource, + /// `None` for a base layer. + pub intent: Option, +} + +/// What a layer contributes. +#[derive(Clone, Copy, Debug)] +pub enum LayerBody<'a> { + /// The default's `requirements`. + Base(&'a CatalogPolicy), + /// Additive data from an intent or overlay. + Additions(&'a Additions), +} + +#[derive(Clone, Debug)] +pub struct Layer<'a> { + pub key: LayerKey, + pub entry_id: &'a str, + pub body: LayerBody<'a>, + pub dependencies: &'a [Dependency], +} + +#[derive(Clone, Debug)] +pub struct EffectiveIntent<'a> { + pub name: String, + pub layers: Vec>, +} + +/// One effective policy before intent selection. +#[derive(Clone, Debug)] +pub struct Effective<'a> { + pub entry: &'a CatalogEntry, + pub base_layers: Vec>, + /// Sorted by name. + pub intents: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IntentChoice<'s> { + /// Base plus every effective intent (no intent requested). + All, + /// Base plus one named intent. + Named(&'s str), + /// Base only (dependencies, and comparing a default with a new intent). + BaseOnly, + /// Base plus the named intents (a dependency reference naming intents). + Set(&'s [String]), +} + +/// The selected base plus intents of one effective policy. +#[derive(Clone, Debug)] +pub struct Selected<'a> { + pub layers: Vec>, + pub intent_names: Vec, +} + +fn layer<'a>( + entry: &'a CatalogEntry, + source: LayerSource, + intent: Option<&str>, + body: LayerBody<'a>, + dependencies: &'a [Dependency], +) -> Layer<'a> { + Layer { + key: LayerKey { + entry_id: entry.entry_id.clone(), + source, + intent: intent.map(str::to_string), + }, + entry_id: &entry.entry_id, + body, + dependencies, + } +} + +fn add_intents<'a>( + intents: &mut Vec>, + entry: &'a CatalogEntry, + source: LayerSource, + list: &'a [(String, IntentDefinition)], + extend: bool, + origin: &str, +) -> std::result::Result<(), String> { + for (name, definition) in list { + let added = layer( + entry, + source, + Some(name), + LayerBody::Additions(&definition.additions), + &definition.dependencies, + ); + match intents.iter_mut().find(|i| &i.name == name) { + Some(existing) if extend => existing.layers.push(added), + Some(_) => { + return Err(format!( + "intent '{name}' from {origin} is already declared by another overlay" + )) + } + None if extend => { + return Err(format!( + "{origin} extends intent '{name}', which is not declared" + )) + } + None => intents.push(EffectiveIntent { + name: name.clone(), + layers: vec![added], + }), + } + } + Ok(()) +} + +/// Default + platform additions + at most one version variant (all additive). +pub fn materialize<'a>( + entry: &'a CatalogEntry, + platform: Option<&'a PlatformVariant>, + version: Option<&'a VersionVariant>, +) -> std::result::Result, String> { + let mut effective = Effective { + entry, + base_layers: vec![layer( + entry, + LayerSource::Default, + None, + LayerBody::Base(&entry.default.requirements), + &entry.default.dependencies, + )], + intents: Vec::new(), + }; + add_intents( + &mut effective.intents, + entry, + LayerSource::Default, + &entry.default.intents, + false, + "the default", + )?; + let platform_overlay = platform.map(|p| { + let index = entry + .platform_variants + .iter() + .position(|v| std::ptr::eq(v, p)) + .expect("the platform overlay belongs to the entry"); + ( + &p.overlay, + LayerSource::Platform(index), + format!( + "the {}/{} overlay", + p.platform, + p.architecture.map_or("*", Architecture::as_str) + ), + ) + }); + let version_overlay = version.map(|v| { + let index = entry + .version_variants + .iter() + .position(|x| std::ptr::eq(x, v)) + .expect("the version overlay belongs to the entry"); + ( + &v.overlay, + LayerSource::Version(index), + format!("version range '{}'", v.version_range), + ) + }); + for (overlay, source, origin) in [platform_overlay, version_overlay].into_iter().flatten() { + let overlay: &'a Overlay = overlay; + effective.base_layers.push(layer( + entry, + source, + None, + LayerBody::Additions(&overlay.policy_additions), + &overlay.dependencies, + )); + add_intents( + &mut effective.intents, + entry, + source, + &overlay.intent_additions, + true, + &origin, + )?; + add_intents( + &mut effective.intents, + entry, + source, + &overlay.new_intents, + false, + &origin, + )?; + } + effective + .intents + .sort_by(|a, b| cmp_utf16(&a.name, &b.name)); + Ok(effective) +} + +impl<'a> Effective<'a> { + pub fn has_intent(&self, name: &str) -> bool { + self.intents.iter().any(|i| i.name == name) + } + + pub fn intent_names(&self) -> Vec { + self.intents.iter().map(|i| i.name.clone()).collect() + } + + /// `None` when a named intent is not defined for this effective policy. + pub fn select(&self, choice: IntentChoice<'_>) -> Option> { + let chosen: Vec<&EffectiveIntent<'a>> = match choice { + IntentChoice::All => self.intents.iter().collect(), + IntentChoice::BaseOnly => Vec::new(), + IntentChoice::Named(name) => vec![self.intents.iter().find(|i| i.name == name)?], + IntentChoice::Set(names) => self + .intents + .iter() + .filter(|i| names.contains(&i.name)) + .collect(), + }; + if let IntentChoice::Set(names) = choice { + if chosen.len() != names.len() { + return None; + } + } + let mut layers = self.base_layers.clone(); + for intent in &chosen { + layers.extend(intent.layers.iter().cloned()); + } + Some(Selected { + layers, + intent_names: chosen.iter().map(|i| i.name.clone()).collect(), + }) + } +} + +/// Every selected layer of one lookup, de-duplicated by [`LayerKey`], with +/// the sorted, distinct input indexes (owners) that require it. +#[derive(Default)] +pub struct LayerSet<'a> { + items: Vec<(Layer<'a>, Vec)>, + index: HashMap, +} + +fn union_into(target: &mut Vec, extra: &[usize]) { + target.extend_from_slice(extra); + target.sort_unstable(); + target.dedup(); +} + +impl<'a> LayerSet<'a> { + pub fn add(&mut self, layer: &Layer<'a>, owners: &[usize]) { + match self.index.get(&layer.key) { + Some(&at) => union_into(&mut self.items[at].1, owners), + None => { + self.index.insert(layer.key.clone(), self.items.len()); + let mut owners = owners.to_vec(); + union_into(&mut owners, &[]); + self.items.push((layer.clone(), owners)); + } + } + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn layers(&self) -> impl Iterator, &[usize])> { + self.items.iter().map(|(l, o)| (l, o.as_slice())) + } + + /// Contributions of every layer that keeps an owner outside `excluded`, + /// with only those remaining owners. Layers owned solely by excluded + /// pairs are discarded. + pub fn contributions(&self, excluded: &HashSet) -> Vec> { + self.items + .iter() + .filter_map(|(layer, owners)| { + let owners: Vec = owners + .iter() + .copied() + .filter(|o| !excluded.contains(o)) + .collect(); + (!owners.is_empty()).then_some(Contribution { + entry_id: layer.entry_id, + body: layer.body, + owners, + }) + }) + .collect() + } +} + +/// Accumulates the dependency closure of every contribution in one lookup. +/// A dependency contributes its default base plus its platform overlay's base +/// additions; a reference naming intents also adds those intents, with their +/// applicable platform intent additions (design §4.5). Dependency layers +/// inherit the owners of the layer that references them. +pub struct Closure<'a, 'm> { + pub by_id: &'m EntryIndex<'a>, + pub platform: Platform, + pub architecture: &'m dyn Fn() -> Result, + pub records: Vec, + /// Platform selection of every dependency entry visited, with its owners. + pub selections: Vec<(&'a CatalogEntry, Option>, Vec)>, +} + +impl<'a, 'm> Closure<'a, 'm> { + pub fn new( + by_id: &'m EntryIndex<'a>, + platform: Platform, + architecture: &'m dyn Fn() -> Result, + ) -> Self { + Self { + by_id, + platform, + architecture, + records: Vec::new(), + selections: Vec::new(), + } + } + + /// Adds the dependencies of `layers` (selected for `root`), transitively. + pub fn add_layers( + &mut self, + root: &'a CatalogEntry, + layers: &[Layer<'a>], + owners: &[usize], + set: &mut LayerSet<'a>, + ) -> Result<()> { + let mut stack = vec![root.entry_id.as_str()]; + for layer in layers { + set.add(layer, owners); + self.visit_all(layer.dependencies, owners, set, &mut stack)?; + } + Ok(()) + } + + fn visit_all( + &mut self, + dependencies: &'a [Dependency], + owners: &[usize], + set: &mut LayerSet<'a>, + stack: &mut Vec<&'a str>, + ) -> Result<()> { + for dependency in dependencies { + let from = *stack.last().expect("stack is seeded with the root"); + let Some(target) = self.by_id.get(dependency.entry_id.as_str()).copied() else { + return Err(invalid_catalog(format!( + "dependency resolution failed: missing-entry ({from} -> {})", + dependency.entry_id + ))); + }; + if stack.contains(&target.entry_id.as_str()) { + let mut path = stack.clone(); + path.push(&target.entry_id); + return Err(invalid_catalog(format!( + "dependency resolution failed: cycle ({})", + path.join(" -> ") + ))); + } + let selection = select_platform_variant(target, self.platform, self.architecture)?; + let effective = materialize(target, selection.map(|s| s.variant), None) + .map_err(|e| invalid_catalog(format!("'{}': {e}", target.entry_id)))?; + let mut named: Vec = dependency.intents.clone().unwrap_or_default(); + named.sort_by(|a, b| cmp_utf16(a, b)); + let Some(selected) = effective.select(IntentChoice::Set(&named)) else { + let missing = named + .iter() + .find(|n| !effective.has_intent(n)) + .cloned() + .unwrap_or_default(); + return Err(invalid_catalog(format!( + "dependency resolution failed: {from} -> {} names intent '{missing}', which {} does not define on {}", + target.entry_id, target.entry_id, self.platform + ))); + }; + let record = DependencyRecord { + entry_id: target.entry_id.clone(), + entry_revision: target.entry_revision, + input_indexes: owners.to_vec(), + required_version_range: dependency.version_range.clone(), + version_selection: VersionSelection::default_match(), + intent_selection: IntentSelection { + requested: None, + mode: if dependency.intents.is_some() { + IntentMode::Named + } else { + IntentMode::None + }, + selected: named, + }, + }; + match self.records.iter_mut().find(|r| r.same_record(&record)) { + Some(existing) => union_into(&mut existing.input_indexes, owners), + None => { + let mut record = record; + union_into(&mut record.input_indexes, &[]); + self.records.push(record); + } + } + match self + .selections + .iter_mut() + .find(|(entry, _, _)| std::ptr::eq(*entry, target)) + { + Some((_, _, existing)) => union_into(existing, owners), + None => self.selections.push((target, selection, owners.to_vec())), + } + stack.push(&target.entry_id); + for layer in &selected.layers { + set.add(layer, owners); + self.visit_all(layer.dependencies, owners, set, stack)?; + } + stack.pop(); + } + Ok(()) + } + + /// Dependency records sorted by entryId, entryRevision, range (absent + /// first), then the selected intents. + pub fn sorted_records(&self) -> Vec { + let mut records = self.records.clone(); + records.sort_by(|a, b| { + cmp_utf16(&a.entry_id, &b.entry_id) + .then( + a.entry_revision + .partial_cmp(&b.entry_revision) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .then_with( + || match (&a.required_version_range, &b.required_version_range) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Less, + (Some(_), None) => std::cmp::Ordering::Greater, + (Some(x), Some(y)) => cmp_utf16(x, y), + }, + ) + .then_with(|| { + cmp_utf16( + &a.intent_selection.selected.join("\u{0}"), + &b.intent_selection.selected.join("\u{0}"), + ) + }) + }); + records + } +} + +// --------------------------------------------------------------------------- +// Build-time materialization (design §4.2, §7) +// --------------------------------------------------------------------------- + +/// One materialized effective policy, symbolic (templates unsubstituted). +#[derive(Clone, Debug)] +pub struct Materialized { + pub platform: Platform, + pub architecture: Architecture, + /// `None` for the default; otherwise the version range. + pub version_range: Option, + /// `None` for all intents. + pub intent: Option, + pub requirements: Requirements, + pub dependency_entry_ids: Vec, + pub intent_names: Vec, + /// The common default (no platform or version overlay) for the same + /// intent, or its base for an intent the default does not declare. + pub default_requirements: Requirements, + pub default_dependency_entry_ids: Vec, +} + +fn compose_symbolic<'a>( + entry: &'a CatalogEntry, + selected: &Selected<'a>, + by_id: &EntryIndex<'a>, + platform: Platform, + architecture: Architecture, +) -> std::result::Result<(Requirements, Vec), String> { + let arch = move || Ok(architecture); + let mut closure = Closure::new(by_id, platform, &arch); + let mut set = LayerSet::default(); + closure + .add_layers(entry, &selected.layers, &[0], &mut set) + .map_err(|e| e.detail().to_string())?; + let contributions = set.contributions(&HashSet::new()); + compose_check(&contributions)?; + let composed = compose( + &contributions, + &|t: &str| t.to_string(), + platform, + &LexicalIdentity, + ); + let mut deps: Vec = closure.records.iter().map(|r| r.entry_id.clone()).collect(); + deps.sort_by(|a, b| cmp_utf16(a, b)); + deps.dedup(); + Ok((composed.requirements, deps)) +} + +fn rule_keys(requirements: &Requirements) -> HashSet { + requirements + .network + .as_ref() + .and_then(|n| n.get("egress")) + .and_then(|e| e.get("allow")) + .and_then(Json::as_array) + .into_iter() + .flatten() + .map(crate::policy_store::json::canonical_json) + .collect() +} + +fn fs_paths( + requirements: &Requirements, + field: fn(&FilesystemRequirements) -> &Option>, +) -> Vec { + requirements + .filesystem + .as_ref() + .and_then(|fs| field(fs).clone()) + .unwrap_or_default() +} + +/// Every access the default grants is still granted by the effective policy. +fn subset_violation( + default: &Requirements, + default_deps: &[String], + effective: &Requirements, + effective_deps: &[String], + platform: Platform, +) -> Option { + use crate::policy_store::paths::path_exact_segments as seg; + let rw = fs_paths(effective, |f| &f.readwrite_paths); + let ro = fs_paths(effective, |f| &f.readonly_paths); + let within = |path: &str, list: &[String]| { + let p = seg(path, platform); + list.iter().any(|g| { + let g = seg(g, platform); + p.len() >= g.len() && g.iter().zip(&p).all(|(a, b)| a == b) + }) + }; + for path in fs_paths(default, |f| &f.readwrite_paths) { + if !within(&path, &rw) { + return Some(format!("loses read-write '{path}'")); + } + } + for path in fs_paths(default, |f| &f.readonly_paths) { + if !within(&path, &rw) && !ro.contains(&path) { + return Some(format!("loses read-only '{path}'")); + } + } + let effective_rules = rule_keys(effective); + if !rule_keys(default).is_subset(&effective_rules) { + return Some("loses an outbound allow rule".to_string()); + } + if let Some(dep) = default_deps.iter().find(|d| !effective_deps.contains(d)) { + return Some(format!("loses dependency '{dep}'")); + } + None +} + +/// Materializes every platform × architecture × version × intent policy of +/// `entry` (design §7), validating composition, the closed composed fields, +/// and that the default is a subset of each effective policy. +pub fn materialize_entry( + entry: &CatalogEntry, + by_id: &EntryIndex<'_>, + contract: &CatalogContract, +) -> Result> { + let mut out = Vec::new(); + for platform in Platform::ALL { + for architecture in Architecture::ALL { + let arch = move || Ok(architecture); + let selection = select_platform_variant(entry, platform, &arch)?; + let default_effective = materialize(entry, None, None) + .map_err(|e| invalid_catalog(format!("'{}': {e}", entry.entry_id)))?; + let versions: Vec> = std::iter::once(None) + .chain(entry.version_variants.iter().map(Some)) + .collect(); + for version in versions { + let range = version.map(|v| v.version_range.as_str().to_string()); + let at = format!( + "'{}' on {platform}/{architecture} ({})", + entry.entry_id, + range.as_deref().unwrap_or("default") + ); + let effective = match materialize(entry, selection.map(|s| s.variant), version) { + Ok(effective) => effective, + Err(e) => return fail(format!("{at}: {e}")), + }; + let intents: Vec> = std::iter::once(None) + .chain(effective.intents.iter().map(|i| Some(i.name.clone()))) + .collect(); + for intent in intents { + let choice = intent + .as_deref() + .map_or(IntentChoice::All, IntentChoice::Named); + let at = format!("{at} intent {}", intent.as_deref().unwrap_or("(all)")); + let selected = effective.select(choice).expect("listed intents select"); + let (requirements, deps) = + compose_symbolic(entry, &selected, by_id, platform, architecture) + .map_err(|e| invalid_catalog(format!("{at}: {e}")))?; + if let Err(e) = + validate_requirements(Some(&requirements.to_json()), "composed", contract) + { + return fail(format!( + "{at}: composed requirements are invalid: {}", + e.detail() + )); + } + let default_choice = match choice { + IntentChoice::Named(name) if !default_effective.has_intent(name) => { + IntentChoice::BaseOnly + } + other => other, + }; + let default_selected = default_effective + .select(default_choice) + .expect("default intents select"); + let (default_requirements, default_deps) = + compose_symbolic(entry, &default_selected, by_id, platform, architecture) + .map_err(|e| invalid_catalog(format!("{at} (default): {e}")))?; + if let Some(violation) = subset_violation( + &default_requirements, + &default_deps, + &requirements, + &deps, + platform, + ) { + return fail(format!( + "{at}: the default is not a subset of the effective policy; it {violation}" + )); + } + out.push(Materialized { + platform, + architecture, + version_range: range.clone(), + intent: intent.clone(), + requirements, + dependency_entry_ids: deps, + intent_names: selected.intent_names, + default_requirements, + default_dependency_entry_ids: default_deps, + }); + } + } + } + } + Ok(out) +} + +/// Revision-level materialization check, called from catalog validation: +/// every combination composes, and its exact SDK request validates. +pub(crate) fn validate_materializations( + entries: &[CatalogEntry], + by_id: &EntryIndex<'_>, + contract: &CatalogContract, +) -> Result<()> { + for entry in entries { + for materialized in materialize_entry(entry, by_id, contract)? { + crate::policy_store::exact::validate_materialized(&materialized, contract).map_err( + |e| { + invalid_catalog(format!( + "'{}' on {}/{} ({}) intent {}: {e}", + entry.entry_id, + materialized.platform, + materialized.architecture, + materialized.version_range.as_deref().unwrap_or("default"), + materialized.intent.as_deref().unwrap_or("(all)") + )) + }, + )?; + } + } + Ok(()) +} diff --git a/src/mxc-sdk/src/policy_store/errors.rs b/src/mxc-sdk/src/policy_store/errors.rs new file mode 100644 index 000000000..fe16d9bc0 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/errors.rs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Error codes reused from MXC's closed `MxcError` code set, plus a stable, +//! language-neutral sub-reason carried in `details.reason` by the SDKs. Warnings stay plain strings. + +use std::fmt; + +/// MXC error code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ErrorCode { + /// Catalog data or the selected composition violates the catalog contract. + PolicyValidation, + /// The caller's context or tool input is structurally invalid. + MalformedRequest, + /// The host platform or native architecture has no selector or cannot be detected. + UnsupportedContainment, + /// Installed catalog data is unavailable, unreadable, or fails integrity. + BackendError, +} + +impl ErrorCode { + pub fn as_str(self) -> &'static str { + match self { + ErrorCode::PolicyValidation => "policy_validation", + ErrorCode::MalformedRequest => "malformed_request", + ErrorCode::UnsupportedContainment => "unsupported_containment", + ErrorCode::BackendError => "backend_error", + } + } +} + +impl fmt::Display for ErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Stable sub-reason (`details.reason`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ErrorReason { + InvalidCatalog, + CompositionConflict, + AmbiguousMatch, + InvalidContext, + UnsupportedHost, + Integrity, + RevisionUnavailable, +} + +impl ErrorReason { + pub const ALL: [ErrorReason; 7] = [ + ErrorReason::InvalidCatalog, + ErrorReason::CompositionConflict, + ErrorReason::AmbiguousMatch, + ErrorReason::InvalidContext, + ErrorReason::UnsupportedHost, + ErrorReason::Integrity, + ErrorReason::RevisionUnavailable, + ]; + + pub fn as_str(self) -> &'static str { + match self { + ErrorReason::InvalidCatalog => "invalid_catalog", + ErrorReason::CompositionConflict => "composition_conflict", + ErrorReason::AmbiguousMatch => "ambiguous_match", + ErrorReason::InvalidContext => "invalid_context", + ErrorReason::UnsupportedHost => "unsupported_host", + ErrorReason::Integrity => "integrity", + ErrorReason::RevisionUnavailable => "revision_unavailable", + } + } + + /// The one code each reason maps to (`ERROR_CODE_FOR_REASON`). + pub fn code(self) -> ErrorCode { + match self { + ErrorReason::InvalidCatalog + | ErrorReason::CompositionConflict + | ErrorReason::AmbiguousMatch => ErrorCode::PolicyValidation, + ErrorReason::InvalidContext => ErrorCode::MalformedRequest, + ErrorReason::UnsupportedHost => ErrorCode::UnsupportedContainment, + ErrorReason::Integrity | ErrorReason::RevisionUnavailable => ErrorCode::BackendError, + } + } +} + +impl fmt::Display for ErrorReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A library failure. `message()` is `[] `, exactly as in the original TypeScript prototype. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PolicyCatalogError { + reason: ErrorReason, + message: String, +} + +impl PolicyCatalogError { + pub fn new(reason: ErrorReason, message: impl AsRef) -> Self { + Self { + reason, + message: format!("[{}] {}", reason.code(), message.as_ref()), + } + } + + pub fn code(&self) -> ErrorCode { + self.reason.code() + } + + pub fn reason(&self) -> ErrorReason { + self.reason + } + + /// Full message including the `[code] ` prefix. + pub fn message(&self) -> &str { + &self.message + } + + /// The message without the `[code] ` prefix. + pub fn detail(&self) -> &str { + self.message + .split_once("] ") + .map_or(self.message.as_str(), |(_, rest)| rest) + } +} + +impl fmt::Display for PolicyCatalogError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for PolicyCatalogError {} + +pub type Result = std::result::Result; + +pub(crate) fn invalid_catalog(message: impl AsRef) -> PolicyCatalogError { + PolicyCatalogError::new(ErrorReason::InvalidCatalog, message) +} + +pub(crate) fn invalid_context(message: impl AsRef) -> PolicyCatalogError { + PolicyCatalogError::new(ErrorReason::InvalidContext, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_is_prefixed_with_code() { + let error = PolicyCatalogError::new(ErrorReason::RevisionUnavailable, "x"); + assert_eq!(error.message(), "[backend_error] x"); + assert_eq!(error.code(), ErrorCode::BackendError); + let codes: Vec<&str> = ErrorReason::ALL.iter().map(|r| r.code().as_str()).collect(); + assert_eq!( + codes, + [ + "policy_validation", + "policy_validation", + "policy_validation", + "malformed_request", + "unsupported_containment", + "backend_error", + "backend_error" + ] + ); + } +} diff --git a/src/mxc-sdk/src/policy_store/exact.rs b/src/mxc-sdk/src/policy_store/exact.rs new file mode 100644 index 000000000..5eb658d12 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/exact.rs @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Typed v1 conversion and exact SDK-contract validation (design §4.2). +//! +//! Composed requirements stay in the catalog's JSON model inside the store. +//! This module converts them to `ContainerRequirements` (the v1 section +//! types) and validates them against the SDK target contract: the typed +//! sections build the exact `OneShotRequest` through the SDK hook, a +//! disposable copy is normalized, and an independent exact JSON serialization +//! (mapping `allowInputInjection` to wire `injection` and supplying the +//! SDK-owned wire version) must parse to the same request. The fixture +//! command is never executed or returned. + +use crate::policy::{ + ClipboardPolicy, ContainerRequirements, FilesystemPolicy, NetworkAction, NetworkEgressPolicy, + NetworkIngressPolicy, NetworkPeerPolicy, NetworkPolicy, NetworkPortPolicy, NetworkProtocol, + NetworkRulePolicy, UiPolicy, +}; +use crate::policy_store::catalog::{CatalogContract, SDK_CONTRACT_VERSION}; +use crate::policy_store::effective::Materialized; +use crate::policy_store::json::{Json, JsonObject}; +use crate::policy_store::model::{Platform, Requirements}; +use crate::policy_store::text::replace_symbols; + +/// The validation-only command bound into exact requests. +pub const VALIDATION_COMMAND: &str = "mxc-policy-store-validation"; +const VALIDATION_CONTAINER_ID: &str = "mxc-policy-store-validation"; + +fn action(value: Option<&Json>, at: &str) -> Result, String> { + match value.map(Json::as_str) { + None => Ok(None), + Some(Some("allow")) => Ok(Some(NetworkAction::Allow)), + Some(Some("deny")) => Ok(Some(NetworkAction::Deny)), + Some(_) => Err(format!("'{at}' is not a network action")), + } +} + +fn strings(value: Option<&Json>) -> Vec { + value + .and_then(Json::as_array) + .into_iter() + .flatten() + .filter_map(Json::as_str) + .map(str::to_string) + .collect() +} + +fn port(value: Option<&Json>, at: &str) -> Result, String> { + match value { + None => Ok(None), + Some(n) => n + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=65535.0).contains(n)) + .map(|n| Some(n as u16)) + .ok_or_else(|| format!("'{at}' is not a port")), + } +} + +fn rules(value: Option<&Json>, at: &str) -> Result>, String> { + let Some(value) = value else { + return Ok(None); + }; + let items = value + .as_array() + .ok_or_else(|| format!("'{at}' must be an array"))?; + items + .iter() + .enumerate() + .map(|(index, rule)| { + let at = format!("{at}[{index}]"); + let to = rule.get("to").and_then(Json::as_array).map(|peers| { + peers + .iter() + .map(|peer| NetworkPeerPolicy { + cidr: peer + .get("cidr") + .and_then(Json::as_str) + .unwrap_or_default() + .to_string(), + except: peer.get("except").map(|e| strings(Some(e))), + }) + .collect() + }); + let ports = match rule.get("ports").and_then(Json::as_array) { + None => None, + Some(ports) => Some( + ports + .iter() + .enumerate() + .map(|(i, p)| { + let at = format!("{at}.ports[{i}]"); + let protocol = match p.get("protocol").map(Json::as_str) { + None => None, + Some(Some("tcp")) => Some(NetworkProtocol::Tcp), + Some(Some("udp")) => Some(NetworkProtocol::Udp), + Some(Some("icmp")) => Some(NetworkProtocol::Icmp), + Some(Some("any")) => Some(NetworkProtocol::Any), + Some(_) => return Err(format!("'{at}.protocol' is unsupported")), + }; + Ok(NetworkPortPolicy { + protocol, + port: port(p.get("port"), &format!("{at}.port"))?, + end_port: port(p.get("endPort"), &format!("{at}.endPort"))?, + }) + }) + .collect::, String>>()?, + ), + }; + Ok(NetworkRulePolicy { to, ports }) + }) + .collect::, String>>() + .map(Some) +} + +/// Converts composed requirements to the v1 section types. +pub fn to_container_requirements( + requirements: &Requirements, +) -> Result { + let filesystem = requirements.filesystem.as_ref().map(|fs| FilesystemPolicy { + readwrite_paths: fs.readwrite_paths.clone().unwrap_or_default(), + readonly_paths: fs.readonly_paths.clone().unwrap_or_default(), + denied_paths: fs.denied_paths.clone().unwrap_or_default(), + clear_policy_on_exit: None, + }); + let network = match &requirements.network { + None => None, + Some(network) => Some(NetworkPolicy { + egress: match network.get("egress") { + None => None, + Some(egress) => Some(NetworkEgressPolicy { + default: action(egress.get("default"), "network.egress.default")?, + allow: rules(egress.get("allow"), "network.egress.allow")?, + deny: rules(egress.get("deny"), "network.egress.deny")?, + }), + }, + ingress: match network.get("ingress") { + None => None, + Some(ingress) => Some(NetworkIngressPolicy { + default: action(ingress.get("default"), "network.ingress.default")?, + host_loopback: action( + ingress.get("hostLoopback"), + "network.ingress.hostLoopback", + )?, + }), + }, + runtime_config: None, + }), + }; + let ui = match &requirements.ui { + None => None, + Some(ui) => Some(UiPolicy { + disable: ui + .get("disable") + .and_then(Json::as_bool) + .ok_or("'ui.disable' is required")?, + clipboard: match ui.get("clipboard").map(Json::as_str) { + None | Some(Some("none")) => ClipboardPolicy::None, + Some(Some("read")) => ClipboardPolicy::Read, + Some(Some("write")) => ClipboardPolicy::Write, + Some(Some("all")) => ClipboardPolicy::All, + Some(_) => return Err("'ui.clipboard' is unsupported".to_string()), + }, + allow_input_injection: ui + .get("allowInputInjection") + .and_then(Json::as_bool) + .unwrap_or(false), + }), + }; + Ok(ContainerRequirements { + filesystem, + network, + ui, + timeout_ms: requirements.timeout_ms, + }) +} + +/// The exact SDK target one-shot document for `requirements`, as the SDK +/// would serialize it with the validation command. +pub fn exact_document(requirements: &Requirements) -> Json { + let mut root = JsonObject::new(); + root.insert("version", SDK_CONTRACT_VERSION.into()); + root.insert("containerId", VALIDATION_CONTAINER_ID.into()); + root.insert("containment", "process".into()); + let mut lifecycle = JsonObject::new(); + lifecycle.insert("destroyOnExit", Json::Bool(true)); + lifecycle.insert("preservePolicy", Json::Bool(false)); + root.insert("lifecycle", Json::Object(lifecycle)); + let mut process = JsonObject::new(); + process.insert("commandLine", VALIDATION_COMMAND.into()); + process.insert( + "timeout", + Json::Number(f64::from(requirements.timeout_ms.unwrap_or(0))), + ); + root.insert("process", Json::Object(process)); + let fs = requirements.filesystem.clone().unwrap_or_default(); + let mut filesystem = JsonObject::new(); + let list = |v: &Option>| { + Json::Array( + v.iter() + .flatten() + .map(|p| Json::String(p.clone())) + .collect(), + ) + }; + filesystem.insert("readwritePaths", list(&fs.readwrite_paths)); + filesystem.insert("readonlyPaths", list(&fs.readonly_paths)); + filesystem.insert("deniedPaths", list(&fs.denied_paths)); + root.insert("filesystem", Json::Object(filesystem)); + if let Some(network) = &requirements.network { + root.insert("network", network.clone()); + } + if let Some(ui) = &requirements.ui { + let mut wire = JsonObject::new(); + wire.insert( + "disable", + ui.get("disable").cloned().unwrap_or(Json::Bool(true)), + ); + wire.insert( + "clipboard", + ui.get("clipboard") + .cloned() + .unwrap_or_else(|| "none".into()), + ); + wire.insert( + "injection", + ui.get("allowInputInjection") + .cloned() + .unwrap_or(Json::Bool(false)), + ); + root.insert("ui", Json::Object(wire)); + } + Json::Object(root) +} + +/// Validates concrete (symbol-free) requirements against the SDK target. +pub fn validate_exact(requirements: &Requirements) -> Result<(), String> { + let typed = to_container_requirements(requirements)?; + let wire = crate::policy_store::json::canonical_json(&exact_document(requirements)); + crate::policy::check_requirements_exact( + &typed, + &wire, + VALIDATION_COMMAND, + VALIDATION_CONTAINER_ID, + ) +} + +/// A fixture value for `symbol` on `platform`. +pub fn fixture_symbol(symbol: &str, platform: Platform) -> String { + match platform { + Platform::Windows => format!("C:\\mxc-policy-store-fixture\\{symbol}"), + _ => format!("/mxc-policy-store-fixture/{symbol}"), + } +} + +/// Binds fixture symbols into symbolic requirements. +pub fn bind_fixture_symbols(requirements: &Requirements, platform: Platform) -> Requirements { + let mut bound = requirements.clone(); + if let Some(fs) = &mut bound.filesystem { + for list in [ + &mut fs.denied_paths, + &mut fs.readonly_paths, + &mut fs.readwrite_paths, + ] + .into_iter() + .flatten() + { + for path in list.iter_mut() { + let substituted = replace_symbols(path, |name| fixture_symbol(name, platform)); + *path = crate::policy_store::paths::normalize_path(&substituted, platform); + } + } + } + bound +} + +/// Validates one materialized combination with fixture symbols bound. +pub fn validate_materialized( + materialized: &Materialized, + _contract: &CatalogContract, +) -> Result<(), String> { + validate_exact(&bind_fixture_symbols( + &materialized.requirements, + materialized.platform, + )) +} diff --git a/src/mxc-sdk/src/policy_store/history.rs b/src/mxc-sdk/src/policy_store/history.rs new file mode 100644 index 000000000..8eabef5b5 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/history.rs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Entry-revision monotonicity and published-revision immutability + +use crate::policy_store::catalog::{ + compare_catalog_revisions, entry_semantic_key, revision_number, CatalogRevision, +}; +use crate::policy_store::json::{canonical_json, Json}; +use crate::policy_store::store::CatalogStore; +use std::cmp::Ordering; +use std::collections::HashMap; + +/// Checks entry-revision monotonicity between two consecutive revisions: a +/// changed entry must bump `entryRevision`; an unchanged one must keep it. +pub fn check_entry_revisions(previous: &CatalogRevision, next: &CatalogRevision) -> Vec { + let mut errors = Vec::new(); + let newer = compare_catalog_revisions(&previous.catalog_revision, &next.catalog_revision) + .map(|o| o == Ordering::Less) + .unwrap_or(false); + if !newer { + errors.push(format!( + "catalog revision '{}' must be newer than '{}'", + next.catalog_revision, previous.catalog_revision + )); + } + let before: HashMap<&str, _> = previous + .entries + .iter() + .map(|e| (e.entry_id.as_str(), e)) + .collect(); + for entry in &next.entries { + let Some(old) = before.get(entry.entry_id.as_str()) else { + continue; + }; + let changed = entry_semantic_key(old) != entry_semantic_key(entry); + if changed && entry.entry_revision <= old.entry_revision { + errors.push(format!( + "{}: '{}' changed but entryRevision did not increase ({} -> {})", + next.catalog_revision, + entry.entry_id, + revision_number(old.entry_revision), + revision_number(entry.entry_revision) + )); + } else if !changed && entry.entry_revision != old.entry_revision { + errors.push(format!( + "{}: '{}' is unchanged but entryRevision moved ({} -> {})", + next.catalog_revision, + entry.entry_id, + revision_number(old.entry_revision), + revision_number(entry.entry_revision) + )); + } + } + errors +} + +/// Validates every installed revision (integrity + contract) and the +/// entry-revision chain between consecutive revisions. +pub fn check_store_history(store: &CatalogStore) -> Vec { + let mut errors = Vec::new(); + let mut previous: Option> = None; + for id in store.available_revisions() { + match store.revision(Some(&id)) { + Err(error) => { + errors.push(error.message().to_string()); + previous = None; + } + Ok(current) => { + if let Some(previous) = &previous { + errors.extend(check_entry_revisions(previous, ¤t)); + } + previous = Some(current); + } + } + } + errors +} + +/// One manifest revision as read (unvalidated) from a published manifest. +#[derive(Clone, Debug, PartialEq)] +pub struct PublishedRevision { + pub catalog_revision: Option, + pub file: Option, +} + +impl PublishedRevision { + pub fn new(catalog_revision: &str, file: &str) -> Self { + Self { + catalog_revision: Some(Json::from(catalog_revision)), + file: Some(Json::from(file)), + } + } + + fn from_json(value: &Json) -> Self { + Self { + catalog_revision: value.get("catalogRevision").cloned(), + file: value.get("file").cloned(), + } + } + + fn file_key(&self) -> Option { + self.file.as_ref().map(file_key) + } +} + +/// Map key for a manifest `file` value (TypeScript uses it as a Map key). +fn file_key(value: &Json) -> String { + match value { + Json::String(s) => s.clone(), + other => format!("\u{0}{}", other.to_compact_string()), + } +} + +fn describe_js(value: &Option) -> String { + match value { + None => "undefined".to_string(), + Some(v) => crate::policy_store::json::js_to_string(v), + } +} + +/// Raw published state compared across a proposed change. +#[derive(Clone, Debug, Default)] +pub struct PublishedState { + pub revisions: Vec, + /// Raw file text keyed by manifest `file`. + pub files: HashMap, +} + +impl PublishedState { + /// Reads `manifest.revisions` from a parsed manifest (absent → empty). + pub fn revisions_from_manifest(manifest: &Json) -> Vec { + manifest + .get("revisions") + .and_then(Json::as_array) + .map(|items| items.iter().map(PublishedRevision::from_json).collect()) + .unwrap_or_default() + } + + pub(crate) fn file_text(&self, revision: &PublishedRevision) -> Option<&String> { + revision.file_key().and_then(|k| self.files.get(&k)) + } +} + +fn canonical_text(text: Option<&String>) -> Option { + let text = text?; + Some(match Json::parse(text) { + Ok(value) => canonical_json(&value), + Err(_) => format!("invalid:{text}"), + }) +} + +/// Published revisions keep their manifest entry and content; new +/// revisions are only appended (design §10). +pub fn check_published_immutability( + base: &PublishedState, + proposed: &PublishedState, +) -> Vec { + let mut errors = Vec::new(); + for (index, published) in base.revisions.iter().enumerate() { + let name = describe_js(&published.catalog_revision); + let now = proposed.revisions.get(index); + let Some(now) = now.filter(|now| now.catalog_revision == published.catalog_revision) else { + errors.push(format!( + "published revision '{name}' was removed or reordered in the manifest" + )); + continue; + }; + if now.file != published.file { + errors.push(format!( + "published revision '{name}' manifest entry was modified" + )); + } + let base_content = canonical_text(base.file_text(published)); + let proposed_content = canonical_text(proposed.file_text(published)); + if base_content.is_some() && proposed_content != base_content { + errors.push(format!( + "published revision file '{}' was modified; publish a new revision instead", + describe_js(&published.file) + )); + } + } + errors +} diff --git a/src/mxc-sdk/src/policy_store/host.rs b/src/mxc-sdk/src/policy_store/host.rs new file mode 100644 index 000000000..0f6df610e --- /dev/null +++ b/src/mxc-sdk/src/policy_store/host.rs @@ -0,0 +1,561 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host platform, native system architecture, and approved host symbols +//! +//! The native architecture is the device's, not the process's or the +//! compile target's: +//! +//! - Windows: machine-wide `PROCESSOR_ARCHITECTURE` in +//! `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, +//! read with `%SystemRoot%\System32\reg.exe`. +//! - macOS: `/usr/sbin/sysctl -n hw.optional.arm64` (`1` = Apple silicon even +//! under Rosetta; the key is absent on Intel Macs), else the machine type. +//! - Linux: the kernel machine type (`uname(2)`). +//! +//! It also supplies local symbol discovery (design §4.2) and filesystem +//! object identity (design §4.5). Both describe this host only. + +use crate::mxc_common::filesystem_object::{ + compare_existing_filesystem_objects, ExistingObjectComparison, +}; +use crate::policy_store::errors::{ErrorReason, PolicyCatalogError, Result}; +use crate::policy_store::model::{Architecture, Platform}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +/// Host facts the resolver uses only when the caller omits them. +pub trait HostEnvironment: Send + Sync { + fn platform(&self) -> Result; + /// The device's native system architecture. Fails when it cannot be + /// determined; never guesses. + fn native_architecture(&self) -> Result; + /// Approved host-known symbols (`source: "host"`) for the current host. + fn symbol(&self, name: &str) -> Option; + /// Local discovery for caller-supplied symbols: `PATH` lookup and tool + /// configuration. It never executes tools, installs software, or contacts + /// the network. A failed configuration read is an error, not absence. + fn discover(&self, name: &str) -> Result>; + /// Compares two local host paths by filesystem object identity + /// (`mxc_common::filesystem_object`): a cleanly missing path is + /// `Different`, and an unexaminable one is `Unknown`. + fn compare_objects(&self, a: &str, b: &str) -> ExistingObjectComparison; +} + +/// How one path relates to another by filesystem object identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectRelation { + /// `inner` names the object of `outer` or an object beneath it. + Within, + NotWithin, + /// Identity could not be established. + Unknown, +} + +/// Whether `inner` is `outer` or beneath it by object identity, comparing +/// existing ancestors. A cleanly missing suffix of `outer` is compared +/// relative to its deepest existing ancestor; it is never treated as an +/// existing alias. +pub fn object_within(host: &dyn HostEnvironment, inner: &str, outer: &str) -> ObjectRelation { + let outer_path = Path::new(outer); + let mut anchor = None; + for ancestor in outer_path.ancestors() { + let text = ancestor.to_string_lossy(); + if text.is_empty() { + break; + } + match host.compare_objects(&text, &text) { + ExistingObjectComparison::Same => { + let suffix = outer_path + .strip_prefix(ancestor) + .map(Path::to_path_buf) + .unwrap_or_default(); + anchor = Some((text.into_owned(), suffix)); + break; + } + ExistingObjectComparison::Different => {} + ExistingObjectComparison::Unknown => return ObjectRelation::Unknown, + } + } + let Some((anchor, suffix)) = anchor else { + return ObjectRelation::NotWithin; + }; + let inner_path = Path::new(inner); + let mut unknown = false; + for ancestor in inner_path.ancestors() { + let text = ancestor.to_string_lossy(); + if text.is_empty() { + break; + } + let remainder = inner_path.strip_prefix(ancestor).unwrap_or(inner_path); + if !remainder.starts_with(&suffix) { + continue; + } + match host.compare_objects(&text, &anchor) { + ExistingObjectComparison::Same => return ObjectRelation::Within, + ExistingObjectComparison::Different => {} + ExistingObjectComparison::Unknown => unknown = true, + } + } + if unknown { + ObjectRelation::Unknown + } else { + ObjectRelation::NotWithin + } +} + +/// Executables discovered on `PATH` for caller-supplied prefix symbols. +fn discovery_executable(name: &str) -> Option<&'static str> { + match name { + "git_prefix" => Some("git"), + "node_prefix" => Some("node"), + "npm_prefix" => Some("npm"), + "ssh_prefix" => Some("ssh"), + _ => None, + } +} + +/// The directory on `PATH` containing `executable` (with `PATHEXT` on +/// Windows). Nothing is executed. +fn find_on_path(executable: &str) -> Option { + let path = std::env::var_os("PATH")?; + let extensions: Vec = if cfg!(windows) { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) + .split(';') + .filter(|e| !e.is_empty()) + .map(str::to_string) + .collect() + } else { + vec![String::new()] + }; + for directory in std::env::split_paths(&path) { + if !directory.is_absolute() { + continue; + } + for extension in &extensions { + let candidate = directory.join(format!("{executable}{extension}")); + if candidate.is_file() { + return Some(directory.to_string_lossy().into_owned()); + } + } + } + None +} + +/// npm's cache override: the `npm_config_cache` environment variable, then +/// the `cache` key of the user `.npmrc`. Defaults come from the catalog. +fn discover_npm_cache() -> Result> { + for name in ["npm_config_cache", "NPM_CONFIG_CACHE"] { + if let Some(value) = non_empty_env(name) { + return Ok(Some(value)); + } + } + let Some(home) = home_dir() else { + return Ok(None); + }; + let npmrc = Path::new(&home).join(".npmrc"); + let text = match std::fs::read_to_string(&npmrc) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(PolicyCatalogError::new( + ErrorReason::UnsupportedHost, + format!( + "symbol discovery could not read '{}': {error}", + npmrc.display() + ), + )) + } + }; + for line in text.lines() { + let line = line.trim(); + if line.starts_with(['#', ';']) { + continue; + } + if let Some((key, value)) = line.split_once('=') { + if key.trim() == "cache" { + let value = value.trim().trim_matches('"'); + if !value.is_empty() && !value.contains("${") { + return Ok(Some(value.to_string())); + } + } + } + } + Ok(None) +} + +/// Maps an OS-reported machine/architecture string to a catalog selector. +pub fn architecture_from_machine(machine: &str) -> Option { + match crate::policy_store::text::js_trim(machine) + .to_lowercase() + .as_str() + { + "x86_64" | "amd64" | "x64" => Some(Architecture::X64), + "arm64" | "aarch64" => Some(Architecture::Arm64), + _ => None, + } +} + +/// Node's `os.platform()` name for the current OS. +fn node_platform_name() -> &'static str { + match std::env::consts::OS { + "windows" => "win32", + "macos" => "darwin", + "solaris" | "illumos" => "sunos", + other => other, + } +} + +fn host_platform() -> Result { + match std::env::consts::OS { + "windows" => Ok(Platform::Windows), + "macos" => Ok(Platform::Macos), + "linux" => Ok(Platform::Linux), + _ => Err(PolicyCatalogError::new( + ErrorReason::UnsupportedHost, + format!( + "host platform '{}' has no catalog selector", + node_platform_name() + ), + )), + } +} + +/// Runs an executable by absolute path; stdout as text, like `execFileSync`. +fn run(file: &str, args: &[&str]) -> std::result::Result { + let mut command = Command::new(file); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let output = command.output().map_err(|error| { + let code = match error.kind() { + std::io::ErrorKind::NotFound => "ENOENT".to_string(), + std::io::ErrorKind::PermissionDenied => "EACCES".to_string(), + _ => error.to_string(), + }; + format!("spawnSync {file} {code}") + })?; + if !output.status.success() { + return Err(format!("Command failed: {file} {}", args.join(" "))); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Kernel machine type (Node `os.machine()`). +#[cfg(unix)] +fn os_machine() -> std::result::Result { + // SAFETY: `uname` fills a caller-provided, zero-initialized struct. + let mut name: libc::utsname = unsafe { std::mem::zeroed() }; + if unsafe { libc::uname(&mut name) } != 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + // SAFETY: `machine` is NUL-terminated after a successful `uname`. + let machine = unsafe { std::ffi::CStr::from_ptr(name.machine.as_ptr()) }; + Ok(machine.to_string_lossy().into_owned()) +} + +#[cfg(not(unix))] +fn os_machine() -> std::result::Result { + Err("os.machine() is not available on this platform".to_string()) +} + +/// Extracts the value from `reg query ... /v PROCESSOR_ARCHITECTURE` output +/// (`/PROCESSOR_ARCHITECTURE\s+REG_SZ\s+(\S+)/i`). +fn parse_reg_output(output: &str) -> Option { + let lower = output.to_ascii_lowercase(); + let mut from = 0; + while let Some(found) = lower[from..].find("processor_architecture") { + let start = from + found; + let rest = &output[start + "processor_architecture".len()..]; + let trimmed = rest.trim_start_matches(crate::policy_store::text::js_is_space); + if trimmed.len() < rest.len() + && trimmed.len() >= 6 + && trimmed[..6].eq_ignore_ascii_case("reg_sz") + { + let after = &trimmed[6..]; + let value = after.trim_start_matches(crate::policy_store::text::js_is_space); + if value.len() < after.len() { + let end = value + .find(crate::policy_store::text::js_is_space) + .unwrap_or(value.len()); + if end > 0 { + return Some(value[..end].to_string()); + } + } + } + from = start + 1; + } + None +} + +fn detect_native_architecture() -> Result { + let reported: std::result::Result, String> = match std::env::consts::OS { + "windows" => { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); + let reg = format!("{}\\System32\\reg.exe", root.trim_end_matches(['\\', '/'])); + run( + ®, + &[ + "query", + "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + "/v", + "PROCESSOR_ARCHITECTURE", + ], + ) + .map(|output| parse_reg_output(&output)) + } + "macos" => { + let apple_silicon = run("/usr/sbin/sysctl", &["-n", "hw.optional.arm64"]) + .map(|output| output.trim() == "1") + .unwrap_or(false); + if apple_silicon { + Ok(Some("arm64".to_string())) + } else { + os_machine().map(Some) + } + } + _ => os_machine().map(Some), + }; + let reported = reported.map_err(|message| { + PolicyCatalogError::new( + ErrorReason::UnsupportedHost, + format!("native system architecture could not be determined: {message}"), + ) + })?; + match reported.as_deref().and_then(architecture_from_machine) { + Some(architecture) => Ok(architecture), + None => Err(PolicyCatalogError::new( + ErrorReason::UnsupportedHost, + format!( + "native system architecture '{}' has no catalog selector", + reported.as_deref().unwrap_or("unknown") + ), + )), + } +} + +fn non_empty_env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +/// Node `os.homedir()`. +fn home_dir() -> Option { + let from_env = if cfg!(windows) { + non_empty_env("USERPROFILE") + } else { + non_empty_env("HOME") + }; + from_env.or_else(|| { + #[allow(deprecated)] + std::env::home_dir() + .map(|p| p.to_string_lossy().into_owned()) + .filter(|p| !p.is_empty()) + }) +} + +/// Node `os.tmpdir()`. +fn temp_dir() -> Option { + if cfg!(windows) { + let mut path = non_empty_env("TEMP") + .or_else(|| non_empty_env("TMP")) + .unwrap_or_else(|| { + format!( + "{}\\temp", + std::env::var("SystemRoot") + .or_else(|_| std::env::var("windir")) + .unwrap_or_default() + ) + }); + let bytes = path.as_bytes(); + if path.len() > 1 && path.ends_with('\\') && !(path.len() == 3 && bytes[1] == b':') { + path.pop(); + } + Some(path) + } else { + let mut path = non_empty_env("TMPDIR") + .or_else(|| non_empty_env("TMP")) + .or_else(|| non_empty_env("TEMP")) + .unwrap_or_else(|| "/tmp".to_string()); + if path.len() > 1 && path.ends_with('/') { + path.pop(); + } + Some(path).filter(|p| !p.is_empty()) + } +} + +/// The real host. The native architecture is detected once per process, on +/// first need (successful detections are cached). +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemHost; + +impl HostEnvironment for SystemHost { + fn platform(&self) -> Result { + host_platform() + } + + fn native_architecture(&self) -> Result { + static CACHE: OnceLock = OnceLock::new(); + if let Some(architecture) = CACHE.get() { + return Ok(*architecture); + } + let architecture = detect_native_architecture()?; + Ok(*CACHE.get_or_init(|| architecture)) + } + + fn symbol(&self, name: &str) -> Option { + match name { + "user_home" => home_dir(), + "temp_dir" => temp_dir(), + "programData" if cfg!(windows) => non_empty_env("ProgramData"), + "localAppData" if cfg!(windows) => non_empty_env("LOCALAPPDATA"), + _ => None, + } + } + + fn discover(&self, name: &str) -> Result> { + if name == "npm_cache" { + return discover_npm_cache(); + } + Ok(discovery_executable(name).and_then(find_on_path)) + } + + fn compare_objects(&self, a: &str, b: &str) -> ExistingObjectComparison { + compare_existing_filesystem_objects(Path::new(a), Path::new(b)) + } +} + +/// A host with fixed facts, for tests and callers that already know them. +/// +/// Object identity is simulated: every path exists and is distinct unless +/// listed as an alias (the same object) or as unknown. +#[derive(Clone, Debug)] +pub struct FixedHost { + pub platform: Platform, + pub architecture: Architecture, + pub symbols: Vec<(String, String)>, + pub discovered: Vec<(String, String)>, + pub aliases: Vec<(String, String)>, + pub unknown_paths: Vec, +} + +impl FixedHost { + pub fn new(platform: Platform, architecture: Architecture) -> Self { + Self { + platform, + architecture, + symbols: Vec::new(), + discovered: Vec::new(), + aliases: Vec::new(), + unknown_paths: Vec::new(), + } + } + + pub fn with_symbol(mut self, name: impl Into, value: impl Into) -> Self { + self.symbols.push((name.into(), value.into())); + self + } + + pub fn with_discovered(mut self, name: impl Into, value: impl Into) -> Self { + self.discovered.push((name.into(), value.into())); + self + } + + /// Declares that two paths name the same filesystem object. + pub fn with_alias(mut self, a: impl Into, b: impl Into) -> Self { + self.aliases.push((a.into(), b.into())); + self + } + + /// Declares a path whose identity cannot be determined. + pub fn with_unknown_path(mut self, path: impl Into) -> Self { + self.unknown_paths.push(path.into()); + self + } +} + +impl HostEnvironment for FixedHost { + fn platform(&self) -> Result { + Ok(self.platform) + } + + fn native_architecture(&self) -> Result { + Ok(self.architecture) + } + + fn symbol(&self, name: &str) -> Option { + self.symbols + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + } + + fn discover(&self, name: &str) -> Result> { + Ok(self + .discovered + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone())) + } + + fn compare_objects(&self, a: &str, b: &str) -> ExistingObjectComparison { + if self.unknown_paths.iter().any(|p| p == a || p == b) { + return ExistingObjectComparison::Unknown; + } + let aliased = self + .aliases + .iter() + .any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + if a == b || aliased { + ExistingObjectComparison::Same + } else { + ExistingObjectComparison::Different + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_machine_names() { + assert_eq!(architecture_from_machine("AMD64"), Some(Architecture::X64)); + assert_eq!(architecture_from_machine("x86_64"), Some(Architecture::X64)); + assert_eq!( + architecture_from_machine("ARM64"), + Some(Architecture::Arm64) + ); + assert_eq!( + architecture_from_machine(" aarch64\n"), + Some(Architecture::Arm64) + ); + assert_eq!(architecture_from_machine("riscv64"), None); + assert_eq!(architecture_from_machine("x86"), None); + } + + #[test] + fn parses_reg_query_output() { + let output = + "\r\nHKEY_LOCAL_MACHINE\\SYSTEM\\...\\Environment\r\n PROCESSOR_ARCHITECTURE REG_SZ ARM64\r\n\r\n"; + assert_eq!(parse_reg_output(output).as_deref(), Some("ARM64")); + assert_eq!(parse_reg_output("nothing"), None); + } + + #[test] + fn system_host_detects_a_supported_architecture_here() { + // Every CI host (x64/arm64 Windows, Linux, macOS) must be detectable. + let host = SystemHost; + assert!(host.platform().is_ok()); + assert!(host.native_architecture().is_ok()); + } +} diff --git a/src/mxc-sdk/src/policy_store/json.rs b/src/mxc-sdk/src/policy_store/json.rs new file mode 100644 index 000000000..437c6e719 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/json.rs @@ -0,0 +1,782 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A small JSON value model with ECMAScript semantics. +//! +//! The TypeScript implementation parses catalog data with `JSON.parse`, so +//! every number is an IEEE double, object keys follow ECMAScript property +//! order (array-index keys ascending first, then insertion order; a repeated +//! key keeps its first position and takes the last value), and there is no +//! nesting limit. [`Json::parse`] reproduces those rules exactly so validation +//! messages and output match byte for byte. +//! +//! Known difference: a lone UTF-16 surrogate escape (`"\ud800"`) cannot be +//! represented in a Rust `String` and is rejected as a parse error. + +use std::cmp::Ordering; +use std::fmt; + +/// A parsed JSON value. Numbers are always `f64`, as in ECMAScript. +#[derive(Clone, Debug)] +pub enum Json { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(JsonObject), +} + +/// A JSON object whose key order follows ECMAScript `OrdinaryOwnPropertyKeys`. +#[derive(Clone, Debug, Default)] +pub struct JsonObject { + entries: Vec<(String, Json)>, +} + +/// Returns the array index a property key denotes, if any (ECMAScript +/// canonical numeric string in `0..2^32-1`). +fn array_index(key: &str) -> Option { + let bytes = key.as_bytes(); + if bytes.is_empty() || bytes.len() > 10 || !bytes.iter().all(u8::is_ascii_digit) { + return None; + } + if bytes.len() > 1 && bytes[0] == b'0' { + return None; + } + let value: u64 = key.parse().ok()?; + if value < u64::from(u32::MAX) { + Some(value as u32) + } else { + None + } +} + +impl JsonObject { + pub fn new() -> Self { + Self::default() + } + + /// Sets `key`. An existing key keeps its position; a new key is placed by + /// ECMAScript property-order rules. + pub fn insert(&mut self, key: impl Into, value: Json) { + let key = key.into(); + if let Some(existing) = self.entries.iter_mut().find(|(k, _)| *k == key) { + existing.1 = value; + return; + } + match array_index(&key) { + Some(index) => { + let position = self + .entries + .iter() + .position(|(k, _)| array_index(k).is_none_or(|other| other > index)) + .unwrap_or(self.entries.len()); + self.entries.insert(position, (key, value)); + } + None => self.entries.push((key, value)), + } + } + + pub fn get(&self, key: &str) -> Option<&Json> { + self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } + + pub fn contains_key(&self, key: &str) -> bool { + self.entries.iter().any(|(k, _)| k == key) + } + + pub fn remove(&mut self, key: &str) -> Option { + let index = self.entries.iter().position(|(k, _)| k == key)?; + Some(self.entries.remove(index).1) + } + + /// Keys in ECMAScript `Object.keys` order. + pub fn keys(&self) -> impl Iterator { + self.entries.iter().map(|(k, _)| k.as_str()) + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(k, v)| (k.as_str(), v)) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl PartialEq for JsonObject { + /// Deep equality that ignores key order (like `assert.deepEqual`). + fn eq(&self, other: &Self) -> bool { + self.len() == other.len() && self.iter().all(|(k, v)| other.get(k) == Some(v)) + } +} + +impl PartialEq for Json { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Json::Null, Json::Null) => true, + (Json::Bool(a), Json::Bool(b)) => a == b, + (Json::Number(a), Json::Number(b)) => a == b, + (Json::String(a), Json::String(b)) => a == b, + (Json::Array(a), Json::Array(b)) => a == b, + (Json::Object(a), Json::Object(b)) => a == b, + _ => false, + } + } +} + +impl From<&str> for Json { + fn from(value: &str) -> Self { + Json::String(value.to_string()) + } +} + +impl From for Json { + fn from(value: String) -> Self { + Json::String(value) + } +} + +impl From for Json { + fn from(value: f64) -> Self { + Json::Number(value) + } +} + +impl From for Json { + fn from(value: bool) -> Self { + Json::Bool(value) + } +} + +impl From for Json { + fn from(value: JsonObject) -> Self { + Json::Object(value) + } +} + +impl From> for Json { + fn from(value: Vec) -> Self { + Json::Array(value) + } +} + +impl Json { + pub fn as_str(&self) -> Option<&str> { + match self { + Json::String(s) => Some(s), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Json::Number(n) => Some(*n), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Json::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&Vec> { + match self { + Json::Array(a) => Some(a), + _ => None, + } + } + + pub fn as_object(&self) -> Option<&JsonObject> { + match self { + Json::Object(o) => Some(o), + _ => None, + } + } + + pub fn is_null(&self) -> bool { + matches!(self, Json::Null) + } + + /// Property lookup; `None` for a missing key or a non-object value. + pub fn get(&self, key: &str) -> Option<&Json> { + self.as_object().and_then(|o| o.get(key)) + } + + /// Parses JSON text with `JSON.parse` semantics. + pub fn parse(text: &str) -> Result { + let mut parser = Parser { + text, + bytes: text.as_bytes(), + pos: 0, + }; + parser.skip_ws(); + let value = parser.value()?; + parser.skip_ws(); + if parser.pos != parser.bytes.len() { + return Err(parser.unexpected()); + } + Ok(value) + } + + /// `JSON.stringify(value)` (compact). + pub fn to_compact_string(&self) -> String { + let mut out = String::new(); + write_value(self, &mut out, None, 0); + out + } + + /// `JSON.stringify(value, null, 2)`. + pub fn to_pretty_string(&self) -> String { + let mut out = String::new(); + write_value(self, &mut out, Some(2), 0); + out + } +} + +impl fmt::Display for Json { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_compact_string()) + } +} + +/// JSON syntax error. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JsonError { + pub message: String, +} + +impl fmt::Display for JsonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for JsonError {} + +struct Parser<'a> { + text: &'a str, + bytes: &'a [u8], + pos: usize, +} + +impl Parser<'_> { + fn unexpected(&self) -> JsonError { + match self.text[self.pos..].chars().next() { + None => JsonError { + message: "Unexpected end of JSON input".to_string(), + }, + Some(c) => JsonError { + message: format!("Unexpected token '{c}' in JSON at position {}", self.pos), + }, + } + } + + fn error(&self, message: &str) -> JsonError { + JsonError { + message: format!("{message} in JSON at position {}", self.pos), + } + } + + fn skip_ws(&mut self) { + while let Some(b) = self.bytes.get(self.pos) { + if matches!(b, b' ' | b'\t' | b'\n' | b'\r') { + self.pos += 1; + } else { + break; + } + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn literal(&mut self, word: &str, value: Json) -> Result { + if self.text[self.pos..].starts_with(word) { + self.pos += word.len(); + Ok(value) + } else { + Err(self.unexpected()) + } + } + + fn value(&mut self) -> Result { + match self.peek() { + Some(b'{') => self.object(), + Some(b'[') => self.array(), + Some(b'"') => Ok(Json::String(self.string()?)), + Some(b't') => self.literal("true", Json::Bool(true)), + Some(b'f') => self.literal("false", Json::Bool(false)), + Some(b'n') => self.literal("null", Json::Null), + Some(b'-' | b'0'..=b'9') => self.number(), + _ => Err(self.unexpected()), + } + } + + fn object(&mut self) -> Result { + self.pos += 1; + let mut object = JsonObject::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Json::Object(object)); + } + loop { + self.skip_ws(); + if self.peek() != Some(b'"') { + return Err(self.unexpected()); + } + let key = self.string()?; + self.skip_ws(); + if self.peek() != Some(b':') { + return Err(self.unexpected()); + } + self.pos += 1; + self.skip_ws(); + let value = self.value()?; + object.insert(key, value); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return Ok(Json::Object(object)); + } + _ => return Err(self.unexpected()), + } + } + } + + fn array(&mut self) -> Result { + self.pos += 1; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Json::Array(items)); + } + loop { + self.skip_ws(); + items.push(self.value()?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return Ok(Json::Array(items)); + } + _ => return Err(self.unexpected()), + } + } + } + + fn digits(&mut self) -> usize { + let start = self.pos; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + self.pos - start + } + + fn number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + match self.peek() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => { + self.digits(); + } + _ => return Err(self.unexpected()), + } + if self.peek() == Some(b'.') { + self.pos += 1; + if self.digits() == 0 { + return Err(self.unexpected()); + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.pos += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.pos += 1; + } + if self.digits() == 0 { + return Err(self.unexpected()); + } + } + // Rust's float parser is correctly rounded (like ECMAScript + // StringToNumber) and overflows to infinity like JSON.parse. + let lexeme = &self.text[start..self.pos]; + lexeme + .parse::() + .map(Json::Number) + .map_err(|_| self.error("Invalid number")) + } + + fn hex4(&mut self) -> Result { + let slice = self + .bytes + .get(self.pos..self.pos + 4) + .ok_or_else(|| self.error("Bad Unicode escape"))?; + let mut value = 0u32; + for b in slice { + let digit = (*b as char) + .to_digit(16) + .ok_or_else(|| self.error("Bad Unicode escape"))?; + value = value * 16 + digit; + } + self.pos += 4; + Ok(value) + } + + fn string(&mut self) -> Result { + self.pos += 1; + let mut out = String::new(); + loop { + let start = self.pos; + while let Some(b) = self.peek() { + if b == b'"' || b == b'\\' || b < 0x20 { + break; + } + self.pos += 1; + } + out.push_str(&self.text[start..self.pos]); + match self.peek() { + None => return Err(self.error("Unterminated string")), + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + let escape = self + .peek() + .ok_or_else(|| self.error("Bad escaped character"))?; + self.pos += 1; + match escape { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let unit = self.hex4()?; + if (0xD800..0xDC00).contains(&unit) { + if self.text[self.pos..].starts_with("\\u") { + let save = self.pos; + self.pos += 2; + let low = self.hex4()?; + if (0xDC00..0xE000).contains(&low) { + let code = + 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00); + out.push( + char::from_u32(code).expect("valid surrogate pair"), + ); + continue; + } + self.pos = save; + } + return Err(self.error("Lone surrogate escapes are not supported")); + } + if (0xDC00..0xE000).contains(&unit) { + return Err(self.error("Lone surrogate escapes are not supported")); + } + out.push(char::from_u32(unit).expect("BMP scalar")); + } + _ => { + self.pos -= 1; + return Err(self.error("Bad escaped character")); + } + } + } + Some(_) => return Err(self.error("Bad control character in string literal")), + } + } + } +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/// `JSON.stringify` of a string: quotes, `\"`, `\\`, short escapes, and +/// `\u00XX` (lower-case hex) for other control characters. Everything else is +/// emitted literally. +pub fn quote_json_string(value: &str, out: &mut String) { + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// ECMAScript `Number::toString(x)` (radix 10). +pub fn js_number_to_string(value: f64) -> String { + if value.is_nan() { + return "NaN".to_string(); + } + if value == 0.0 { + return "0".to_string(); + } + if value.is_infinite() { + return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + let mut out = String::new(); + if value < 0.0 { + out.push('-'); + } + // Rust's `{:e}` yields the shortest round-tripping digits (closest to the + // exact value), which is the digit string ECMAScript specifies. + let formatted = format!("{:e}", value.abs()); + let (mantissa, exponent) = formatted.split_once('e').expect("exponent form"); + let exponent: i32 = exponent.parse().expect("integer exponent"); + let digits: String = mantissa.chars().filter(|c| *c != '.').collect(); + let k = digits.len() as i32; + let n = exponent + 1; + if k <= n && n <= 21 { + out.push_str(&digits); + out.extend(std::iter::repeat_n('0', (n - k) as usize)); + } else if 0 < n && n <= 21 { + out.push_str(&digits[..n as usize]); + out.push('.'); + out.push_str(&digits[n as usize..]); + } else if -6 < n && n <= 0 { + out.push_str("0."); + out.extend(std::iter::repeat_n('0', (-n) as usize)); + out.push_str(&digits); + } else { + let e = n - 1; + out.push_str(&digits[..1]); + if k > 1 { + out.push('.'); + out.push_str(&digits[1..]); + } + out.push('e'); + out.push(if e < 0 { '-' } else { '+' }); + out.push_str(&e.abs().to_string()); + } + out +} + +/// A number as `JSON.stringify` writes it (`null` for non-finite values). +pub fn json_number(value: f64) -> String { + if value.is_finite() { + js_number_to_string(value) + } else { + "null".to_string() + } +} + +fn newline(out: &mut String, indent: Option, depth: usize) { + if let Some(width) = indent { + out.push('\n'); + out.extend(std::iter::repeat_n(' ', width * depth)); + } +} + +fn write_value(value: &Json, out: &mut String, indent: Option, depth: usize) { + match value { + Json::Null => out.push_str("null"), + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Json::Number(n) => out.push_str(&json_number(*n)), + Json::String(s) => quote_json_string(s, out), + Json::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + newline(out, indent, depth + 1); + write_value(item, out, indent, depth + 1); + } + newline(out, indent, depth); + out.push(']'); + } + Json::Object(object) => { + if object.is_empty() { + out.push_str("{}"); + return; + } + out.push('{'); + for (index, (key, item)) in object.iter().enumerate() { + if index > 0 { + out.push(','); + } + newline(out, indent, depth + 1); + quote_json_string(key, out); + out.push(':'); + if indent.is_some() { + out.push(' '); + } + write_value(item, out, indent, depth + 1); + } + newline(out, indent, depth); + out.push('}'); + } + } +} + +/// Compares two strings by UTF-16 code units (ECMAScript `<` and default `sort`). +pub fn cmp_utf16(left: &str, right: &str) -> Ordering { + left.encode_utf16().cmp(right.encode_utf16()) +} + +/// Canonical JSON: keys sorted by UTF-16 code units at every level, arrays in +/// order, no insignificant whitespace, `JSON.stringify` scalars. +pub fn canonical_json(value: &Json) -> String { + let mut out = String::new(); + write_canonical(value, &mut out); + out +} + +fn write_canonical(value: &Json, out: &mut String) { + match value { + Json::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_canonical(item, out); + } + out.push(']'); + } + Json::Object(object) => { + let mut keys: Vec<(&str, &Json)> = object.iter().collect(); + keys.sort_by(|a, b| cmp_utf16(a.0, b.0)); + out.push('{'); + for (index, (key, item)) in keys.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + quote_json_string(key, out); + out.push(':'); + write_canonical(item, out); + } + out.push('}'); + } + scalar => write_value(scalar, out, None, 0), + } +} + +/// ECMAScript `String(value)` for a JSON value (used where TypeScript +/// interpolates unvalidated data into a message). +pub fn js_to_string(value: &Json) -> String { + match value { + Json::Null => "null".to_string(), + Json::Bool(b) => b.to_string(), + Json::Number(n) => js_number_to_string(*n), + Json::String(s) => s.clone(), + Json::Array(items) => items + .iter() + .map(|item| { + if item.is_null() { + String::new() + } else { + js_to_string(item) + } + }) + .collect::>() + .join(","), + Json::Object(_) => "[object Object]".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn number_to_string_follows_ecmascript() { + let cases: &[(f64, &str)] = &[ + (0.0, "0"), + (-0.0, "0"), + (1.0, "1"), + (1.5, "1.5"), + (0.1, "0.1"), + (1e21, "1e+21"), + (1e20, "100000000000000000000"), + (1e-7, "1e-7"), + (0.000001, "0.000001"), + (2.5e-7, "2.5e-7"), + (123456789012345680000.0, "123456789012345680000"), + (5e-324, "5e-324"), + (1.7976931348623157e308, "1.7976931348623157e+308"), + (-1234.5678, "-1234.5678"), + (0.30000000000000004, "0.30000000000000004"), + (1.2345e-5, "0.000012345"), + (123e-20, "1.23e-18"), + ]; + for (value, expected) in cases { + assert_eq!(js_number_to_string(*value), *expected, "{value:e}"); + } + } + + #[test] + fn parse_follows_json_parse() { + let value = Json::parse(r#"{"b":1,"2":2,"a":3,"1":4,"b":5}"#).unwrap(); + let keys: Vec<&str> = value.as_object().unwrap().keys().collect(); + assert_eq!(keys, ["1", "2", "b", "a"]); + assert_eq!(value.get("b"), Some(&Json::Number(5.0))); + assert_eq!( + Json::parse("12345678901234567890").unwrap(), + Json::Number(12345678901234567000.0) + ); + assert_eq!(Json::parse("1e400").unwrap(), Json::Number(f64::INFINITY)); + assert_eq!(canonical_json(&Json::parse("1e400").unwrap()), "null"); + for bad in [ + "", + "01", + "1.", + "[1,]", + "{\"a\":1,}", + "\u{feff}{}", + "\"\\x\"", + "tru", + "\"\u{1}\"", + "\"\\ud800\"", + ] { + assert!(Json::parse(bad).is_err(), "{bad:?}"); + } + assert!(Json::parse(" \t\r\n[ ] ").is_ok()); + assert_eq!( + Json::parse(r#""\ud83d\ude00""#).unwrap(), + Json::String("\u{1F600}".into()) + ); + } + + #[test] + fn pretty_matches_json_stringify_indent_2() { + let value = Json::parse(r#"{"a":[],"b":{},"c":[1,{"d":null}]}"#).unwrap(); + assert_eq!( + value.to_pretty_string(), + "{\n \"a\": [],\n \"b\": {},\n \"c\": [\n 1,\n {\n \"d\": null\n }\n ]\n}" + ); + } +} diff --git a/src/mxc-sdk/src/policy_store/mod.rs b/src/mxc-sdk/src/policy_store/mod.rs new file mode 100644 index 000000000..7c575372a --- /dev/null +++ b/src/mxc-sdk/src/policy_store/mod.rs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! **PROTOTYPE, pending API review.** The MXC policy store: resolves known +//! tools to command-free MXC 1.0 container requirements (filesystem, network, +//! UI, timeout) from a policy catalog that is bundled statically in +//! `mxc-sdk`. +//! +//! The API names, shapes, and catalog contents are proposed and may change +//! before sign-off. The feature is not part of MXC 1.0; it targets a later SDK +//! release and builds the SDK's own v1 `ContainerRequest` types. +//! +//! References to "design §N" are to `docs/mxc-policy-store.md` (catalog +//! design) and to "API spec §N" are to `docs/mxc-policy-store-api.md` (the +//! caller-facing contract), both in microsoft/mxc#1309. +//! +//! The public surface is `crate::v1::resolve_tool_requirements` and +//! `crate::v1::resolve_tool_requirements_with_diagnostics` (plus the +//! metadata calls). The internal lookup ([`resolve_requirements`], +//! [`resolve_requirements_with_diagnostics`], [`get_catalog_info`], +//! [`list_catalog_entries`], and [`PolicyCatalog`]) returns a **best-effort +//! floor**, not a guarantee: the access a known tool typically needs, which +//! callers review and constrain before adding a command. It is complementary to Learning +//! Mode, not a replacement. It never grants access, launches a sandbox, +//! contacts a network service, or writes consumer state. The V1 catalog is +//! compiled in and validated on first use; nothing is downloaded. +//! +//! This is an internal `mxc-sdk` module. The Node and C# SDKs reach it through +//! `mxc_ffi`. [`tooling`] exposes the catalog contribution rules (validation, +//! history, canonical JSON, paths) that the `policy_store_*` tests enforce. + +pub mod assemble; +pub mod catalog; +pub mod compose; +pub mod effective; +pub mod errors; +pub mod exact; +pub mod history; +pub mod host; +pub mod json; +pub mod model; +pub mod netrule; +pub mod paths; +pub mod purl; +pub mod request; +pub mod resolver; +pub mod sdk; +pub mod store; +mod text; +pub mod validate; +pub mod vers; +pub mod view; + +pub use crate::mxc_common::filesystem_object::ExistingObjectComparison; +pub use errors::{ErrorCode, ErrorReason, PolicyCatalogError}; +pub use host::{FixedHost, HostEnvironment, SystemHost}; +pub use model::{ + Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, + CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, DependencyRecord, + DetailWarningKind, Diagnostics, FilesystemRequirements, IdentityStrength, IntentMode, + IntentSelection, MatchedIdentity, NetworkRequirement, PathAccess, PathRequirement, Platform, + PlatformVariantMetadata, Provenance, PurlComponent, Requirements, RequirementsResolution, + ResolutionDetailWarning, ResolveContext, SymbolMap, SymbolValueSource, ToolCandidate, + ToolInput, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, ToolSelection, + ToolWarningKind, VersionSelection, VersionStatus, VersionVariantMetadata, Warning, +}; +pub use resolver::{ + get_catalog_info, list_catalog_entries, resolve_requirements, + resolve_requirements_with_diagnostics, PolicyCatalog, +}; +pub use store::{ + bundled_catalog_store, load_catalog_directory, CatalogManifest, CatalogSource, CatalogStore, + DirectorySource, ManifestRevision, MemorySource, +}; + +/// Contribution and CI tooling (not part of the runtime lookup API). +pub mod tooling { + pub use crate::policy_store::assemble::{ + assemble_revision, collect_entry_sources, render_revision, EntrySource, + }; + pub use crate::policy_store::catalog::{ + compare_catalog_revisions, validate_catalog_revision, validate_contract, Additions, + CatalogContract, CatalogEntry, CatalogRevision, Dependency, EntryDefault, + IdentityPredicate, IntentDefinition, Overlay, PlatformVariant, SymbolSource, + VersionVariant, + }; + pub use crate::policy_store::effective::{ + materialize, materialize_entry, select_platform_variant, Effective, IntentChoice, + Materialized, PlatformSelection, + }; + pub use crate::policy_store::exact::{ + bind_fixture_symbols, exact_document, to_container_requirements, validate_exact, + VALIDATION_COMMAND, + }; + pub use crate::policy_store::history::{ + check_entry_revisions, check_published_immutability, check_store_history, + PublishedRevision, PublishedState, + }; + pub use crate::policy_store::host::{architecture_from_machine, object_within, ObjectRelation}; + pub use crate::policy_store::json::{canonical_json, js_number_to_string, Json, JsonObject}; + pub use crate::policy_store::paths::{ + case_key, folds_case, is_absolute_path, normalize_path, path_key_segments, + }; + pub use crate::policy_store::purl::{parse_purl, ParsedPurl}; + pub use crate::policy_store::store::{ + bundled_catalog_files, bundled_catalog_source, validate_manifest, + }; + pub use crate::policy_store::validate::{ + check_against_base_ref, read_published_state_at_ref, validate_bundled_catalog, + validate_catalog_directory, CatalogValidationReport, + }; + pub use crate::policy_store::view::{render_exact_requests, render_reviewer_view}; +} + +pub use vers::{VersRange, Version, VersionScheme}; diff --git a/src/mxc-sdk/src/policy_store/model.rs b/src/mxc-sdk/src/policy_store/model.rs new file mode 100644 index 000000000..5336ff189 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/model.rs @@ -0,0 +1,1221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Public request and result types (API spec §2). Every result type renders to +//! the exact JSON shape the original TypeScript prototype produced, which the +//! SDK bindings and conformance vectors rely on (`to_json`), with absent optional fields +//! omitted rather than written as `null`. + +use crate::policy_store::json::{Json, JsonObject}; +use crate::policy_store::vers::VersionScheme; +use std::fmt; + +/// Catalog platform selector (design §4.4). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Platform { + Windows, + Linux, + Macos, +} + +impl Platform { + /// Declaration order used by validation (`PLATFORMS`). + pub const ALL: [Platform; 3] = [Platform::Windows, Platform::Linux, Platform::Macos]; + + pub fn as_str(self) -> &'static str { + match self { + Platform::Windows => "windows", + Platform::Linux => "linux", + Platform::Macos => "macos", + } + } + + pub fn parse(value: &str) -> Option { + Platform::ALL.into_iter().find(|p| p.as_str() == value) + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Catalog architecture selector (design §4.4). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Architecture { + X64, + Arm64, +} + +impl Architecture { + /// Declaration order used by validation (`ARCHITECTURES`). + pub const ALL: [Architecture; 2] = [Architecture::X64, Architecture::Arm64]; + + pub fn as_str(self) -> &'static str { + match self { + Architecture::X64 => "x64", + Architecture::Arm64 => "arm64", + } + } + + pub fn parse(value: &str) -> Option { + Architecture::ALL.into_iter().find(|a| a.as_str() == value) + } +} + +impl fmt::Display for Architecture { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Runtime lookup input (API spec §2). `package_url` is the strong +/// identity and `invocation_name` the opt-in weak fallback. Version evidence +/// comes only from `detected_version`; a version embedded in `package_url` +/// is ignored. `intent` names a tool-defined intent such as Git's `push`; +/// without one, the base and every effective intent contribute. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ToolCandidate { + pub invocation_name: String, + pub package_url: Option, + pub detected_version: Option, + pub intent: Option, +} + +impl ToolCandidate { + pub fn new(invocation_name: impl Into) -> Self { + Self { + invocation_name: invocation_name.into(), + ..Self::default() + } + } + + pub fn with_package_url(mut self, value: impl Into) -> Self { + self.package_url = Some(value.into()); + self + } + + pub fn with_detected_version(mut self, value: impl Into) -> Self { + self.detected_version = Some(value.into()); + self + } + + pub fn with_intent(mut self, value: impl Into) -> Self { + self.intent = Some(value.into()); + self + } +} + +/// Name-only input: sets only `invocation_name` (API spec §6). +impl From<&str> for ToolCandidate { + fn from(name: &str) -> Self { + Self { + invocation_name: name.to_owned(), + package_url: None, + detected_version: None, + intent: None, + } + } +} + +/// A bare name is shorthand for `ToolCandidate { invocation_name }`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ToolInput { + Name(String), + Candidate(ToolCandidate), +} + +impl ToolInput { + pub(crate) fn to_candidate(&self) -> ToolCandidate { + match self { + ToolInput::Name(name) => ToolCandidate::new(name.clone()), + ToolInput::Candidate(candidate) => candidate.clone(), + } + } +} + +impl From<&str> for ToolInput { + fn from(value: &str) -> Self { + ToolInput::Name(value.to_string()) + } +} + +impl From for ToolInput { + fn from(value: String) -> Self { + ToolInput::Name(value) + } +} + +impl From for ToolInput { + fn from(value: ToolCandidate) -> Self { + ToolInput::Candidate(value) + } +} + +/// One tool or a list of tools. A single input is exactly a one-element list. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ToolInputs(pub Vec); + +impl From for ToolInputs { + fn from(value: ToolInput) -> Self { + ToolInputs(vec![value]) + } +} + +impl From<&str> for ToolInputs { + fn from(value: &str) -> Self { + ToolInputs(vec![value.into()]) + } +} + +impl From for ToolInputs { + fn from(value: String) -> Self { + ToolInputs(vec![value.into()]) + } +} + +impl From for ToolInputs { + fn from(value: ToolCandidate) -> Self { + ToolInputs(vec![value.into()]) + } +} + +impl From> for ToolInputs { + fn from(value: Vec) -> Self { + ToolInputs(value) + } +} + +impl From<&[ToolInput]> for ToolInputs { + fn from(value: &[ToolInput]) -> Self { + ToolInputs(value.to_vec()) + } +} + +impl From<&[ToolCandidate]> for ToolInputs { + fn from(value: &[ToolCandidate]) -> Self { + ToolInputs(value.iter().cloned().map(ToolInput::from).collect()) + } +} + +impl From> for ToolInputs { + fn from(value: Vec<&str>) -> Self { + ToolInputs(value.into_iter().map(ToolInput::from).collect()) + } +} + +impl From<[&str; N]> for ToolInputs { + fn from(value: [&str; N]) -> Self { + ToolInputs(value.into_iter().map(ToolInput::from).collect()) + } +} + +/// Caller-supplied symbol values, keyed by symbol name. Key order follows +/// ECMAScript property order (the order TypeScript enumerates them), and a +/// repeated name keeps its first position and takes the last value. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SymbolMap(JsonObject); + +impl SymbolMap { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, name: impl Into, value: impl Into) { + self.0.insert(name, Json::String(value.into())); + } + + /// Own-key lookup (never consults anything like a prototype chain). + pub fn get(&self, name: &str) -> Option<&str> { + self.0.get(name).and_then(Json::as_str) + } + + pub fn iter(&self) -> impl Iterator { + self.0 + .iter() + .map(|(k, v)| (k, v.as_str().unwrap_or_default())) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl, V: Into> FromIterator<(K, V)> for SymbolMap { + fn from_iter>(iter: I) -> Self { + let mut map = SymbolMap::new(); + for (k, v) in iter { + map.insert(k, v); + } + map + } +} + +/// Runtime lookup context (API spec §2, §3). `platform` and `architecture` are +/// the caller's raw strings so that an unsupported value is reported as +/// `malformed_request` (`invalid_context`) exactly like the original TypeScript prototype. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ResolveContext { + pub project_root: Option, + pub symbols: Option, + pub platform: Option, + pub architecture: Option, + pub catalog_revision: Option, + pub allow_weak_identity_fallback: bool, +} + +impl ResolveContext { + pub fn new() -> Self { + Self::default() + } + + pub fn platform(mut self, platform: Platform) -> Self { + self.platform = Some(platform.as_str().to_string()); + self + } + + pub fn architecture(mut self, architecture: Architecture) -> Self { + self.architecture = Some(architecture.as_str().to_string()); + self + } + + pub fn project_root(mut self, value: impl Into) -> Self { + self.project_root = Some(value.into()); + self + } + + pub fn symbol(mut self, name: impl Into, value: impl Into) -> Self { + self.symbols + .get_or_insert_with(SymbolMap::new) + .insert(name, value); + self + } + + pub fn catalog_revision(mut self, value: impl Into) -> Self { + self.catalog_revision = Some(value.into()); + self + } + + pub fn allow_weak(mut self, allow: bool) -> Self { + self.allow_weak_identity_fallback = allow; + self + } +} + +/// Strength of a matched identity predicate. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum IdentityStrength { + Weak, + Strong, +} + +impl IdentityStrength { + pub fn as_str(self) -> &'static str { + match self { + IdentityStrength::Strong => "strong", + IdentityStrength::Weak => "weak", + } + } +} + +/// The filesystem part of composed requirements. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FilesystemRequirements { + pub denied_paths: Option>, + pub readonly_paths: Option>, + pub readwrite_paths: Option>, +} + +pub(crate) fn strings(values: &[String]) -> Json { + Json::Array(values.iter().map(|v| Json::String(v.clone())).collect()) +} + +fn indexes(values: &[usize]) -> Json { + Json::Array(values.iter().map(|v| Json::Number(*v as f64)).collect()) +} + +impl FilesystemRequirements { + pub fn to_json(&self) -> Json { + let mut object = JsonObject::new(); + if let Some(v) = &self.denied_paths { + object.insert("deniedPaths", strings(v)); + } + if let Some(v) = &self.readonly_paths { + object.insert("readonlyPaths", strings(v)); + } + if let Some(v) = &self.readwrite_paths { + object.insert("readwritePaths", strings(v)); + } + Json::Object(object) + } +} + +/// Composed requirements in the catalog's JSON model: the four +/// `ContainerRequirements` fields (filesystem, directional network, v1 UI, +/// and `timeoutMs`). `network` and `ui` keep the reviewed JSON shape; the +/// SDK converts the whole value to its typed v1 sections. +#[derive(Clone, Debug, PartialEq)] +pub struct Requirements { + pub filesystem: Option, + pub network: Option, + pub ui: Option, + pub timeout_ms: Option, +} + +impl Requirements { + /// The `ContainerRequirements` JSON shape used by the Node and C# SDKs. + pub fn to_json(&self) -> Json { + let mut object = JsonObject::new(); + if let Some(fs) = &self.filesystem { + object.insert("filesystem", fs.to_json()); + } + if let Some(network) = &self.network { + object.insert("network", network.clone()); + } + if let Some(ui) = &self.ui { + object.insert("ui", ui.clone()); + } + if let Some(timeout) = self.timeout_ms { + object.insert("timeoutMs", Json::Number(f64::from(timeout))); + } + Json::Object(object) + } +} + +/// How a detected version selected the entry's version data (design §4.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VersionStatus { + /// No version was supplied; the unversioned default applies. + MatchedDefault, + /// The version is inside exactly one version variant's range. + MatchedVersion, + /// The version is valid but inside no range; the default applies. + VersionOutOfRange, + /// The version does not parse under the entry's scheme; the pair + /// contributes nothing. + VersionUnparseable, +} + +impl VersionStatus { + pub fn as_str(self) -> &'static str { + match self { + VersionStatus::MatchedDefault => "matched_default", + VersionStatus::MatchedVersion => "matched_version", + VersionStatus::VersionOutOfRange => "version_out_of_range", + VersionStatus::VersionUnparseable => "version_unparseable", + } + } +} + +/// Per-input resolution status: the version status of a contributing pair, +/// or why the pair contributes nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolResolutionStatus { + Version(VersionStatus), + IntentUnsupported, + ToolUnmatched, + /// Object identity needed to compose the pair's filesystem access could + /// not be established; the pair contributes nothing. + FilesystemIdentityUnresolved, +} + +impl ToolResolutionStatus { + pub fn as_str(self) -> &'static str { + match self { + ToolResolutionStatus::Version(status) => status.as_str(), + ToolResolutionStatus::IntentUnsupported => "intent_unsupported", + ToolResolutionStatus::ToolUnmatched => "tool_unmatched", + ToolResolutionStatus::FilesystemIdentityUnresolved => "filesystem_identity_unresolved", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VersionSelection { + pub status: VersionStatus, + pub detected_version: Option, + /// Present only for [`VersionStatus::MatchedVersion`]. + pub selected_version_range: Option, +} + +impl VersionSelection { + pub fn default_match() -> Self { + Self { + status: VersionStatus::MatchedDefault, + detected_version: None, + selected_version_range: None, + } + } + + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("status", self.status.as_str().into()); + if let Some(v) = &self.detected_version { + o.insert("detectedVersion", v.as_str().into()); + } + if let Some(r) = &self.selected_version_range { + o.insert("selectedVersionRange", r.as_str().into()); + } + Json::Object(o) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IntentMode { + Named, + All, + /// A dependency contributing its base only. + None, + Unsupported, +} + +impl IntentMode { + pub fn as_str(self) -> &'static str { + match self { + IntentMode::Named => "named", + IntentMode::All => "all", + IntentMode::None => "none", + IntentMode::Unsupported => "unsupported", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IntentSelection { + pub requested: Option, + pub mode: IntentMode, + /// Selected intent names, sorted; empty when unsupported. + pub selected: Vec, +} + +impl IntentSelection { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + if let Some(r) = &self.requested { + o.insert("requested", r.as_str().into()); + } + o.insert("mode", self.mode.as_str().into()); + o.insert("selected", strings(&self.selected)); + Json::Object(o) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MatchedIdentity { + pub kind: String, + pub strength: IdentityStrength, +} + +/// Metadata about the catalog entry one input selected (API spec §3). It is +/// retained when a version or intent failure stops the input contributing. +#[derive(Clone, Debug, PartialEq)] +pub struct ToolSelection { + pub entry_id: String, + pub entry_revision: f64, + pub matched_identities: Vec, + pub version_selection: VersionSelection, + /// Absent when the version could not be parsed. + pub intent_selection: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ToolRecord { + pub input_index: usize, + /// Whether this input's complete resolved requirements, including its + /// selected dependencies, are in the returned requirements. Not unique + /// access, authorization, or an execution guarantee. + pub contributes: bool, + pub status: ToolResolutionStatus, + /// The selected entry; `None` when no entry was selected. + pub selection: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DependencyRecord { + pub entry_id: String, + pub entry_revision: f64, + /// Sorted, distinct indexes of every requester, including transitive + /// ones. Not part of the record's identity. + pub input_indexes: Vec, + pub required_version_range: Option, + pub version_selection: VersionSelection, + pub intent_selection: IntentSelection, +} + +impl DependencyRecord { + /// Identity for de-duplication: everything except the requester indexes. + pub(crate) fn same_record(&self, other: &DependencyRecord) -> bool { + self.entry_id == other.entry_id + && self.entry_revision == other.entry_revision + && self.required_version_range == other.required_version_range + && self.version_selection == other.version_selection + && self.intent_selection == other.intent_selection + } +} + +/// Components of a candidate package URL ignored for matching. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PurlComponent { + Version, + Qualifiers, + Subpath, +} + +impl PurlComponent { + pub fn as_str(self) -> &'static str { + match self { + PurlComponent::Version => "version", + PurlComponent::Qualifiers => "qualifiers", + PurlComponent::Subpath => "subpath", + } + } +} + +/// The category-specific fields of a per-input warning. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ToolWarningKind { + VersionOutOfRange { + entry_id: String, + detected_version: String, + }, + VersionUnparseable { + entry_id: String, + detected_version: String, + }, + IntentUnsupported { + entry_id: String, + intent: String, + }, + ToolUnmatched { + invocation_name: String, + }, + PurlInvalid { + package_url: String, + }, + PurlComponentsIgnored { + package_url: String, + ignored_components: Vec, + }, + WeakIdentity { + entry_id: String, + invocation_name: String, + }, +} + +impl ToolWarningKind { + pub fn code(&self) -> &'static str { + match self { + ToolWarningKind::VersionOutOfRange { .. } => "version_out_of_range", + ToolWarningKind::VersionUnparseable { .. } => "version_unparseable", + ToolWarningKind::IntentUnsupported { .. } => "intent_unsupported", + ToolWarningKind::ToolUnmatched { .. } => "tool_unmatched", + ToolWarningKind::PurlInvalid { .. } => "purl_invalid", + ToolWarningKind::PurlComponentsIgnored { .. } => "purl_components_ignored", + ToolWarningKind::WeakIdentity { .. } => "weak_identity", + } + } +} + +/// A structured per-input warning (API spec §2 `ToolResolutionWarning`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolResolutionWarning { + pub input_index: usize, + pub kind: ToolWarningKind, + pub message: String, +} + +/// Filesystem access class of a path requirement. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PathAccess { + Denied, + Readonly, + Readwrite, +} + +impl PathAccess { + pub fn as_str(self) -> &'static str { + match self { + PathAccess::Denied => "denied", + PathAccess::Readonly => "readonly", + PathAccess::Readwrite => "readwrite", + } + } +} + +/// A resolved path requirement and the entries contributing it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PathRequirement { + pub path: String, + pub access: PathAccess, + pub entry_ids: Vec, +} + +impl PathRequirement { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("path", self.path.as_str().into()); + o.insert("access", self.access.as_str().into()); + o.insert("entryIds", strings(&self.entry_ids)); + Json::Object(o) + } +} + +/// A complete egress rule and the entries contributing it. +#[derive(Clone, Debug, PartialEq)] +pub struct NetworkRequirement { + pub rule: Json, + pub entry_ids: Vec, +} + +impl NetworkRequirement { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("rule", self.rule.clone()); + o.insert("entryIds", strings(&self.entry_ids)); + Json::Object(o) + } +} + +/// Which data an architecture-neutral fallback selected. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArchitectureFallback { + /// The platform's architecture-neutral overlay. + Platform, + /// No platform overlay: the common default. + Default, +} + +impl ArchitectureFallback { + pub fn as_str(self) -> &'static str { + match self { + ArchitectureFallback::Platform => "platform", + ArchitectureFallback::Default => "default", + } + } +} + +/// Where a symbol value came from (design §4.2 precedence). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SymbolValueSource { + Caller, + Discovery, + Host, + Default, +} + +impl SymbolValueSource { + pub fn as_str(self) -> &'static str { + match self { + SymbolValueSource::Caller => "caller", + SymbolValueSource::Discovery => "discovery", + SymbolValueSource::Host => "host", + SymbolValueSource::Default => "default", + } + } +} + +/// The category-specific fields of a resolution-detail warning. +#[derive(Clone, Debug, PartialEq)] +pub enum DetailWarningKind { + ArchitectureDefault { + platform: Platform, + architecture: Architecture, + }, + ArchitectureFallback { + platform: Platform, + architecture: Architecture, + selected: ArchitectureFallback, + }, + SymbolResolved { + symbol: String, + value: String, + source: SymbolValueSource, + }, + SymbolUnresolved { + symbol: String, + }, + FilesystemCaseAssumed { + paths: Vec, + }, + FilesystemIdentityUnresolved { + paths: Vec, + platform: Platform, + }, + ReadonlySuperseded { + removed: PathRequirement, + required_by: Vec, + }, + FilesystemDenyRemoved { + removed: PathRequirement, + required_by: Vec, + }, + NetworkDenyRemoved { + removed: NetworkRequirement, + required_by: Vec, + }, +} + +impl DetailWarningKind { + pub fn code(&self) -> &'static str { + match self { + DetailWarningKind::ArchitectureDefault { .. } => "architecture_default", + DetailWarningKind::ArchitectureFallback { .. } => "architecture_fallback", + DetailWarningKind::SymbolResolved { .. } => "symbol_resolved", + DetailWarningKind::SymbolUnresolved { .. } => "symbol_unresolved", + DetailWarningKind::FilesystemCaseAssumed { .. } => "filesystem_case_assumed", + DetailWarningKind::FilesystemIdentityUnresolved { .. } => { + "filesystem_identity_unresolved" + } + DetailWarningKind::ReadonlySuperseded { .. } => "readonly_superseded", + DetailWarningKind::FilesystemDenyRemoved { .. } => "filesystem_deny_removed", + DetailWarningKind::NetworkDenyRemoved { .. } => "network_deny_removed", + } + } +} + +/// A structured warning about shared resolution (API spec §2 +/// `ResolutionDetailWarning`), scoped to sorted, distinct inputs and entries. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolutionDetailWarning { + pub input_indexes: Vec, + pub entry_ids: Vec, + pub kind: DetailWarningKind, + pub message: String, +} + +/// A structured diagnostics warning. +#[derive(Clone, Debug, PartialEq)] +pub enum Warning { + Tool(ToolResolutionWarning), + Detail(ResolutionDetailWarning), +} + +impl Warning { + pub fn code(&self) -> &'static str { + match self { + Warning::Tool(w) => w.kind.code(), + Warning::Detail(w) => w.kind.code(), + } + } + + /// The human-readable message (not a parsing contract). + pub fn message(&self) -> &str { + match self { + Warning::Tool(w) => &w.message, + Warning::Detail(w) => &w.message, + } + } + + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("code", self.code().into()); + match self { + Warning::Tool(w) => { + o.insert("inputIndex", Json::Number(w.input_index as f64)); + match &w.kind { + ToolWarningKind::VersionOutOfRange { + entry_id, + detected_version, + } + | ToolWarningKind::VersionUnparseable { + entry_id, + detected_version, + } => { + o.insert("entryId", entry_id.as_str().into()); + o.insert("detectedVersion", detected_version.as_str().into()); + } + ToolWarningKind::IntentUnsupported { entry_id, intent } => { + o.insert("entryId", entry_id.as_str().into()); + o.insert("intent", intent.as_str().into()); + } + ToolWarningKind::ToolUnmatched { invocation_name } => { + o.insert("invocationName", invocation_name.as_str().into()); + } + ToolWarningKind::PurlInvalid { package_url } => { + o.insert("packageUrl", package_url.as_str().into()); + } + ToolWarningKind::PurlComponentsIgnored { + package_url, + ignored_components, + } => { + o.insert("packageUrl", package_url.as_str().into()); + o.insert( + "ignoredComponents", + Json::Array( + ignored_components + .iter() + .map(|c| c.as_str().into()) + .collect(), + ), + ); + } + ToolWarningKind::WeakIdentity { + entry_id, + invocation_name, + } => { + o.insert("entryId", entry_id.as_str().into()); + o.insert("invocationName", invocation_name.as_str().into()); + } + } + o.insert("message", w.message.as_str().into()); + } + Warning::Detail(w) => { + o.insert("inputIndexes", indexes(&w.input_indexes)); + o.insert("entryIds", strings(&w.entry_ids)); + match &w.kind { + DetailWarningKind::ArchitectureDefault { + platform, + architecture, + } => { + o.insert("platform", platform.as_str().into()); + o.insert("architecture", architecture.as_str().into()); + } + DetailWarningKind::ArchitectureFallback { + platform, + architecture, + selected, + } => { + o.insert("platform", platform.as_str().into()); + o.insert("architecture", architecture.as_str().into()); + o.insert("selected", selected.as_str().into()); + } + DetailWarningKind::SymbolResolved { + symbol, + value, + source, + } => { + o.insert("symbol", symbol.as_str().into()); + o.insert("value", value.as_str().into()); + o.insert("source", source.as_str().into()); + } + DetailWarningKind::SymbolUnresolved { symbol } => { + o.insert("symbol", symbol.as_str().into()); + } + DetailWarningKind::FilesystemCaseAssumed { paths } => { + o.insert("paths", strings(paths)); + o.insert("comparison", "case_sensitive".into()); + } + DetailWarningKind::FilesystemIdentityUnresolved { paths, platform } => { + o.insert("paths", strings(paths)); + o.insert("platform", platform.as_str().into()); + } + DetailWarningKind::ReadonlySuperseded { + removed, + required_by, + } + | DetailWarningKind::FilesystemDenyRemoved { + removed, + required_by, + } => { + o.insert("removed", removed.to_json()); + o.insert( + "requiredBy", + Json::Array(required_by.iter().map(PathRequirement::to_json).collect()), + ); + } + DetailWarningKind::NetworkDenyRemoved { + removed, + required_by, + } => { + o.insert("removed", removed.to_json()); + o.insert( + "requiredBy", + Json::Array( + required_by + .iter() + .map(NetworkRequirement::to_json) + .collect(), + ), + ); + } + } + o.insert("message", w.message.as_str().into()); + } + } + Json::Object(o) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Diagnostics { + pub catalog_revision: String, + pub tools: Vec, + pub resolved_dependencies: Vec, + pub warnings: Vec, +} + +impl Diagnostics { + pub fn to_json(&self) -> Json { + let mut object = JsonObject::new(); + object.insert( + "catalogRevision", + Json::String(self.catalog_revision.clone()), + ); + let tools = self + .tools + .iter() + .map(|tool| { + let mut record = JsonObject::new(); + record.insert("inputIndex", Json::Number(tool.input_index as f64)); + record.insert("contributes", Json::Bool(tool.contributes)); + record.insert("status", tool.status.as_str().into()); + if let Some(m) = &tool.selection { + let mut o = JsonObject::new(); + o.insert("entryId", Json::String(m.entry_id.clone())); + o.insert("entryRevision", Json::Number(m.entry_revision)); + let identities = m + .matched_identities + .iter() + .map(|i| { + let mut io = JsonObject::new(); + io.insert("kind", Json::String(i.kind.clone())); + io.insert("strength", Json::String(i.strength.as_str().into())); + Json::Object(io) + }) + .collect(); + o.insert("matchedIdentities", Json::Array(identities)); + o.insert("versionSelection", m.version_selection.to_json()); + if let Some(intent) = &m.intent_selection { + o.insert("intentSelection", intent.to_json()); + } + record.insert("selection", Json::Object(o)); + } + Json::Object(record) + }) + .collect(); + object.insert("tools", Json::Array(tools)); + let dependencies = self + .resolved_dependencies + .iter() + .map(|d| { + let mut o = JsonObject::new(); + o.insert("entryId", Json::String(d.entry_id.clone())); + o.insert("entryRevision", Json::Number(d.entry_revision)); + o.insert("inputIndexes", indexes(&d.input_indexes)); + if let Some(range) = &d.required_version_range { + o.insert("requiredVersionRange", Json::String(range.clone())); + } + o.insert("versionSelection", d.version_selection.to_json()); + o.insert("intentSelection", d.intent_selection.to_json()); + Json::Object(o) + }) + .collect(); + object.insert("resolvedDependencies", Json::Array(dependencies)); + object.insert( + "warnings", + Json::Array(self.warnings.iter().map(Warning::to_json).collect()), + ); + Json::Object(object) + } +} + +/// Result of a diagnostics lookup (API spec §2 `ToolRequirementsResolution`) +/// in the catalog's JSON model. +#[derive(Clone, Debug, PartialEq)] +pub struct RequirementsResolution { + pub requirements: Option, + pub diagnostics: Diagnostics, +} + +impl RequirementsResolution { + /// `{"requirements"?, "diagnostics"}`; `requirements` is omitted when absent. + pub fn to_json(&self) -> Json { + let mut object = JsonObject::new(); + if let Some(requirements) = &self.requirements { + object.insert("requirements", requirements.to_json()); + } + object.insert("diagnostics", self.diagnostics.to_json()); + Json::Object(object) + } +} + +/// Inspection metadata for one identity predicate (API spec §2, inspection metadata). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CatalogIdentityMetadata { + Purl { value: String }, + InvocationName { names: Vec }, +} + +impl CatalogIdentityMetadata { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + match self { + CatalogIdentityMetadata::Purl { value } => { + o.insert("kind", "purl".into()); + o.insert("value", value.as_str().into()); + } + CatalogIdentityMetadata::InvocationName { names } => { + o.insert("kind", "invocation-name".into()); + o.insert("names", strings(names)); + } + } + Json::Object(o) + } +} + +/// Inspection metadata for one intent definition or extension. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogIntentMetadata { + pub name: String, + pub example_subcommands: Option>, + pub dependency_entry_ids: Vec, +} + +impl CatalogIntentMetadata { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("name", self.name.as_str().into()); + if let Some(examples) = &self.example_subcommands { + o.insert("exampleSubcommands", strings(examples)); + } + o.insert("dependencyEntryIds", strings(&self.dependency_entry_ids)); + Json::Object(o) + } +} + +fn intents_json(intents: &[CatalogIntentMetadata]) -> Json { + Json::Array(intents.iter().map(CatalogIntentMetadata::to_json).collect()) +} + +/// Inspection metadata for the additions an overlay makes. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct CatalogAdditionsMetadata { + pub dependency_entry_ids: Vec, + pub intent_additions: Vec, + pub new_intents: Vec, +} + +impl CatalogAdditionsMetadata { + fn write(&self, o: &mut JsonObject) { + o.insert("dependencyEntryIds", strings(&self.dependency_entry_ids)); + o.insert("intentAdditions", intents_json(&self.intent_additions)); + o.insert("newIntents", intents_json(&self.new_intents)); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DefaultMetadata { + pub dependency_entry_ids: Vec, + pub intents: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlatformVariantMetadata { + pub platform: Platform, + pub architecture: Option, + pub additions: CatalogAdditionsMetadata, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VersionVariantMetadata { + pub version_range: String, + pub additions: CatalogAdditionsMetadata, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Provenance { + pub method: String, + pub source_revision: String, +} + +impl Provenance { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("method", self.method.as_str().into()); + o.insert("sourceRevision", self.source_revision.as_str().into()); + Json::Object(o) + } +} + +/// Inspection metadata for one entry. It never exposes a policy body. +#[derive(Clone, Debug, PartialEq)] +pub struct CatalogEntryMetadata { + pub catalog_revision: String, + pub entry_id: String, + pub entry_revision: f64, + pub display_name: String, + pub version_scheme: VersionScheme, + pub identity: Vec, + pub default: DefaultMetadata, + pub platform_variants: Vec, + pub version_variants: Vec, + pub provenance: Provenance, +} + +impl CatalogEntryMetadata { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("catalogRevision", self.catalog_revision.as_str().into()); + o.insert("entryId", self.entry_id.as_str().into()); + o.insert("entryRevision", Json::Number(self.entry_revision)); + o.insert("displayName", self.display_name.as_str().into()); + o.insert("versionScheme", self.version_scheme.as_str().into()); + o.insert( + "identity", + Json::Array(self.identity.iter().map(|i| i.to_json()).collect()), + ); + let mut default = JsonObject::new(); + default.insert( + "dependencyEntryIds", + strings(&self.default.dependency_entry_ids), + ); + default.insert("intents", intents_json(&self.default.intents)); + o.insert("default", Json::Object(default)); + let platform_variants = self + .platform_variants + .iter() + .map(|v| { + let mut vo = JsonObject::new(); + vo.insert("platform", v.platform.as_str().into()); + if let Some(arch) = v.architecture { + vo.insert("architecture", arch.as_str().into()); + } + v.additions.write(&mut vo); + Json::Object(vo) + }) + .collect(); + o.insert("platformVariants", Json::Array(platform_variants)); + let version_variants = self + .version_variants + .iter() + .map(|v| { + let mut vo = JsonObject::new(); + vo.insert("versionRange", v.version_range.as_str().into()); + v.additions.write(&mut vo); + Json::Object(vo) + }) + .collect(); + o.insert("versionVariants", Json::Array(version_variants)); + o.insert("provenance", self.provenance.to_json()); + Json::Object(o) + } +} + +/// Result of `get_catalog_info` (API spec §2, inspection metadata). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogInfo { + pub catalog_schema_version: String, + pub catalog_revision: String, + pub sdk_contract_version: String, +} + +impl CatalogInfo { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert( + "catalogSchemaVersion", + self.catalog_schema_version.as_str().into(), + ); + o.insert("catalogRevision", self.catalog_revision.as_str().into()); + o.insert( + "sdkContractVersion", + self.sdk_contract_version.as_str().into(), + ); + Json::Object(o) + } +} diff --git a/src/mxc-sdk/src/policy_store/netrule.rs b/src/mxc-sdk/src/policy_store/netrule.rs new file mode 100644 index 000000000..92c792931 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/netrule.rs @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Outbound network rule geometry for floor composition (design §4.5): CIDR +//! parsing and the overlap test that decides whether a catalog egress deny +//! conflicts with a required allow rule. + +use crate::policy_store::json::Json; +use std::net::IpAddr; + +/// One parsed CIDR: the address as a 128-bit value, its family, and prefix. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cidr { + v6: bool, + bits: u128, + prefix: u8, +} + +impl Cidr { + fn width(self) -> u8 { + if self.v6 { + 128 + } else { + 32 + } + } + + fn mask(self) -> u128 { + if self.prefix == 0 { + 0 + } else { + let shift = u32::from(self.width() - self.prefix); + (u128::MAX >> (128 - u32::from(self.width()))) & !((1u128 << shift) - 1) + } + } + + /// `other` equals this block or lies inside it. + pub fn contains(self, other: Cidr) -> bool { + self.v6 == other.v6 + && other.prefix >= self.prefix + && (other.bits & self.mask()) == (self.bits & self.mask()) + } + + /// The smaller block when the two intersect (CIDR blocks either nest or + /// are disjoint). + fn intersection(self, other: Cidr) -> Option { + if self.contains(other) { + Some(other) + } else if other.contains(self) { + Some(self) + } else { + None + } + } + + fn halves(self) -> Option<[Cidr; 2]> { + if self.prefix == self.width() { + return None; + } + let prefix = self.prefix + 1; + let low = self.bits & self.mask(); + let high = low | (1u128 << u32::from(self.width() - prefix)); + Some([ + Cidr { + v6: self.v6, + bits: low, + prefix, + }, + Cidr { + v6: self.v6, + bits: high, + prefix, + }, + ]) + } +} + +/// Parses `address/prefix` (IPv4 or IPv6). +pub fn parse_cidr(value: &str) -> Option { + let (address, prefix) = value.split_once('/')?; + if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) || prefix.len() > 3 { + return None; + } + let prefix: u8 = prefix.parse().ok()?; + let (v6, bits) = match address.parse::().ok()? { + IpAddr::V4(v4) => (false, u128::from(u32::from(v4))), + IpAddr::V6(v6) => (true, u128::from(v6)), + }; + let cidr = Cidr { v6, bits, prefix }; + (prefix <= cidr.width()).then_some(cidr) +} + +/// Whether `block` is entirely covered by the union of `excluded`. +fn covered(block: Cidr, excluded: &[Cidr]) -> bool { + if excluded.iter().any(|e| e.contains(block)) { + return true; + } + if !excluded.iter().any(|e| block.contains(*e)) { + return false; + } + match block.halves() { + Some([low, high]) => covered(low, excluded) && covered(high, excluded), + None => false, + } +} + +/// One `to[]` peer: a block minus its exclusions. +#[derive(Clone, Debug)] +struct Peer { + cidr: Cidr, + except: Vec, +} + +/// `None` for an omitted `to` (every destination in both families). +fn peers(rule: &Json) -> Option> { + let to = rule.get("to")?.as_array()?; + Some( + to.iter() + .filter_map(|peer| { + let cidr = parse_cidr(peer.get("cidr")?.as_str()?)?; + let except = peer + .get("except") + .and_then(Json::as_array) + .into_iter() + .flatten() + .filter_map(|e| e.as_str().and_then(parse_cidr)) + .collect(); + Some(Peer { cidr, except }) + }) + .collect(), + ) +} + +fn everything(v6: bool) -> Peer { + Peer { + cidr: Cidr { + v6, + bits: 0, + prefix: 0, + }, + except: Vec::new(), + } +} + +fn destinations_intersect(allow: &Json, deny: &Json) -> bool { + let all = || vec![everything(false), everything(true)]; + let allow = peers(allow).unwrap_or_else(all); + let deny = peers(deny).unwrap_or_else(all); + allow.iter().any(|a| { + deny.iter().any(|d| match a.cidr.intersection(d.cidr) { + None => false, + Some(block) => { + let mut excluded = a.except.clone(); + excluded.extend(&d.except); + !covered(block, &excluded) + } + }) + }) +} + +/// A protocol and an inclusive port range (`None` for ICMP, which has none). +#[derive(Clone, Copy, Debug)] +struct Selector<'a> { + protocol: &'a str, + ports: Option<(u32, u32)>, +} + +fn selectors(rule: &Json) -> Vec> { + let Some(ports) = rule.get("ports").and_then(Json::as_array) else { + return vec![Selector { + protocol: "any", + ports: None, + }]; + }; + ports + .iter() + .map(|port| { + let protocol = port.get("protocol").and_then(Json::as_str).unwrap_or("any"); + let start = port.get("port").and_then(Json::as_f64).map(|n| n as u32); + let end = port.get("endPort").and_then(Json::as_f64).map(|n| n as u32); + Selector { + protocol, + ports: start.map(|s| (s, end.unwrap_or(s))), + } + }) + .collect() +} + +/// Whether two protocol/port selectors can match the same traffic. A +/// selector without ports matches every port, and ICMP (which has no ports) +/// as well when its protocol is `any`; a selector with ports never matches +/// ICMP. +fn selectors_intersect(left: Selector<'_>, right: Selector<'_>) -> bool { + let protocol = match (left.protocol, right.protocol) { + ("any", other) | (other, "any") => other, + (l, r) if l == r => l, + _ => return false, + }; + match (left.ports, right.ports) { + (Some((ls, le)), Some((rs, re))) => protocol != "icmp" && ls <= re && rs <= le, + (Some(_), None) | (None, Some(_)) => protocol != "icmp", + (None, None) => true, + } +} + +/// Whether a deny rule overlaps an allow rule: their destinations intersect +/// after `except` exclusions and their protocol/port selectors intersect. +pub fn rules_overlap(allow: &Json, deny: &Json) -> bool { + destinations_intersect(allow, deny) + && selectors(allow) + .iter() + .any(|a| selectors(deny).iter().any(|d| selectors_intersect(*a, *d))) +} + +/// The full destination and port scope of a rule, for diagnostics. +pub fn describe_rule(rule: &Json) -> String { + let to = match rule.get("to").and_then(Json::as_array) { + None => "any destination".to_string(), + Some(peers) => peers + .iter() + .map(|peer| { + let cidr = peer.get("cidr").and_then(Json::as_str).unwrap_or("?"); + let except: Vec<&str> = peer + .get("except") + .and_then(Json::as_array) + .into_iter() + .flatten() + .filter_map(Json::as_str) + .collect(); + if except.is_empty() { + cidr.to_string() + } else { + format!("{cidr} except {}", except.join(", ")) + } + }) + .collect::>() + .join(", "), + }; + let ports = match rule.get("ports").and_then(Json::as_array) { + None => "any protocol and port".to_string(), + Some(_) => selectors(rule) + .iter() + .map(|s| match s.ports { + None => format!("{}/any port", s.protocol), + Some((start, end)) if start == end => format!("{}/{start}", s.protocol), + Some((start, end)) => format!("{}/{start}-{end}", s.protocol), + }) + .collect::>() + .join(", "), + }; + format!("[{to}; {ports}]") +} + +#[cfg(test)] +mod tests { + use super::*; + fn rule(text: &str) -> Json { + Json::parse(text).expect("valid JSON") + } + + #[test] + fn parses_cidrs() { + assert!(parse_cidr("192.0.2.0/24").is_some()); + assert!(parse_cidr("2001:db8::/32").is_some()); + assert!(parse_cidr("192.0.2.0/33").is_none()); + assert!(parse_cidr("192.0.2.0").is_none()); + assert!(parse_cidr("192.0.2.0/+1").is_none()); + assert!(parse_cidr("host.example/24").is_none()); + let outer = parse_cidr("192.0.2.0/24").unwrap(); + assert!(outer.contains(parse_cidr("192.0.2.10/32").unwrap())); + assert!(!outer.contains(parse_cidr("198.51.100.0/32").unwrap())); + assert!(!outer.contains(parse_cidr("2001:db8::/128").unwrap())); + } + + #[test] + fn overlap_needs_destination_and_port_intersection() { + let allow = + rule(r#"{"to":[{"cidr":"192.0.2.10/32"}],"ports":[{"protocol":"tcp","port":443}]}"#); + let cases = [ + (r#"{"to":[{"cidr":"192.0.2.0/24"}]}"#, true), + ( + r#"{"to":[{"cidr":"192.0.2.0/24"}],"ports":[{"protocol":"tcp","port":400,"endPort":500}]}"#, + true, + ), + ( + r#"{"to":[{"cidr":"192.0.2.0/24"}],"ports":[{"protocol":"udp","port":443}]}"#, + false, + ), + ( + r#"{"to":[{"cidr":"192.0.2.0/24"}],"ports":[{"protocol":"tcp","port":22}]}"#, + false, + ), + (r#"{"to":[{"cidr":"198.51.100.0/24"}]}"#, false), + ( + r#"{"to":[{"cidr":"192.0.2.0/24","except":["192.0.2.0/28"]}]}"#, + false, + ), + ( + r#"{"to":[{"cidr":"192.0.2.0/24","except":["192.0.2.0/29"]}]}"#, + true, + ), + (r#"{"ports":[{"protocol":"any"}]}"#, true), + (r#"{"to":[{"cidr":"2001:db8::/32"}]}"#, false), + (r#"{"ports":[{"protocol":"icmp"}]}"#, false), + ]; + for (deny, expected) in cases { + assert_eq!(rules_overlap(&allow, &rule(deny)), expected, "{deny}"); + } + } + + #[test] + fn allow_exclusions_count() { + let allow = rule(r#"{"to":[{"cidr":"192.0.2.0/24","except":["192.0.2.128/25"]}]}"#); + assert!(!rules_overlap( + &allow, + &rule(r#"{"to":[{"cidr":"192.0.2.128/26"}]}"#) + )); + assert!(rules_overlap( + &allow, + &rule(r#"{"to":[{"cidr":"192.0.2.64/26"}]}"#) + )); + } + + #[test] + fn describes_full_scope() { + assert_eq!( + describe_rule(&rule( + r#"{"to":[{"cidr":"192.0.2.0/24","except":["192.0.2.1/32"]}],"ports":[{"protocol":"tcp","port":80,"endPort":90}]}"# + )), + "[192.0.2.0/24 except 192.0.2.1/32; tcp/80-90]" + ); + assert_eq!( + describe_rule(&rule("{}")), + "[any destination; any protocol and port]" + ); + } +} diff --git a/src/mxc-sdk/src/policy_store/paths.rs b/src/mxc-sdk/src/policy_store/paths.rs new file mode 100644 index 000000000..1e2bf27ba --- /dev/null +++ b/src/mxc-sdk/src/policy_store/paths.rs @@ -0,0 +1,563 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Node.js `path.win32` / `path.posix` `normalize`, `isAbsolute`, and +//! `parse().root`, ported from Node v24.20.0 `lib/path.js`, plus the catalog +//! helpers built on them (ported from the TypeScript prototype's `normalizePath`, `isAbsolutePath`, +//! `pathKeySegments`). +//! +//! The algorithms run over UTF-16 code units, exactly like JavaScript, so +//! index arithmetic and slicing match Node for every input. + +use crate::policy_store::model::Platform; +use crate::policy_store::text::js_to_lower; + +const SLASH: u16 = b'/' as u16; +const BACKSLASH: u16 = b'\\' as u16; +const DOT: u16 = b'.' as u16; +const COLON: u16 = b':' as u16; +const QUESTION: u16 = b'?' as u16; + +fn is_sep(code: u16) -> bool { + code == SLASH || code == BACKSLASH +} + +fn is_posix_sep(code: u16) -> bool { + code == SLASH +} + +fn is_device_root(code: u16) -> bool { + (u16::from(b'A')..=u16::from(b'Z')).contains(&code) + || (u16::from(b'a')..=u16::from(b'z')).contains(&code) +} + +fn u(value: &str) -> Vec { + value.encode_utf16().collect() +} + +fn s(value: &[u16]) -> String { + String::from_utf16_lossy(value) +} + +fn index_of(haystack: &[u16], needle: u16, from: usize) -> Option { + haystack + .iter() + .skip(from) + .position(|c| *c == needle) + .map(|i| i + from) +} + +/// JavaScript `String.prototype.slice(start, end)` with negative-index rules. +fn js_slice(value: &[u16], start: isize, end: isize) -> &[u16] { + let len = value.len() as isize; + let clamp = |i: isize| if i < 0 { (len + i).max(0) } else { i.min(len) }; + let (start, end) = (clamp(start), clamp(end)); + if start >= end { + &[] + } else { + &value[start as usize..end as usize] + } +} + +const WINDOWS_RESERVED_NAMES: [&str; 28] = [ + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", + "COM\u{b9}", + "COM\u{b2}", + "COM\u{b3}", + "LPT\u{b9}", + "LPT\u{b2}", + "LPT\u{b3}", +]; + +fn is_windows_reserved_name(path: &[u16], colon_index: isize) -> bool { + let part = js_slice(path, 0, colon_index); + // A slice that splits a surrogate pair cannot equal a reserved name. + let Ok(part) = String::from_utf16(part) else { + return false; + }; + let upper = part.to_uppercase(); + WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) +} + +/// Node `normalizeString`: resolves `.` and `..` segments. +fn normalize_string( + path: &[u16], + allow_above_root: bool, + separator: u16, + is_separator: fn(u16) -> bool, +) -> Vec { + let mut res: Vec = Vec::new(); + let mut last_segment_length: isize = 0; + let mut last_slash: isize = -1; + let mut dots: i32 = 0; + let mut code: u16 = 0; + let len = path.len(); + let mut i = 0usize; + while i <= len { + if i < len { + code = path[i]; + } else if is_separator(code) { + break; + } else { + code = SLASH; + } + let ii = i as isize; + if is_separator(code) { + if last_slash == ii - 1 || dots == 1 { + // NOOP + } else if dots == 2 { + let rl = res.len(); + if rl < 2 || last_segment_length != 2 || res[rl - 1] != DOT || res[rl - 2] != DOT { + if rl > 2 { + let last_slash_index = rl as isize - last_segment_length - 1; + if last_slash_index == -1 { + res.clear(); + last_segment_length = 0; + } else { + res.truncate(last_slash_index as usize); + let last_sep = res + .iter() + .rposition(|c| *c == separator) + .map_or(-1, |p| p as isize); + last_segment_length = res.len() as isize - 1 - last_sep; + } + last_slash = ii; + dots = 0; + i += 1; + continue; + } else if rl != 0 { + res.clear(); + last_segment_length = 0; + last_slash = ii; + dots = 0; + i += 1; + continue; + } + } + if allow_above_root { + if !res.is_empty() { + res.push(separator); + } + res.extend_from_slice(&[DOT, DOT]); + last_segment_length = 2; + } + } else { + let segment = &path[(last_slash + 1) as usize..i]; + if !res.is_empty() { + res.push(separator); + } + res.extend_from_slice(segment); + last_segment_length = ii - last_slash - 1; + } + last_slash = ii; + dots = 0; + } else if code == DOT && dots != -1 { + dots += 1; + } else { + dots = -1; + } + i += 1; + } + res +} + +fn cat(parts: &[&[u16]]) -> Vec { + parts.concat() +} + +/// Node `path.win32.normalize`. +pub fn win32_normalize(input: &str) -> String { + s(&win32_normalize_units(&u(input))) +} + +fn win32_normalize_units(path: &[u16]) -> Vec { + let len = path.len(); + if len == 0 { + return vec![DOT]; + } + let mut root_end = 0usize; + let mut device: Option> = None; + let mut is_absolute = false; + let code = path[0]; + let bs = [BACKSLASH]; + if len == 1 { + return if is_posix_sep(code) { + vec![BACKSLASH] + } else { + path.to_vec() + }; + } + if is_sep(code) { + is_absolute = true; + if is_sep(path[1]) { + let mut j = 2; + let mut last = j; + while j < len && !is_sep(path[j]) { + j += 1; + } + if j < len && j != last { + let first_part = &path[last..j]; + last = j; + while j < len && is_sep(path[j]) { + j += 1; + } + if j < len && j != last { + last = j; + while j < len && !is_sep(path[j]) { + j += 1; + } + if j == len || j != last { + if first_part == [DOT] || first_part == [QUESTION] { + device = Some(cat(&[&[BACKSLASH, BACKSLASH], first_part])); + root_end = 4; + let colon_index = index_of(path, COLON, 0).map_or(-1, |i| i as isize); + let possible = js_slice(path, 4, colon_index + 1).to_vec(); + if is_windows_reserved_name(&possible, possible.len() as isize - 1) { + device = Some(cat(&[ + &[BACKSLASH, BACKSLASH, QUESTION, BACKSLASH], + &possible, + ])); + root_end = 4 + possible.len(); + } + } else if j == len { + return cat(&[ + &[BACKSLASH, BACKSLASH], + first_part, + &bs, + &path[last..], + &bs, + ]); + } else { + device = Some(cat(&[ + &[BACKSLASH, BACKSLASH], + first_part, + &bs, + &path[last..j], + ])); + root_end = j; + } + } + } + } + } else { + root_end = 1; + } + } else if let Some(colon_index) = index_of(path, COLON, 0).filter(|i| *i > 0) { + if is_device_root(code) && colon_index == 1 { + device = Some(path[0..2].to_vec()); + root_end = 2; + if len > 2 && is_sep(path[2]) { + is_absolute = true; + root_end = 3; + } + } else if is_windows_reserved_name(path, colon_index as isize) { + device = Some(path[..colon_index + 1].to_vec()); + root_end = colon_index + 1; + } + } + + let mut tail = if root_end < len { + normalize_string(&path[root_end..], !is_absolute, BACKSLASH, is_sep) + } else { + Vec::new() + }; + if tail.is_empty() && !is_absolute { + tail = vec![DOT]; + } + if !tail.is_empty() && is_sep(path[len - 1]) { + tail.push(BACKSLASH); + } + let dot_bs = [DOT, BACKSLASH]; + if !is_absolute && device.is_none() && path.contains(&COLON) { + if tail.len() >= 2 && is_device_root(tail[0]) && tail[1] == COLON { + return cat(&[&dot_bs, &tail]); + } + let mut index = index_of(path, COLON, 0); + while let Some(at) = index { + if at == len - 1 || is_sep(path[at + 1]) { + return cat(&[&dot_bs, &tail]); + } + index = index_of(path, COLON, at + 1); + } + } + let colon_index = index_of(path, COLON, 0).map_or(-1, |i| i as isize); + if is_windows_reserved_name(path, colon_index) { + return cat(&[&dot_bs, device.as_deref().unwrap_or(&[]), &tail]); + } + match device { + None => { + if is_absolute { + cat(&[&bs, &tail]) + } else { + tail + } + } + Some(device) => { + if is_absolute { + cat(&[&device, &bs, &tail]) + } else { + cat(&[&device, &tail]) + } + } + } +} + +/// Node `path.win32.isAbsolute`. +pub fn win32_is_absolute(input: &str) -> bool { + let path = u(input); + let len = path.len(); + if len == 0 { + return false; + } + let code = path[0]; + is_sep(code) || (len > 2 && is_device_root(code) && path[1] == COLON && is_sep(path[2])) +} + +/// Node `path.win32.parse(path).root`. +fn win32_root(path: &[u16]) -> usize { + let len = path.len(); + if len == 0 { + return 0; + } + let code = path[0]; + if len == 1 { + return if is_sep(code) { 1 } else { 0 }; + } + let mut root_end = 0; + if is_sep(code) { + root_end = 1; + if is_sep(path[1]) { + let mut j = 2; + let mut last = j; + while j < len && !is_sep(path[j]) { + j += 1; + } + if j < len && j != last { + last = j; + while j < len && is_sep(path[j]) { + j += 1; + } + if j < len && j != last { + last = j; + while j < len && !is_sep(path[j]) { + j += 1; + } + if j == len { + root_end = j; + } else if j != last { + root_end = j + 1; + } + } + } + } + } else if is_device_root(code) && path[1] == COLON { + if len <= 2 { + return len; + } + root_end = 2; + if is_sep(path[2]) { + if len == 3 { + return len; + } + root_end = 3; + } + } + root_end +} + +/// Node `path.posix.normalize`. +pub fn posix_normalize(input: &str) -> String { + let path = u(input); + if path.is_empty() { + return ".".to_string(); + } + let is_absolute = path[0] == SLASH; + let trailing = path[path.len() - 1] == SLASH; + let mut normalized = normalize_string(&path, !is_absolute, SLASH, is_posix_sep); + if normalized.is_empty() { + if is_absolute { + return "/".to_string(); + } + return if trailing { "./" } else { "." }.to_string(); + } + if trailing { + normalized.push(SLASH); + } + if is_absolute { + format!("/{}", s(&normalized)) + } else { + s(&normalized) + } +} + +/// Node `path.posix.isAbsolute`. +pub fn posix_is_absolute(input: &str) -> bool { + input.starts_with('/') +} + +/// The one casing rule for a target platform: Windows and macOS fold case, +/// Linux does not. Used for invocation-name matching and path comparison. +pub fn folds_case(platform: Platform) -> bool { + platform != Platform::Linux +} + +/// Applies [`folds_case`] to one value. +pub fn case_key(value: &str, platform: Platform) -> String { + if folds_case(platform) { + js_to_lower(value) + } else { + value.to_string() + } +} + +/// Normalizes a path with the platform's rules, then removes trailing +/// separators unless only the root remains (the prototype's `normalizePath`). +pub fn normalize_path(value: &str, platform: Platform) -> String { + let (normalized, root_len) = if platform == Platform::Windows { + let n = win32_normalize_units(&u(value)); + let root = win32_root(&n); + (n, root) + } else { + let n = u(&posix_normalize(value)); + let root = usize::from(n.first() == Some(&SLASH)); + (n, root) + }; + if normalized.len() > root_len { + let mut end = normalized.len(); + while end > 0 && is_sep(normalized[end - 1]) { + end -= 1; + } + s(&normalized[..end]) + } else { + s(&normalized) + } +} + +/// Whether `value` is absolute under the platform's rules (Node `isAbsolute`). +pub fn is_absolute_path(value: &str, platform: Platform) -> bool { + if platform == Platform::Windows { + win32_is_absolute(value) + } else { + posix_is_absolute(value) + } +} + +/// Splits a path into comparable segments with the platform's separator and casing rules. +pub fn path_key_segments(value: &str, platform: Platform) -> Vec { + split_segments(value, platform, true) +} + +/// Splits a path into segments with the platform's separators, preserving +/// case. Used where filesystem case sensitivity is unknown (design §4.5). +pub fn path_exact_segments(value: &str, platform: Platform) -> Vec { + split_segments(value, platform, false) +} + +fn split_segments(value: &str, platform: Platform, fold: bool) -> Vec { + let is_separator: fn(char) -> bool = if platform == Platform::Windows { + |c| c == '/' || c == '\\' + } else { + |c| c == '/' + }; + // `value.split(/[\\/]+/)`: split on runs of separators. + let mut raw: Vec<&str> = Vec::new(); + let mut start = 0; + let mut in_run = false; + for (index, c) in value.char_indices() { + if is_separator(c) { + if !in_run { + raw.push(&value[start..index]); + in_run = true; + } + start = index + c.len_utf8(); + } else { + in_run = false; + } + } + raw.push(&value[start..]); + let segments: Vec<&str> = raw + .into_iter() + .enumerate() + .filter(|(index, segment)| *segment != "." && (!segment.is_empty() || *index == 0)) + .map(|(_, segment)| segment) + .collect(); + let segments = if segments.len() > 1 && segments.last() == Some(&"") { + &segments[..segments.len() - 1] + } else { + &segments[..] + }; + segments + .iter() + .map(|segment| { + if fold { + case_key(segment, platform) + } else { + segment.to_string() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn win32_normalize_matches_node() { + let cases = [ + ("", "."), + ("/", "\\"), + ("a", "a"), + ("C:", "C:."), + ("C:\\", "C:\\"), + ("C:foo\\..\\..", "C:.."), + ("\\\\server\\share", "\\\\server\\share\\"), + ("\\\\.\\PHYSICALDRIVE0\\x", "\\\\.\\PHYSICALDRIVE0\\x"), + ("\\\\?\\COM1:\\x", "\\\\?\\COM1:\\x"), + ("COM1:foo", ".\\COM1:foo"), + ("foo:bar", "foo:bar"), + ("foo:", ".\\foo:"), + ("a\\..\\c:x", ".\\c:x"), + ("./..", ".."), + ("a/b/../../..", ".."), + ("\\\\x", "\\x"), + ]; + for (input, expected) in cases { + assert_eq!(win32_normalize(input), expected, "{input:?}"); + } + } + + #[test] + fn posix_normalize_matches_node() { + for (input, expected) in [ + ("", "."), + ("./", "./"), + ("a/..", "."), + ("../a/", "../a/"), + ("//a//b/../", "/a/"), + ] { + assert_eq!(posix_normalize(input), expected, "{input:?}"); + } + } +} diff --git a/src/mxc-sdk/src/policy_store/purl.rs b/src/mxc-sdk/src/policy_store/purl.rs new file mode 100644 index 000000000..f3c298f94 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/purl.rs @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Package URL parsing for identity matching (design §4.3). The whole PURL is +//! validated using the purl-spec component rules; matching then compares only +//! type, namespace, and name. + +/// A parsed, percent-decoded package URL. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedPurl { + /// Lower-cased type. + pub package_type: String, + /// Decoded namespace segments joined with `/`, if any. + pub namespace: Option, + /// Decoded name. + pub name: String, + pub version: Option, + pub has_qualifiers: bool, + pub has_subpath: bool, + /// Catalog comparison key: type, case-folded namespace, and the name + /// normalized by its type's rules. + pub key: String, +} + +/// ECMAScript `decodeURIComponent`; `None` where it throws `URIError`. +pub fn decode_uri_component(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = bytes.get(i + 1..i + 3)?; + let high = (hex[0] as char).to_digit(16)?; + let low = (hex[1] as char).to_digit(16)?; + out.push((high * 16 + low) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + // Literal characters are complete UTF-8 sequences, so the combined bytes + // are valid exactly when every escaped sequence is (no overlong forms, + // surrogates, or out-of-range code points), matching the URIError rules. + String::from_utf8(out).ok() +} + +fn is_purl_type(value: &str) -> bool { + let mut chars = value.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '+' || c == '-') +} + +fn is_qualifier_key(value: &str) -> bool { + let mut chars = value.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '-' || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') +} + +/// Name normalization for package types whose definitions declare +/// case-insensitive names. Every other type compares names exactly. +fn normalize_name(package_type: &str, name: &str) -> String { + match package_type { + "pypi" => name.to_lowercase().replace('_', "-"), + "bitbucket" | "github" | "composer" | "hex" | "nuget" => name.to_lowercase(), + _ => name.to_string(), + } +} + +/// Parses `pkg:type/namespace/name@version?qualifiers#subpath` following the +/// purl-spec parsing steps. `None` for an invalid package URL. +pub fn parse_purl(value: &str) -> Option { + if value.chars().any(char::is_whitespace) { + return None; + } + let (rest, subpath) = match value.rsplit_once('#') { + Some((rest, subpath)) => (rest, Some(subpath)), + None => (value, None), + }; + let mut has_subpath = false; + if let Some(subpath) = subpath { + for segment in subpath.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + continue; + } + let decoded = decode_uri_component(segment)?; + if decoded.contains('/') { + return None; + } + has_subpath = true; + } + } + let (rest, qualifiers) = match rest.rsplit_once('?') { + Some((rest, qualifiers)) => (rest, Some(qualifiers)), + None => (rest, None), + }; + let mut has_qualifiers = false; + let mut keys: Vec = Vec::new(); + if let Some(qualifiers) = qualifiers { + for pair in qualifiers.split('&').filter(|p| !p.is_empty()) { + let (key, raw) = pair.split_once('=')?; + let key = key.to_ascii_lowercase(); + if !is_qualifier_key(&key) || keys.contains(&key) { + return None; + } + let decoded = decode_uri_component(raw)?; + keys.push(key); + if !decoded.is_empty() { + has_qualifiers = true; + } + } + } + let (scheme, rest) = rest.split_once(':')?; + if !scheme.eq_ignore_ascii_case("pkg") { + return None; + } + let rest = rest.trim_matches('/'); + let (package_type, rest) = rest.split_once('/')?; + if !is_purl_type(package_type) { + return None; + } + let package_type = package_type.to_ascii_lowercase(); + // An '@' before the last '/' is an unencoded npm scope, not a version. + let (rest, version) = match rest.rsplit_once('@') { + Some((head, version)) if !version.contains('/') => { + (head, Some(decode_uri_component(version)?)) + } + _ => (rest, None), + }; + let rest = rest.trim_end_matches('/'); + let (namespace, name) = match rest.rsplit_once('/') { + Some((namespace, name)) => (Some(namespace), name), + None => (None, rest), + }; + let name = decode_uri_component(name)?; + if name.is_empty() { + return None; + } + let namespace = match namespace { + None => None, + Some(namespace) => { + let mut segments = Vec::new(); + for segment in namespace.split('/').filter(|s| !s.is_empty()) { + let decoded = decode_uri_component(segment)?; + if decoded.is_empty() || decoded.contains('/') { + return None; + } + segments.push(decoded); + } + (!segments.is_empty()).then(|| segments.join("/")) + } + }; + let key = format!( + "{package_type}\u{0}{}\u{0}{}", + namespace.as_deref().unwrap_or("").to_lowercase(), + normalize_name(&package_type, &name) + ); + Some(ParsedPurl { + package_type, + namespace, + name, + version: version.filter(|v| !v.is_empty()), + has_qualifiers, + has_subpath, + key, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(value: &str) -> Option { + parse_purl(value).map(|p| p.key) + } + + #[test] + fn compares_type_namespace_and_name_only() { + assert_eq!(key("pkg:npm/npm@10.9.0"), key("pkg:npm/npm")); + assert_eq!(key("pkg:NPM/%40Scope/pkg"), key("pkg:npm/%40scope/pkg")); + assert_eq!(key("pkg:npm/@scope/pkg"), key("pkg:npm/%40scope/pkg")); + assert_ne!(key("pkg:npm/Pkg"), key("pkg:npm/pkg")); + assert_eq!(key("pkg:pypi/Foo_Bar"), key("pkg:pypi/foo-bar")); + assert_eq!( + key("pkg:nuget/Newtonsoft.Json"), + key("pkg:nuget/newtonsoft.json") + ); + assert_eq!(key("pkg://npm/npm"), key("pkg:npm/npm")); + assert_eq!(key("PKG:npm/npm"), key("pkg:npm/npm")); + } + + #[test] + fn records_ignored_components() { + let p = parse_purl("pkg:npm/%40scope/pkg@1.0.0?arch=x64#lib/x").unwrap(); + assert_eq!(p.namespace.as_deref(), Some("@scope")); + assert_eq!(p.name, "pkg"); + assert_eq!(p.version.as_deref(), Some("1.0.0")); + assert!(p.has_qualifiers && p.has_subpath); + let p = parse_purl("pkg:npm/npm@").unwrap(); + assert_eq!(p.version, None); + assert!(!p.has_qualifiers && !p.has_subpath); + assert_eq!( + parse_purl("pkg:npm/a@%E2%82%AC") + .unwrap() + .version + .as_deref(), + Some("\u{20ac}") + ); + } + + #[test] + fn rejects_invalid_package_urls() { + for invalid in [ + "npm/npm", + "pkg:npm", + "pkg:npm/", + "pkg:1pm/x", + "pkg:npm/npm@%E0%A4%A", + "pkg:npm/npm@%C0%AF", + "pkg:npm/npm@%ED%A0%80", + "pkg:npm/npm@%zz", + "pkg:npm/npm?1bad=x", + "pkg:npm/npm?novalue", + "pkg:npm/npm?a=1&a=2", + "pkg:npm/has space", + "http:npm/npm", + ] { + assert_eq!(parse_purl(invalid), None, "{invalid}"); + } + } +} diff --git a/src/mxc-sdk/src/policy_store/request.rs b/src/mxc-sdk/src/policy_store/request.rs new file mode 100644 index 000000000..b731785d1 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/request.rs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The JSON request envelope that the Node and C# SDKs send through +//! `mxc_ffi`: `{"tools": , "context"?: {...}}`. +//! +//! A tool input is a bare invocation name or +//! `{"invocationName", "packageUrl"?, "detectedVersion"?, "intent"?}`. The context keys +//! are the camelCase `ResolveContext` fields. An explicit `null` is the same +//! as an omitted optional field. Unknown keys and wrongly typed values are +//! `malformed_request` (`invalid_context`) failures. + +use crate::policy_store::errors::{invalid_context, Result}; +use crate::policy_store::json::{Json, JsonObject}; +use crate::policy_store::model::{ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs}; + +fn only_keys(object: &JsonObject, allowed: &[&str], at: &str) -> Result<()> { + match object.keys().find(|key| !allowed.contains(key)) { + Some(key) => Err(invalid_context(format!( + "{at}.{key} is not a supported field" + ))), + None => Ok(()), + } +} + +fn optional_string(object: &JsonObject, key: &str, at: &str) -> Result> { + match object.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(invalid_context(format!("{at}.{key} must be a string"))), + } +} + +fn tool_input(value: &Json, index: usize) -> Result { + let at = format!("tools[{index}]"); + match value { + Json::String(name) => Ok(ToolInput::Name(name.clone())), + Json::Object(object) => { + only_keys( + object, + &["invocationName", "packageUrl", "detectedVersion", "intent"], + &at, + )?; + let Some(invocation_name) = optional_string(object, "invocationName", &at)? else { + return Err(invalid_context(format!("{at}.invocationName is required"))); + }; + Ok(ToolInput::Candidate(ToolCandidate { + invocation_name, + package_url: optional_string(object, "packageUrl", &at)?, + detected_version: optional_string(object, "detectedVersion", &at)?, + intent: optional_string(object, "intent", &at)?, + })) + } + _ => Err(invalid_context(format!( + "{at} must be an invocation name or a tool candidate object" + ))), + } +} + +/// One tool input or an array of them. +pub fn parse_tool_inputs(value: &Json) -> Result { + match value { + Json::Array(items) => items + .iter() + .enumerate() + .map(|(index, item)| tool_input(item, index)) + .collect::>>() + .map(ToolInputs), + single => Ok(ToolInputs(vec![tool_input(single, 0)?])), + } +} + +/// A `ResolveContext` object; `None` or `null` is the empty context. +pub fn parse_resolve_context(value: Option<&Json>) -> Result { + let object = match value { + None | Some(Json::Null) => return Ok(ResolveContext::new()), + Some(Json::Object(object)) => object, + Some(_) => return Err(invalid_context("context must be an object")), + }; + let at = "context"; + only_keys( + object, + &[ + "projectRoot", + "symbols", + "platform", + "architecture", + "catalogRevision", + "allowWeakIdentityFallback", + ], + at, + )?; + let symbols = match object.get("symbols") { + None | Some(Json::Null) => None, + Some(Json::Object(entries)) => { + let mut map = SymbolMap::new(); + for (name, value) in entries.iter() { + let Some(value) = value.as_str() else { + return Err(invalid_context(format!( + "{at}.symbols.{name} must be a string" + ))); + }; + map.insert(name, value); + } + Some(map) + } + Some(_) => return Err(invalid_context(format!("{at}.symbols must be an object"))), + }; + let allow_weak_identity_fallback = match object.get("allowWeakIdentityFallback") { + None | Some(Json::Null) => false, + Some(Json::Bool(value)) => *value, + Some(_) => { + return Err(invalid_context(format!( + "{at}.allowWeakIdentityFallback must be a boolean" + ))) + } + }; + Ok(ResolveContext { + project_root: optional_string(object, "projectRoot", at)?, + symbols, + platform: optional_string(object, "platform", at)?, + architecture: optional_string(object, "architecture", at)?, + catalog_revision: optional_string(object, "catalogRevision", at)?, + allow_weak_identity_fallback, + }) +} + +/// Parses the whole `{"tools", "context"?}` request document. +pub fn parse_resolve_request(text: &str) -> Result<(ToolInputs, ResolveContext)> { + let value = Json::parse(text) + .map_err(|e| invalid_context(format!("request is not valid JSON: {e}")))?; + let Some(object) = value.as_object() else { + return Err(invalid_context("request must be an object")); + }; + only_keys(object, &["tools", "context"], "request")?; + let Some(tools) = object.get("tools") else { + return Err(invalid_context("request.tools is required")); + }; + Ok(( + parse_tool_inputs(tools)?, + parse_resolve_context(object.get("context"))?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy_store::errors::ErrorReason; + + #[test] + fn parses_a_full_request() { + let (tools, ctx) = parse_resolve_request( + r#"{"tools":["git",{"invocationName":"npm","packageUrl":"pkg:npm/npm","detectedVersion":null,"intent":"install"}], + "context":{"platform":"linux","allowWeakIdentityFallback":true,"symbols":{"a":"/x"},"projectRoot":null}}"#, + ) + .unwrap(); + assert_eq!(tools.0.len(), 2); + assert_eq!(tools.0[0], ToolInput::Name("git".into())); + assert_eq!( + tools.0[1], + ToolInput::Candidate( + ToolCandidate::new("npm") + .with_package_url("pkg:npm/npm") + .with_intent("install") + ) + ); + assert_eq!(ctx.platform.as_deref(), Some("linux")); + assert!(ctx.allow_weak_identity_fallback); + assert_eq!(ctx.symbols.unwrap().get("a"), Some("/x")); + assert_eq!(ctx.project_root, None); + } + + #[test] + fn a_single_input_is_a_one_element_list() { + let (tools, ctx) = parse_resolve_request(r#"{"tools":"git"}"#).unwrap(); + assert_eq!(tools, ToolInputs(vec!["git".into()])); + assert_eq!(ctx, ResolveContext::new()); + } + + #[test] + fn rejects_malformed_requests_as_invalid_context() { + for text in [ + "not json", + "[]", + "{}", + r#"{"tools":"git","extra":1}"#, + r#"{"tools":1}"#, + r#"{"tools":[{"packageUrl":"pkg:npm/x"}]}"#, + r#"{"tools":[{"invocationName":"x","other":1}]}"#, + r#"{"tools":[{"invocationName":"x","intent":1}]}"#, + r#"{"tools":"git","context":[]}"#, + r#"{"tools":"git","context":{"platform":1}}"#, + r#"{"tools":"git","context":{"symbols":{"a":1}}}"#, + r#"{"tools":"git","context":{"allowWeakIdentityFallback":"yes"}}"#, + r#"{"tools":"git","context":{"unknown":true}}"#, + ] { + let error = parse_resolve_request(text).unwrap_err(); + assert_eq!( + error.reason(), + ErrorReason::InvalidContext, + "{text}: {error}" + ); + } + } +} diff --git a/src/mxc-sdk/src/policy_store/resolver.rs b/src/mxc-sdk/src/policy_store/resolver.rs new file mode 100644 index 000000000..2c238d89f --- /dev/null +++ b/src/mxc-sdk/src/policy_store/resolver.rs @@ -0,0 +1,1069 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Runtime lookup and inspection (design §4.3–§4.5; API spec §1, §3): per input, match one +//! entry, select its version and intent, and compose every contributing +//! (tool, intent) pair and its dependencies into one candidate floor. + +use crate::policy_store::catalog::{ + entry_index, CatalogEntry, Dependency, IdentityPredicate, IntentDefinition, Overlay, + SymbolSource, +}; +use crate::policy_store::compose::{ + compose, compose_check, contribution_symbols, Contribution, IdentityOracle, +}; +use crate::policy_store::effective::{ + has_arch_specific, materialize, select_platform_variant, Closure, IntentChoice, LayerSet, + PlatformSelection, +}; +use crate::policy_store::errors::{ + invalid_catalog, invalid_context, ErrorReason, PolicyCatalogError, Result, +}; +use crate::policy_store::exact::validate_exact; +use crate::policy_store::host::{object_within, HostEnvironment, ObjectRelation, SystemHost}; +use crate::policy_store::json::cmp_utf16; +use crate::policy_store::model::{ + Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, + CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, + DetailWarningKind, Diagnostics, IntentMode, IntentSelection, MatchedIdentity, Platform, + PlatformVariantMetadata, Provenance, PurlComponent, Requirements, RequirementsResolution, + ResolutionDetailWarning, ResolveContext, SymbolValueSource, ToolCandidate, ToolInputs, + ToolRecord, ToolResolutionStatus, ToolResolutionWarning, ToolSelection, ToolWarningKind, + VersionSelection, VersionStatus, VersionVariantMetadata, Warning, +}; +use crate::policy_store::paths::{case_key, is_absolute_path, normalize_path}; +use crate::policy_store::purl::{parse_purl, ParsedPurl}; +use crate::policy_store::store::{bundled_catalog_store, CatalogStore}; +use crate::policy_store::text::replace_symbols; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; + +fn validate_candidate(candidate: &ToolCandidate, index: usize) -> Result<()> { + if candidate.invocation_name.is_empty() { + return Err(invalid_context(format!( + "tool input {index}: invocationName must be a non-empty string" + ))); + } + if candidate.invocation_name.contains(['/', '\\']) { + return Err(invalid_context(format!( + "tool input {index}: invocationName must be a bare name, not a path" + ))); + } + for (key, value) in [ + ("packageUrl", &candidate.package_url), + ("detectedVersion", &candidate.detected_version), + ("intent", &candidate.intent), + ] { + if value.as_deref() == Some("") { + return Err(invalid_context(format!( + "tool input {index}: {key} must be a non-empty string when present" + ))); + } + } + Ok(()) +} + +fn describe_input(index: usize, tool: &ToolCandidate) -> String { + format!("input {index} ('{}')", tool.invocation_name) +} + +fn tool_warning(input_index: usize, kind: ToolWarningKind, message: String) -> Warning { + Warning::Tool(ToolResolutionWarning { + input_index, + kind, + message, + }) +} + +fn union_into(target: &mut Vec, extra: &[usize]) { + target.extend_from_slice(extra); + target.sort_unstable(); + target.dedup(); +} + +fn detail_warning( + mut input_indexes: Vec, + mut entry_ids: Vec, + kind: DetailWarningKind, + message: String, +) -> Warning { + union_into(&mut input_indexes, &[]); + entry_ids.sort_by(|a, b| cmp_utf16(a, b)); + entry_ids.dedup(); + Warning::Detail(ResolutionDetailWarning { + input_indexes, + entry_ids, + kind, + message, + }) +} + +fn intent_metadata(list: &[(String, IntentDefinition)]) -> Vec { + list.iter() + .map(|(name, definition)| CatalogIntentMetadata { + name: name.clone(), + example_subcommands: definition.example_subcommands.clone(), + dependency_entry_ids: dependency_ids(&definition.dependencies), + }) + .collect() +} + +fn dependency_ids(list: &[Dependency]) -> Vec { + list.iter().map(|d| d.entry_id.clone()).collect() +} + +fn additions_metadata(overlay: &Overlay) -> CatalogAdditionsMetadata { + CatalogAdditionsMetadata { + dependency_entry_ids: dependency_ids(&overlay.dependencies), + intent_additions: intent_metadata(&overlay.intent_additions), + new_intents: intent_metadata(&overlay.new_intents), + } +} + +/// The eligible entry chosen for one input. +struct Choice<'a> { + entry: &'a CatalogEntry, + satisfied: Vec<&'a IdentityPredicate>, + strong: bool, +} + +/// Lazily determined architecture: detected only when first needed. +struct LazyArchitecture<'h> { + host: &'h dyn HostEnvironment, + value: Cell>, +} + +impl LazyArchitecture<'_> { + fn get(&self) -> Result { + if let Some(value) = self.value.get() { + return Ok(value); + } + let value = self.host.native_architecture()?; + self.value.set(Some(value)); + Ok(value) + } +} + +/// Read-only view over one catalog store with an injectable host. +pub struct PolicyCatalog { + store: Arc, + host: Arc, +} + +impl std::fmt::Debug for PolicyCatalog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PolicyCatalog") + .field("store", &self.store) + .finish_non_exhaustive() + } +} + +impl PolicyCatalog { + /// A catalog over `store` using the real host ([`SystemHost`]). + pub fn new(store: Arc) -> Self { + Self::with_host(store, Arc::new(SystemHost)) + } + + pub fn with_host(store: Arc, host: Arc) -> Self { + Self { store, host } + } + + pub fn store(&self) -> &CatalogStore { + &self.store + } + + // ----------------------------------------------------------------------- + // Setup and inspection (API spec §1) + // ----------------------------------------------------------------------- + + pub fn get_catalog_info(&self) -> Result { + let revision = self.store.revision(None)?; + Ok(CatalogInfo { + catalog_schema_version: revision.catalog_schema_version.clone(), + catalog_revision: revision.catalog_revision.clone(), + sdk_contract_version: revision.sdk_contract_version.clone(), + }) + } + + /// Metadata for every entry of the installed revision, ordered by `entryId`. + pub fn list_catalog_entries(&self) -> Result> { + let revision = self.store.revision(None)?; + let mut entries: Vec<&CatalogEntry> = revision.entries.iter().collect(); + entries.sort_by(|a, b| cmp_utf16(&a.entry_id, &b.entry_id)); + Ok(entries + .into_iter() + .map(|entry| CatalogEntryMetadata { + catalog_revision: revision.catalog_revision.clone(), + entry_id: entry.entry_id.clone(), + entry_revision: entry.entry_revision, + display_name: entry.display_name.clone(), + version_scheme: entry.version_scheme, + identity: entry + .identity + .iter() + .map(|predicate| match predicate { + IdentityPredicate::Purl { value } => CatalogIdentityMetadata::Purl { + value: value.clone(), + }, + IdentityPredicate::InvocationName { names } => { + CatalogIdentityMetadata::InvocationName { + names: names.clone(), + } + } + }) + .collect(), + default: DefaultMetadata { + dependency_entry_ids: dependency_ids(&entry.default.dependencies), + intents: intent_metadata(&entry.default.intents), + }, + platform_variants: entry + .platform_variants + .iter() + .map(|variant| PlatformVariantMetadata { + platform: variant.platform, + architecture: variant.architecture, + additions: additions_metadata(&variant.overlay), + }) + .collect(), + version_variants: entry + .version_variants + .iter() + .map(|variant| VersionVariantMetadata { + version_range: variant.version_range.as_str().to_string(), + additions: additions_metadata(&variant.overlay), + }) + .collect(), + provenance: Provenance { + method: entry.provenance_method.clone(), + source_revision: entry.provenance_source_revision.clone(), + }, + }) + .collect()) + } + + // ----------------------------------------------------------------------- + // Runtime lookup (API spec §1, §3) + // ----------------------------------------------------------------------- + + /// The composed requirements, or `None` when none can be resolved. + pub fn resolve_requirements( + &self, + tools: impl Into, + ctx: &ResolveContext, + ) -> Result> { + Ok(self.resolve(&tools.into(), ctx)?.requirements) + } + + /// Resolves one tool or a list of tools in one pass: the composed + /// requirements with attribution and warnings. Library failures are + /// errors, never absence. The result is a best-effort floor, not + /// authorization. + pub fn resolve_requirements_with_diagnostics( + &self, + tools: impl Into, + ctx: &ResolveContext, + ) -> Result { + self.resolve(&tools.into(), ctx) + } + + fn resolve(&self, tools: &ToolInputs, ctx: &ResolveContext) -> Result { + let candidates: Vec = tools.0.iter().map(|t| t.to_candidate()).collect(); + for (index, candidate) in candidates.iter().enumerate() { + validate_candidate(candidate, index)?; + } + let (ctx_platform, ctx_architecture) = self.validate_context(ctx)?; + let revision = self.store.revision(ctx.catalog_revision.as_deref())?; + let platform = match ctx_platform { + Some(platform) => platform, + None => self.host.platform()?, + }; + let allow_weak = ctx.allow_weak_identity_fallback; + let lazy = LazyArchitecture { + host: self.host.as_ref(), + value: Cell::new(ctx_architecture), + }; + let architecture = || lazy.get(); + + let mut warnings: Vec = Vec::new(); + let mut tool_records = Vec::new(); + let by_id = entry_index(&revision.entries); + let mut ordered: Vec<&CatalogEntry> = revision.entries.iter().collect(); + ordered.sort_by(|a, b| cmp_utf16(&a.entry_id, &b.entry_id)); + + let mut set = LayerSet::default(); + let mut closure = Closure::new(&by_id, platform, &architecture); + // Root entries with their platform selection and requesting inputs. + let mut roots: Vec<(&CatalogEntry, Option>, Vec)> = Vec::new(); + + for (input_index, tool) in candidates.iter().enumerate() { + let unmatched = |records: &mut Vec| { + records.push(ToolRecord { + input_index, + contributes: false, + status: ToolResolutionStatus::ToolUnmatched, + selection: None, + }); + }; + // Identity (design §4.3): an invalid candidate PURL leaves the + // pair unmatched, with no invocation-name retry. + let purl = match &tool.package_url { + None => None, + Some(package_url) => match parse_purl(package_url) { + None => { + warnings.push(tool_warning( + input_index, + ToolWarningKind::PurlInvalid { + package_url: package_url.clone(), + }, + format!( + "{}: '{package_url}' is not a valid package URL; the input was not matched by invocation name either", + describe_input(input_index, tool) + ), + )); + unmatched(&mut tool_records); + continue; + } + Some(purl) => { + let mut ignored = Vec::new(); + if purl.version.is_some() { + ignored.push(PurlComponent::Version); + } + if purl.has_qualifiers { + ignored.push(PurlComponent::Qualifiers); + } + if purl.has_subpath { + ignored.push(PurlComponent::Subpath); + } + if !ignored.is_empty() { + let names: Vec<&str> = ignored.iter().map(|c| c.as_str()).collect(); + warnings.push(tool_warning( + input_index, + ToolWarningKind::PurlComponentsIgnored { + package_url: package_url.clone(), + ignored_components: ignored, + }, + format!( + "{}: packageUrl {} ignored for matching; it is not version evidence (supply detectedVersion)", + describe_input(input_index, tool), + names.join(", ") + ), + )); + } + Some(purl) + } + }, + }; + let choice = match self.match_tool( + &ordered, + tool, + purl.as_ref(), + input_index, + platform, + allow_weak, + &architecture, + )? { + Ok(choice) => choice, + Err(skipped) => { + let suffix = if skipped.is_empty() { + String::new() + } else { + format!(": {}", skipped.join("; ")) + }; + warnings.push(tool_warning( + input_index, + ToolWarningKind::ToolUnmatched { + invocation_name: tool.invocation_name.clone(), + }, + format!( + "{} matched no eligible catalog entry{suffix}", + describe_input(input_index, tool) + ), + )); + unmatched(&mut tool_records); + continue; + } + }; + let entry = choice.entry; + if !choice.strong { + warnings.push(tool_warning( + input_index, + ToolWarningKind::WeakIdentity { + entry_id: entry.entry_id.clone(), + invocation_name: tool.invocation_name.clone(), + }, + format!( + "{} matched {} only by invocation name (weak identity)", + describe_input(input_index, tool), + entry.entry_id + ), + )); + } + let matched_identities: Vec = choice + .satisfied + .iter() + .map(|p| MatchedIdentity { + kind: p.kind().to_string(), + strength: p.strength(), + }) + .collect(); + let mut record = ToolSelection { + entry_id: entry.entry_id.clone(), + entry_revision: entry.entry_revision, + matched_identities, + version_selection: VersionSelection { + status: VersionStatus::MatchedDefault, + detected_version: tool.detected_version.clone(), + selected_version_range: None, + }, + intent_selection: None, + }; + + // Version selection (design §4.3). + let mut version_variant = None; + if let Some(detected) = &tool.detected_version { + match entry.version_scheme.parse_version(detected) { + None => { + record.version_selection.status = VersionStatus::VersionUnparseable; + warnings.push(tool_warning( + input_index, + ToolWarningKind::VersionUnparseable { + entry_id: entry.entry_id.clone(), + detected_version: detected.clone(), + }, + format!( + "{}: detected version '{detected}' is not a valid {} version for {}; the input contributes nothing", + describe_input(input_index, tool), + entry.version_scheme, + entry.entry_id + ), + )); + tool_records.push(ToolRecord { + input_index, + contributes: false, + status: ToolResolutionStatus::Version( + VersionStatus::VersionUnparseable, + ), + selection: Some(record), + }); + continue; + } + Some(version) => { + version_variant = entry + .version_variants + .iter() + .find(|v| v.version_range.contains(&version)); + match version_variant { + Some(variant) => { + record.version_selection.status = VersionStatus::MatchedVersion; + record.version_selection.selected_version_range = + Some(variant.version_range.as_str().to_string()); + } + None => { + record.version_selection.status = VersionStatus::VersionOutOfRange; + warnings.push(tool_warning( + input_index, + ToolWarningKind::VersionOutOfRange { + entry_id: entry.entry_id.clone(), + detected_version: detected.clone(), + }, + format!( + "{}: detected version '{detected}' is outside every reviewed version range for {}; its unversioned default applies", + describe_input(input_index, tool), + entry.entry_id + ), + )); + } + } + } + } + } + + let selection = select_platform_variant(entry, platform, &architecture)?; + let effective = materialize(entry, selection.map(|s| s.variant), version_variant) + .map_err(|e| invalid_catalog(format!("'{}': {e}", entry.entry_id)))?; + + // Intent selection (design §4.4). + let intent_choice = match &tool.intent { + Some(name) => IntentChoice::Named(name), + None => IntentChoice::All, + }; + let Some(selected) = effective.select(intent_choice) else { + let requested = tool.intent.clone().unwrap_or_default(); + record.intent_selection = Some(IntentSelection { + requested: Some(requested.clone()), + mode: IntentMode::Unsupported, + selected: Vec::new(), + }); + let available = effective.intent_names(); + warnings.push(tool_warning( + input_index, + ToolWarningKind::IntentUnsupported { + entry_id: entry.entry_id.clone(), + intent: requested.clone(), + }, + format!( + "{}: intent '{requested}' is not defined for the selected policy of {} (available: {}); the input contributes nothing", + describe_input(input_index, tool), + entry.entry_id, + if available.is_empty() { + "none".to_string() + } else { + available.join(", ") + } + ), + )); + tool_records.push(ToolRecord { + input_index, + contributes: false, + status: ToolResolutionStatus::IntentUnsupported, + selection: Some(record), + }); + continue; + }; + record.intent_selection = Some(IntentSelection { + requested: tool.intent.clone(), + mode: if tool.intent.is_some() { + IntentMode::Named + } else { + IntentMode::All + }, + selected: selected.intent_names.clone(), + }); + let status = ToolResolutionStatus::Version(record.version_selection.status); + // Provisional; settled once the composed output is known. + tool_records.push(ToolRecord { + input_index, + contributes: true, + status, + selection: Some(record), + }); + match roots.iter_mut().find(|(e, _, _)| std::ptr::eq(*e, entry)) { + Some((_, _, owners)) => owners.push(input_index), + None => roots.push((entry, selection, vec![input_index])), + } + closure.add_layers(entry, &selected.layers, &[input_index], &mut set)?; + } + + let mut diagnostics = Diagnostics { + catalog_revision: revision.catalog_revision.clone(), + tools: tool_records, + resolved_dependencies: Vec::new(), + warnings: Vec::new(), + }; + if set.is_empty() { + diagnostics + .tools + .iter_mut() + .for_each(|t| t.contributes = false); + diagnostics.warnings = warnings; + return Ok(RequirementsResolution { + requirements: None, + diagnostics, + }); + } + + // Architecture diagnostics (design §4.2): every entry whose platform + // has architecture-specific overlays consulted the architecture. + let mut consulted: Vec<(&CatalogEntry, Option>, Vec)> = + Vec::new(); + for (entry, selection, owners) in roots + .iter() + .cloned() + .chain(closure.selections.iter().cloned()) + { + if !has_arch_specific(entry, platform) { + continue; + } + match consulted + .iter_mut() + .find(|(e, _, _)| std::ptr::eq(*e, entry)) + { + Some((_, _, existing)) => union_into(existing, &owners), + None => { + let mut owners = owners; + union_into(&mut owners, &[]); + consulted.push((entry, selection, owners)); + } + } + } + consulted.sort_by(|a, b| cmp_utf16(&a.0.entry_id, &b.0.entry_id)); + if ctx_architecture.is_none() && !consulted.is_empty() { + if let Some(native) = lazy.value.get() { + let mut owners = Vec::new(); + for (_, _, o) in &consulted { + union_into(&mut owners, o); + } + warnings.push(detail_warning( + owners, + consulted.iter().map(|(e, _, _)| e.entry_id.clone()).collect(), + DetailWarningKind::ArchitectureDefault { + platform, + architecture: native, + }, + format!( + "architecture was not specified; overlays were selected for the native system architecture '{native}'; the tool's architecture was not verified" + ), + )); + } + } + for (entry, selection, owners) in &consulted { + if selection.is_some_and(|s| s.exact) { + continue; + } + let arch = architecture()?; + let selected = if selection.is_some() { + ArchitectureFallback::Platform + } else { + ArchitectureFallback::Default + }; + warnings.push(detail_warning( + owners.clone(), + vec![entry.entry_id.clone()], + DetailWarningKind::ArchitectureFallback { + platform, + architecture: arch, + selected, + }, + format!( + "{} has no {arch}-specific {platform} overlay; its architecture-neutral {} data was used", + entry.entry_id, + match selected { + ArchitectureFallback::Platform => "platform", + ArchitectureFallback::Default => "default", + } + ), + )); + } + + let excluded_none = HashSet::new(); + if let Err(violation) = compose_check(&set.contributions(&excluded_none)) { + return Err(PolicyCatalogError::new( + ErrorReason::CompositionConflict, + format!("selected entries cannot be composed: {violation}"), + )); + } + + let Some(symbols) = self.resolve_symbols( + &set.contributions(&excluded_none), + ctx, + platform, + &mut warnings, + )? + else { + diagnostics + .tools + .iter_mut() + .for_each(|t| t.contributes = false); + diagnostics.resolved_dependencies = closure.sorted_records(); + diagnostics.warnings = warnings; + return Ok(RequirementsResolution { + requirements: None, + diagnostics, + }); + }; + let resolve = |template: &str| replace_symbols(template, |name| symbols[name].clone()); + + // Filesystem object identity (design §4.5): a relation whose identity + // is unknown fails closed for every pair that owns either side; the + // remaining layers are composed again. + let identity = HostIdentity { + host: self.host.as_ref(), + local: self.host.platform().ok() == Some(platform), + }; + let mut excluded: HashSet = HashSet::new(); + let composed = loop { + let contributions = set.contributions(&excluded); + if contributions.is_empty() { + break None; + } + let composed = compose(&contributions, &resolve, platform, &identity); + if composed.identity_failures.is_empty() { + break Some(composed); + } + for failure in &composed.identity_failures { + excluded.extend(failure.owners.iter().copied()); + warnings.push(detail_warning( + failure.owners.clone(), + failure.entry_ids.clone(), + DetailWarningKind::FilesystemIdentityUnresolved { + paths: failure.paths.clone(), + platform, + }, + format!( + "filesystem object identity of {} could not be established on {platform}; the requesting tools contribute nothing", + failure + .paths + .iter() + .map(|p| format!("'{p}'")) + .collect::>() + .join(" and ") + ), + )); + } + }; + for record in &mut diagnostics.tools { + if excluded.contains(&record.input_index) { + record.status = ToolResolutionStatus::FilesystemIdentityUnresolved; + record.contributes = false; + } + } + diagnostics.resolved_dependencies = closure + .sorted_records() + .into_iter() + .filter_map(|mut record| { + record.input_indexes.retain(|i| !excluded.contains(i)); + (!record.input_indexes.is_empty()).then_some(record) + }) + .collect(); + let requirements = match composed { + None => None, + Some(composed) => { + warnings.extend(composed.warnings); + if let Err(problem) = validate_exact(&composed.requirements) { + return Err(PolicyCatalogError::new( + ErrorReason::CompositionConflict, + format!("the composed requirements fail SDK target validation: {problem}"), + )); + } + Some(composed.requirements) + } + }; + if requirements.is_none() { + diagnostics + .tools + .iter_mut() + .for_each(|t| t.contributes = false); + } + diagnostics.warnings = warnings; + Ok(RequirementsResolution { + requirements, + diagnostics, + }) + } + + /// The single most specific eligible entry (design §4.3), or the reasons + /// candidate entries were skipped. Ties at the highest rank are errors. + #[allow(clippy::too_many_arguments)] + fn match_tool<'a>( + &self, + ordered: &[&'a CatalogEntry], + tool: &ToolCandidate, + purl: Option<&ParsedPurl>, + input_index: usize, + platform: Platform, + allow_weak: bool, + architecture: &dyn Fn() -> Result, + ) -> Result, Vec>> { + let invocation = case_key(&tool.invocation_name, platform); + let mut ranked: Vec<((bool, bool, u8), Choice<'a>)> = Vec::new(); + let mut skipped: Vec = Vec::new(); + for entry in ordered { + let satisfied: Vec<&IdentityPredicate> = entry + .identity + .iter() + .filter(|predicate| match predicate { + IdentityPredicate::Purl { value } => match purl { + Some(purl) => parse_purl(value).is_some_and(|p| p.key == purl.key), + None => false, + }, + IdentityPredicate::InvocationName { names } => names + .iter() + .any(|name| case_key(name, platform) == invocation), + }) + .collect(); + if satisfied.is_empty() { + continue; + } + let strong = satisfied + .iter() + .any(|p| matches!(p, IdentityPredicate::Purl { .. })); + if !strong && !allow_weak { + skipped.push(format!( + "{} matched only by invocation name and allowWeakIdentityFallback is not enabled", + entry.entry_id + )); + continue; + } + let selection = select_platform_variant(entry, platform, architecture)?; + // Intent specificity uses the overlays that apply to this input: + // its platform selection and the version variant containing its + // detected version, if any. + let version_variant = tool.detected_version.as_deref().and_then(|detected| { + let version = entry.version_scheme.parse_version(detected)?; + entry + .version_variants + .iter() + .find(|v| v.version_range.contains(&version)) + }); + let intent_declared = match &tool.intent { + None => false, + Some(intent) => materialize(entry, selection.map(|s| s.variant), version_variant) + .map_err(|e| invalid_catalog(format!("'{}': {e}", entry.entry_id)))? + .has_intent(intent), + }; + let arch_rank = match selection { + Some(s) if s.exact => 2, + Some(_) => 1, + None => 0, + }; + ranked.push(( + (strong, intent_declared, arch_rank), + Choice { + entry, + satisfied, + strong, + }, + )); + } + let Some(best) = ranked.iter().map(|(rank, _)| *rank).max() else { + return Ok(Err(skipped)); + }; + let mut top: Vec> = ranked + .into_iter() + .filter(|(rank, _)| *rank == best) + .map(|(_, choice)| choice) + .collect(); + if top.len() > 1 { + let ids: Vec<&str> = top.iter().map(|c| c.entry.entry_id.as_str()).collect(); + return Err(PolicyCatalogError::new( + ErrorReason::AmbiguousMatch, + format!( + "{} matches {} entries with equal rank ({}); no entry was selected", + describe_input(input_index, tool), + ids.len(), + ids.join(", ") + ), + )); + } + Ok(Ok(top.remove(0))) + } + + fn validate_context( + &self, + ctx: &ResolveContext, + ) -> Result<(Option, Option)> { + let platform = match &ctx.platform { + None => None, + Some(value) => Some(Platform::parse(value).ok_or_else(|| { + invalid_context(format!("ResolveContext.platform '{value}' is unsupported")) + })?), + }; + let architecture = match &ctx.architecture { + None => None, + Some(value) => Some(Architecture::parse(value).ok_or_else(|| { + invalid_context(format!( + "ResolveContext.architecture '{value}' is unsupported" + )) + })?), + }; + if ctx.project_root.as_deref() == Some("") { + return Err(invalid_context( + "ResolveContext.projectRoot must be a non-empty string when present", + )); + } + let contract = self.store.contract(); + for (name, value) in ctx.symbols.iter().flat_map(|s| s.iter()) { + let Some(definition) = contract.symbol(name) else { + return Err(invalid_context(format!( + "ResolveContext.symbols.{name} is not a catalog symbol" + ))); + }; + // `projectRoot` is shorthand for this symbol (API spec §4): either + // form may supply it, and both must then be identical strings. + if definition.source == SymbolSource::Context { + if value.is_empty() { + return Err(invalid_context(format!( + "ResolveContext.symbols.{name} must be a non-empty string when present" + ))); + } + if let Some(root) = ctx.project_root.as_deref() { + if root != value { + return Err(invalid_context(format!( + "ResolveContext.projectRoot '{root}' and ResolveContext.symbols.{name} '{value}' must be identical when both are present" + ))); + } + } + } + } + Ok((platform, architecture)) + } + + /// Resolves every symbol the contributions need (design §4.2 precedence: + /// caller values, then host-local discovery, then the contract's platform + /// default); `None` (with warnings) when any is unresolved. Never a + /// partial result. + fn resolve_symbols( + &self, + contributions: &[Contribution<'_>], + ctx: &ResolveContext, + platform: Platform, + warnings: &mut Vec, + ) -> Result>> { + let contract = self.store.contract(); + let on_host = self.host.platform().ok() == Some(platform); + let supplied = |name: &str| { + ctx.symbols + .as_ref() + .and_then(|s| s.get(name)) + .map(str::to_string) + }; + let mut values: HashMap = HashMap::new(); + let mut missing: Vec<(String, Vec, Vec)> = Vec::new(); + for (name, entry_ids, owners) in contribution_symbols(contributions) { + let definition = contract + .symbol(&name) + .expect("validated revisions reference declared symbols"); + let mut value: Option<(String, SymbolValueSource)> = + if definition.source == SymbolSource::Context { + ctx.project_root + .clone() + .or_else(|| supplied(&name)) + .map(|v| (v, SymbolValueSource::Caller)) + } else { + supplied(&name).map(|v| (v, SymbolValueSource::Caller)) + }; + if value.is_none() && on_host { + value = match definition.source { + SymbolSource::Host => self + .host + .symbol(&name) + .map(|v| (v, SymbolValueSource::Host)), + SymbolSource::Caller => self + .host + .discover(&name)? + .map(|v| (v, SymbolValueSource::Discovery)), + SymbolSource::Context => None, + }; + } + if value.is_none() { + if let Some(template) = definition.default_for(platform) { + let mut complete = true; + let expanded = replace_symbols(template, |inner| { + match supplied(inner) + .or_else(|| on_host.then(|| self.host.symbol(inner)).flatten()) + { + Some(v) => v, + None => { + complete = false; + String::new() + } + } + }); + if complete { + value = Some(( + normalize_path(&expanded, platform), + SymbolValueSource::Default, + )); + } + } + } + let Some((value, source)) = value else { + missing.push((name, entry_ids, owners)); + continue; + }; + if value.contains("${") || !is_absolute_path(&value, platform) { + return Err(invalid_context(format!( + "symbol '{name}' must resolve to an absolute {platform} path (from {} value '{value}')", + source.as_str() + ))); + } + // API spec §4: every resolved symbol is reported with its source, + // including caller values from either project-root form. + warnings.push(detail_warning( + owners, + entry_ids, + DetailWarningKind::SymbolResolved { + symbol: name.clone(), + value: value.clone(), + source, + }, + format!( + "symbol '{name}' resolved to '{value}' from {}", + match source { + SymbolValueSource::Discovery => "host-local discovery", + SymbolValueSource::Host => "the host environment", + SymbolValueSource::Default => "the contract's platform default", + SymbolValueSource::Caller => "the caller", + } + ), + )); + values.insert(name, value); + } + if !missing.is_empty() { + missing.sort_by(|a, b| cmp_utf16(&a.0, &b.0)); + for (name, entry_ids, owners) in missing { + let hint = + if contract.symbol(&name).map(|d| d.source) == Some(SymbolSource::Context) { + "ResolveContext.projectRoot".to_string() + } else { + format!("ResolveContext.symbols.{name}") + }; + let message = format!( + "required symbol '{name}' (needed by {}) is unresolved; supply {hint}; no requirements were returned", + entry_ids.join(", ") + ); + warnings.push(detail_warning( + owners, + entry_ids, + DetailWarningKind::SymbolUnresolved { symbol: name }, + message, + )); + } + return Ok(None); + } + Ok(Some(values)) + } +} + +/// Host object identity, available only when the lookup targets the host's +/// own platform; other targets fail closed. +struct HostIdentity<'h> { + host: &'h dyn HostEnvironment, + local: bool, +} + +impl IdentityOracle for HostIdentity<'_> { + fn within(&self, inner: &str, outer: &str) -> ObjectRelation { + if self.local { + object_within(self.host, inner, outer) + } else { + ObjectRelation::Unknown + } + } +} + +fn bundled() -> Result<&'static PolicyCatalog> { + static CATALOG: OnceLock = OnceLock::new(); + if let Some(catalog) = CATALOG.get() { + return Ok(catalog); + } + let catalog = PolicyCatalog::new(bundled_catalog_store()?); + Ok(CATALOG.get_or_init(|| catalog)) +} + +/// Composed requirements for one or several tools, from the bundled catalog. +pub fn resolve_requirements( + tools: impl Into, + ctx: &ResolveContext, +) -> Result> { + bundled()?.resolve_requirements(tools, ctx) +} + +/// Composed requirements plus attribution, from the bundled catalog. +pub fn resolve_requirements_with_diagnostics( + tools: impl Into, + ctx: &ResolveContext, +) -> Result { + bundled()?.resolve_requirements_with_diagnostics(tools, ctx) +} + +/// Bundled catalog entry metadata. +pub fn list_catalog_entries() -> Result> { + bundled()?.list_catalog_entries() +} + +/// Bundled catalog schema version, revision, and SDK contract version. +pub fn get_catalog_info() -> Result { + bundled()?.get_catalog_info() +} diff --git a/src/mxc-sdk/src/policy_store/sdk.rs b/src/mxc-sdk/src/policy_store/sdk.rs new file mode 100644 index 000000000..7e8ce27b3 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/sdk.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The public v1 surface (API spec §1): typed [`ContainerRequirements`] over +//! the bundled catalog, with failures mapped to the SDK's [`Error`]. +//! +//! **Prototype, pending API review.** Rust `Error` carries no +//! `details.reason`; the code alone is authoritative (the reason is optional +//! by design). The C ABI, Node, and .NET surfaces carry the reason. + +use crate::mxc_engine::{Error, ErrorCode}; +use crate::policy::ContainerRequirements; +use crate::policy_store::errors::{self, PolicyCatalogError}; +use crate::policy_store::exact::to_container_requirements; +use crate::policy_store::model::{ + CatalogEntryMetadata, CatalogInfo, Diagnostics, ResolveContext, ToolCandidate, +}; +use crate::policy_store::resolver; + +/// Composed requirements and the attribution and warnings from the same +/// resolution pass. +#[derive(Clone, Debug)] +pub struct ToolRequirementsResolution { + /// `None` when no pair contributed or a required symbol is unresolved. + pub requirements: Option, + pub diagnostics: Diagnostics, +} + +fn to_error(error: PolicyCatalogError) -> Error { + let code = match error.code() { + errors::ErrorCode::PolicyValidation => ErrorCode::PolicyValidation, + errors::ErrorCode::MalformedRequest => ErrorCode::MalformedRequest, + errors::ErrorCode::UnsupportedContainment => ErrorCode::UnsupportedContainment, + errors::ErrorCode::BackendError => ErrorCode::BackendError, + }; + Error::new(code, error.detail()) +} + +fn typed( + requirements: Option, +) -> Result, Error> { + requirements + .map(|r| to_container_requirements(&r)) + .transpose() + .map_err(|problem| Error::new(ErrorCode::PolicyValidation, problem)) +} + +/// Resolves one tool or several to command-free container requirements: a +/// best-effort floor that callers review and constrain before adding a +/// command with [`crate::v1::ContainerRequest::from_requirements`]. `None` +/// means no requirements were resolved; library failures are errors. +/// +/// One tool is a one-element slice; `None` selects the documented context +/// defaults (API spec §6). +pub fn resolve_tool_requirements( + tools: &[ToolCandidate], + context: Option<&ResolveContext>, +) -> Result, Error> { + let default = ResolveContext::default(); + typed(resolver::resolve_requirements(tools, context.unwrap_or(&default)).map_err(to_error)?) +} + +/// [`resolve_tool_requirements`] plus per-input statuses, dependency +/// attribution, and structured warnings from the same pass. +pub fn resolve_tool_requirements_with_diagnostics( + tools: &[ToolCandidate], + context: Option<&ResolveContext>, +) -> Result { + let default = ResolveContext::default(); + let resolution = + resolver::resolve_requirements_with_diagnostics(tools, context.unwrap_or(&default)) + .map_err(to_error)?; + Ok(ToolRequirementsResolution { + requirements: typed(resolution.requirements)?, + diagnostics: resolution.diagnostics, + }) +} + +/// Metadata for every entry of the installed catalog revision. +pub fn list_catalog_entries() -> Result, Error> { + resolver::list_catalog_entries().map_err(to_error) +} + +/// The bundled catalog schema version, revision, and SDK contract version. +pub fn get_catalog_info() -> Result { + resolver::get_catalog_info().map_err(to_error) +} + +/// JSON entry points for the Node and C# SDKs, which reach the store through +/// `mxc_ffi`. Not a stable API; the C ABI and its bindings are co-versioned. +/// Failures keep the store's `details.reason`. +#[doc(hidden)] +pub mod binding { + use crate::policy_store::errors::{ErrorReason, PolicyCatalogError, Result}; + use crate::policy_store::exact::to_container_requirements; + use crate::policy_store::json::{Json, JsonObject}; + use crate::policy_store::model::Requirements; + use crate::policy_store::request::parse_resolve_request; + use crate::policy_store::resolver; + + /// Proves the composed value converts to the typed v1 sections before it + /// crosses the boundary, as the Rust surface does. + fn checked(requirements: &Requirements) -> Result { + to_container_requirements(requirements) + .map_err(|problem| PolicyCatalogError::new(ErrorReason::InvalidCatalog, problem))?; + Ok(requirements.to_json()) + } + + /// `{"tools", "context"?}` → `{"requirements"?}`; `requirements` is + /// omitted when nothing resolves, so absence survives the boundary. + pub fn resolve_tool_requirements_json(request: &str) -> Result { + let (tools, context) = parse_resolve_request(request)?; + let mut out = JsonObject::new(); + if let Some(requirements) = resolver::resolve_requirements(tools, &context)? { + out.insert("requirements", checked(&requirements)?); + } + Ok(Json::Object(out).to_compact_string()) + } + + /// `{"tools", "context"?}` → `{"requirements"?, "diagnostics"}`. + pub fn resolve_tool_requirements_with_diagnostics_json(request: &str) -> Result { + let (tools, context) = parse_resolve_request(request)?; + let resolution = resolver::resolve_requirements_with_diagnostics(tools, &context)?; + if let Some(requirements) = &resolution.requirements { + checked(requirements)?; + } + Ok(resolution.to_json().to_compact_string()) + } + + /// `{"catalogSchemaVersion", "catalogRevision", "sdkContractVersion"}`. + pub fn catalog_info_json() -> Result { + Ok(resolver::get_catalog_info()?.to_json().to_compact_string()) + } + + /// An array of entry metadata objects. + pub fn list_catalog_entries_json() -> Result { + let entries = resolver::list_catalog_entries()?; + Ok(Json::Array(entries.iter().map(|e| e.to_json()).collect()).to_compact_string()) + } +} diff --git a/src/mxc-sdk/src/policy_store/store.rs b/src/mxc-sdk/src/policy_store/store.rs new file mode 100644 index 000000000..9bb878697 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/store.rs @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Manifest validation and lazy, validated revision loading plus the catalog +//! bundled into this crate. V1 has no separate catalog digest: the data is +//! compiled into the native library and inherits MXC package signing +//! (design §10). + +use crate::policy_store::catalog::{ + compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, + validate_contract, CatalogContract, CatalogRevision, CATALOG_SCHEMA_VERSION, +}; +use crate::policy_store::errors::{invalid_catalog, ErrorReason, PolicyCatalogError, Result}; +use crate::policy_store::json::{Json, JsonObject}; +use std::cmp::Ordering; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +/// One published revision as listed in `manifest.json`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestRevision { + pub catalog_revision: String, + pub file: String, +} + +/// A validated manifest. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogManifest { + pub catalog_schema_version: String, + pub default_revision: String, + pub revisions: Vec, +} + +/// Source of catalog data: the parsed contract and manifest, and a reader +/// for revision files named by the manifest. +pub trait CatalogSource: Send + Sync { + fn contract(&self) -> &Json; + fn manifest(&self) -> &Json; + /// Reads and parses one manifest `file` entry. The error text follows + /// `could not be read: ` in the resulting integrity error. + fn read_revision(&self, file: &str) -> std::result::Result; +} + +/// In-memory catalog source (fixtures, tests, embedded data). +#[derive(Clone, Debug)] +pub struct MemorySource { + pub contract: Json, + pub manifest: Json, + pub files: HashMap, +} + +impl MemorySource { + /// A source whose manifest publishes `revisions`; the default is the last + /// revision unless `default_revision` is given. + pub fn publishing(contract: Json, revisions: &[Json], default_revision: Option<&str>) -> Self { + let mut files = HashMap::new(); + let mut listed = Vec::new(); + for revision in revisions { + let id = revision + .get("catalogRevision") + .and_then(Json::as_str) + .unwrap_or_default() + .to_string(); + let file = format!("revisions/{id}.json"); + files.insert(file.clone(), revision.clone()); + let mut entry = JsonObject::new(); + entry.insert("catalogRevision", Json::String(id.clone())); + entry.insert("file", Json::String(file)); + listed.push(Json::Object(entry)); + } + let default = default_revision.map(str::to_string).unwrap_or_else(|| { + revisions + .last() + .and_then(|r| r.get("catalogRevision")) + .and_then(Json::as_str) + .unwrap_or_default() + .to_string() + }); + let mut manifest = JsonObject::new(); + manifest.insert( + "catalogSchemaVersion", + Json::String(CATALOG_SCHEMA_VERSION.into()), + ); + manifest.insert("defaultRevision", Json::String(default)); + manifest.insert("revisions", Json::Array(listed)); + Self { + contract, + manifest: Json::Object(manifest), + files, + } + } +} + +impl CatalogSource for MemorySource { + fn contract(&self) -> &Json { + &self.contract + } + + fn manifest(&self) -> &Json { + &self.manifest + } + + fn read_revision(&self, file: &str) -> std::result::Result { + self.files + .get(file) + .cloned() + .ok_or_else(|| format!("no such file '{file}'")) + } +} + +/// Reads a file as UTF-8 text the way Node's `readFileSync(path, 'utf8')` +/// does (invalid sequences become U+FFFD). +pub(crate) fn read_text(path: &Path) -> std::io::Result { + std::fs::read(path).map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) +} + +fn read_json(path: &Path) -> std::result::Result { + let text = read_text(path).map_err(|e| e.to_string())?; + Json::parse(&text).map_err(|e| e.to_string()) +} + +/// Catalog directory source (`contract.v1.json`, `manifest.json`, `revisions/`). +#[derive(Clone, Debug)] +pub struct DirectorySource { + directory: PathBuf, + contract: Json, + manifest: Json, +} + +impl DirectorySource { + pub fn open(directory: impl Into) -> Result { + let directory = directory.into(); + let read = || -> std::result::Result<(Json, Json), String> { + Ok(( + read_json(&directory.join("contract.v1.json"))?, + read_json(&directory.join("manifest.json"))?, + )) + }; + match read() { + Ok((contract, manifest)) => Ok(Self { + directory, + contract, + manifest, + }), + Err(message) => Err(PolicyCatalogError::new( + ErrorReason::Integrity, + format!("catalog data could not be read: {message}"), + )), + } + } +} + +impl CatalogSource for DirectorySource { + fn contract(&self) -> &Json { + &self.contract + } + + fn manifest(&self) -> &Json { + &self.manifest + } + + fn read_revision(&self, file: &str) -> std::result::Result { + read_json(&self.directory.join(file)) + } +} + +fn invalid(message: impl AsRef) -> Result { + Err(invalid_catalog(message)) +} + +/// Validates `manifest.json` (the prototype's `validateManifest`). +pub fn validate_manifest(raw: &Json) -> Result { + let Some(object) = raw.as_object() else { + return invalid("manifest root must be an object"); + }; + for key in object.keys() { + if !["catalogSchemaVersion", "defaultRevision", "revisions"].contains(&key) { + return invalid(format!("unsupported field 'manifest.{key}'")); + } + } + if object.get("catalogSchemaVersion").and_then(Json::as_str) != Some(CATALOG_SCHEMA_VERSION) { + return invalid(format!( + "manifest.catalogSchemaVersion must be '{CATALOG_SCHEMA_VERSION}'" + )); + } + let items = match object.get("revisions").and_then(Json::as_array) { + Some(items) if !items.is_empty() => items, + _ => return invalid("manifest.revisions must be a non-empty array"), + }; + let mut revisions = Vec::new(); + for (index, item) in items.iter().enumerate() { + let at = format!("manifest.revisions[{index}]"); + let Some(item) = item.as_object() else { + return invalid(format!("'{at}' must be an object")); + }; + for key in item.keys() { + if !["catalogRevision", "file"].contains(&key) { + return invalid(format!("unsupported field '{at}.{key}'")); + } + } + let catalog_revision = match item.get("catalogRevision").and_then(Json::as_str) { + Some(id) if is_catalog_revision_id(id) => id.to_string(), + _ => return invalid(format!("'{at}.catalogRevision' must match YYYY-MM-DD.N")), + }; + let expected_file = format!("revisions/{catalog_revision}.json"); + if item.get("file").and_then(Json::as_str) != Some(expected_file.as_str()) { + return invalid(format!("'{at}.file' must be '{expected_file}'")); + } + revisions.push(ManifestRevision { + catalog_revision, + file: expected_file, + }); + } + for index in 1..revisions.len() { + if compare_catalog_revisions( + &revisions[index - 1].catalog_revision, + &revisions[index].catalog_revision, + )? != Ordering::Less + { + return invalid(format!( + "manifest.revisions must be strictly increasing ('{}')", + revisions[index].catalog_revision + )); + } + } + let default_revision = match object.get("defaultRevision").and_then(Json::as_str) { + Some(d) if revisions.iter().any(|r| r.catalog_revision == d) => d.to_string(), + _ => return invalid("manifest.defaultRevision must name a listed revision"), + }; + Ok(CatalogManifest { + catalog_schema_version: CATALOG_SCHEMA_VERSION.to_string(), + default_revision, + revisions, + }) +} + +/// Read-only access to installed catalog revisions. Revisions load lazily on +/// first use, are validated against the contract, and are cached (successes +/// only). +pub struct CatalogStore { + contract: CatalogContract, + manifest: CatalogManifest, + source: Box, + loaded: Mutex>>, +} + +impl std::fmt::Debug for CatalogStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CatalogStore") + .field("manifest", &self.manifest) + .finish_non_exhaustive() + } +} + +impl CatalogStore { + /// Validates the contract, then the manifest. + pub fn new(source: impl CatalogSource + 'static) -> Result { + let contract = validate_contract(source.contract())?; + let manifest = validate_manifest(source.manifest())?; + Ok(Self { + contract, + manifest, + source: Box::new(source), + loaded: Mutex::new(HashMap::new()), + }) + } + + pub fn contract(&self) -> &CatalogContract { + &self.contract + } + + pub fn manifest(&self) -> &CatalogManifest { + &self.manifest + } + + pub fn default_revision(&self) -> &str { + &self.manifest.default_revision + } + + pub fn available_revisions(&self) -> Vec { + self.manifest + .revisions + .iter() + .map(|r| r.catalog_revision.clone()) + .collect() + } + + /// The requested revision, or the installed default. An explicitly + /// requested revision that is not installed is an error, never substituted. + pub fn revision(&self, catalog_revision: Option<&str>) -> Result> { + let id = catalog_revision.unwrap_or(&self.manifest.default_revision); + if let Some(cached) = self.loaded.lock().expect("store cache lock").get(id) { + return Ok(cached.clone()); + } + let Some(listed) = self + .manifest + .revisions + .iter() + .find(|r| r.catalog_revision == id) + else { + return Err(PolicyCatalogError::new( + ErrorReason::RevisionUnavailable, + format!("catalog revision '{id}' is not installed"), + )); + }; + let raw = self.source.read_revision(&listed.file).map_err(|message| { + PolicyCatalogError::new( + ErrorReason::Integrity, + format!("catalog revision '{id}' could not be read: {message}"), + ) + })?; + let revision = validate_catalog_revision(&raw, &self.contract)?; + if revision.catalog_revision != id { + return Err(PolicyCatalogError::new( + ErrorReason::Integrity, + format!( + "file '{}' declares revision '{}', expected '{id}'", + listed.file, revision.catalog_revision + ), + )); + } + let revision = Arc::new(revision); + self.loaded + .lock() + .expect("store cache lock") + .insert(id.to_string(), revision.clone()); + Ok(revision) + } +} + +/// Loads a store from a catalog directory. +pub fn load_catalog_directory(directory: impl AsRef) -> Result { + CatalogStore::new(DirectorySource::open(directory.as_ref())?) +} + +mod bundled_data { + include!(concat!(env!("OUT_DIR"), "/policy_store_catalog.rs")); +} + +/// The directory the bundled catalog was embedded from at build time. The +/// data itself is compiled into the crate; this path is informational (the +/// `validate` command reports it) and may not exist at run time. +pub fn bundled_catalog_source_dir() -> &'static str { + bundled_data::SOURCE_DIR +} + +/// The embedded catalog as an in-memory source. +pub fn bundled_catalog_source() -> Result { + let parse = |name: &str, text: &str| { + Json::parse(text).map_err(|e| { + PolicyCatalogError::new( + ErrorReason::Integrity, + format!("catalog data could not be read: {name}: {e}"), + ) + }) + }; + let contract = parse("contract.v1.json", bundled_data::CONTRACT)?; + let manifest = parse("manifest.json", bundled_data::MANIFEST)?; + let mut files = HashMap::new(); + for (file, text) in bundled_data::REVISIONS { + // A revision that fails to parse is left out, so reading it reports + // an integrity error exactly when it is requested. + if let Ok(value) = Json::parse(text) { + files.insert((*file).to_string(), value); + } + } + Ok(MemorySource { + contract, + manifest, + files, + }) +} + +/// The catalog bundled with this crate (lazily constructed, then shared). +pub fn bundled_catalog_store() -> Result> { + static BUNDLED: OnceLock> = OnceLock::new(); + if let Some(store) = BUNDLED.get() { + return Ok(store.clone()); + } + let store = Arc::new(CatalogStore::new(bundled_catalog_source()?)?); + Ok(BUNDLED.get_or_init(|| store).clone()) +} + +/// Raw text of the embedded files (`contract.v1.json`, `manifest.json`, +/// `revisions/*.json`), for tooling that must see the shipped bytes. +pub fn bundled_catalog_files() -> Vec<(&'static str, &'static str)> { + let mut files = vec![ + ("contract.v1.json", bundled_data::CONTRACT), + ("manifest.json", bundled_data::MANIFEST), + ]; + files.extend(bundled_data::REVISIONS.iter().copied()); + files +} diff --git a/src/mxc-sdk/src/policy_store/text.rs b/src/mxc-sdk/src/policy_store/text.rs new file mode 100644 index 000000000..cda99e94e --- /dev/null +++ b/src/mxc-sdk/src/policy_store/text.rs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! ECMAScript string helpers the TypeScript implementation relies on. + +/// ECMAScript `WhiteSpace` or `LineTerminator` (the set `String.prototype.trim` +/// removes and the regular-expression class `\s` matches). +pub fn js_is_space(c: char) -> bool { + matches!( + c, + '\u{9}' | '\u{a}' | '\u{b}' | '\u{c}' | '\u{d}' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +/// `String.prototype.trim`. +pub fn js_trim(value: &str) -> &str { + value.trim_matches(js_is_space) +} + +/// `String.prototype.toLowerCase` (locale-independent Unicode lowercasing). +pub fn js_to_lower(value: &str) -> String { + value.to_lowercase() +} + +/// Every match of `/\$\{([a-z][A-Za-z0-9_]*)\}/g`: `(start, end, name)` byte ranges. +pub fn symbol_matches(value: &str) -> Vec<(usize, usize, &str)> { + let bytes = value.as_bytes(); + let mut matches = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'$' + && bytes.get(i + 1) == Some(&b'{') + && bytes.get(i + 2).is_some_and(u8::is_ascii_lowercase) + { + let mut j = i + 3; + while bytes + .get(j) + .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_') + { + j += 1; + } + if bytes.get(j) == Some(&b'}') { + matches.push((i, j + 1, &value[i + 2..j])); + i = j + 1; + continue; + } + } + i += 1; + } + matches +} + +/// `value.replace(/\$\{([a-z][A-Za-z0-9_]*)\}/g, name => replacement(name))`. +pub fn replace_symbols(value: &str, mut replacement: impl FnMut(&str) -> String) -> String { + let mut out = String::new(); + let mut last = 0; + for (start, end, name) in symbol_matches(value) { + out.push_str(&value[last..start]); + out.push_str(&replacement(name)); + last = end; + } + out.push_str(&value[last..]); + out +} + +/// `/^[a-z][A-Za-z0-9_]*$/` +pub fn is_symbol_name(value: &str) -> bool { + let mut chars = value.chars(); + chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trim_matches_ecmascript() { + assert_eq!(js_trim("\u{feff} a \u{3000}"), "a"); + assert_eq!(js_trim("\u{85}a"), "\u{85}a"); + } + + #[test] + fn symbols() { + let found: Vec<&str> = symbol_matches("${a}/${b_1}${C}${x${programData}") + .iter() + .map(|m| m.2) + .collect(); + assert_eq!(found, ["a", "b_1", "programData"]); + assert_eq!(replace_symbols("${a}/x/${a}", |_| "$&".into()), "$&/x/$&"); + assert!(is_symbol_name("git_prefix")); + assert!(is_symbol_name("programData")); + assert!(!is_symbol_name("Git")); + assert!(!is_symbol_name("")); + } +} diff --git a/src/mxc-sdk/src/policy_store/validate.rs b/src/mxc-sdk/src/policy_store/validate.rs new file mode 100644 index 000000000..abb60e1f4 --- /dev/null +++ b/src/mxc-sdk/src/policy_store/validate.rs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Catalog-directory validation and the `--base-ref` published-revision +//! immutability check Tooling only: the +//! runtime lookup path never runs git. + +use crate::policy_store::errors::{invalid_catalog, PolicyCatalogError}; +use crate::policy_store::history::{ + check_published_immutability, check_store_history, PublishedRevision, PublishedState, +}; +use crate::policy_store::json::{Json, JsonObject}; +use crate::policy_store::model::Platform; +use crate::policy_store::paths::{is_absolute_path, normalize_path}; +use crate::policy_store::store::{read_text, CatalogManifest, CatalogStore, DirectorySource}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Result of [`validate_catalog_directory`]. +#[derive(Clone, Debug, PartialEq)] +pub struct CatalogValidationReport { + /// True only when every requested check ran and passed. + pub ok: bool, + pub catalog_dir: String, + pub default_revision: Option, + pub revisions: Option>, + /// `(ref, comparedRevisions)`, present only when a base ref was requested. + pub base_ref: Option<(String, usize)>, + pub errors: Vec, +} + +impl CatalogValidationReport { + /// The report as JSON (absent fields omitted). + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + o.insert("ok", Json::Bool(self.ok)); + o.insert("catalogDir", Json::String(self.catalog_dir.clone())); + if let Some(d) = &self.default_revision { + o.insert("defaultRevision", Json::String(d.clone())); + } + if let Some(r) = &self.revisions { + o.insert( + "revisions", + Json::Array(r.iter().map(|s| Json::String(s.clone())).collect()), + ); + } + if let Some((reference, compared)) = &self.base_ref { + let mut b = JsonObject::new(); + b.insert("ref", Json::String(reference.clone())); + b.insert("comparedRevisions", Json::Number(*compared as f64)); + o.insert("baseRef", Json::Object(b)); + } + o.insert( + "errors", + Json::Array( + self.errors + .iter() + .map(|s| Json::String(s.clone())) + .collect(), + ), + ); + Json::Object(o) + } +} + +fn host_path_platform() -> Platform { + if cfg!(windows) { + Platform::Windows + } else { + Platform::Linux + } +} + +/// Node `path.resolve(value)` against the current directory. +pub fn resolve_path(value: &str) -> String { + let platform = host_path_platform(); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let sep = if cfg!(windows) { "\\" } else { "/" }; + let joined = if value.is_empty() { + cwd + } else if is_absolute_path(value, platform) { + let bytes = value.as_bytes(); + let rooted_without_drive = cfg!(windows) + && (bytes[0] == b'\\' || bytes[0] == b'/') + && !(bytes.len() > 1 && (bytes[1] == b'\\' || bytes[1] == b'/')); + if rooted_without_drive && cwd.len() >= 2 && cwd.as_bytes()[1] == b':' { + format!("{}{value}", &cwd[..2]) + } else { + value.to_string() + } + } else { + format!("{cwd}{sep}{value}") + }; + normalize_path(&joined, platform) +} + +/// `fs.realpathSync.native` (without the Windows verbatim prefix). +fn realpath(path: &str) -> std::io::Result { + let canonical = std::fs::canonicalize(path)?; + let text = canonical.to_string_lossy().into_owned(); + Ok(if let Some(rest) = text.strip_prefix("\\\\?\\UNC\\") { + format!("\\\\{rest}") + } else if let Some(rest) = text.strip_prefix("\\\\?\\") { + rest.to_string() + } else { + text + }) +} + +fn git(args: &[&str], cwd: &str) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn git_succeeds(args: &[&str], cwd: &str) -> bool { + git(args, cwd).is_ok() +} + +/// A failure reported as `[policy_validation] ` (TypeScript wraps +/// non-library exceptions that way, which equals an invalid_catalog message). +fn validation_error(message: impl Into) -> PolicyCatalogError { + invalid_catalog(message.into()) +} + +fn segments(path: &str) -> Vec<&str> { + let separators: &[char] = if cfg!(windows) { &['\\', '/'] } else { &['/'] }; + path.split(separators).filter(|s| !s.is_empty()).collect() +} + +/// Node `path.relative(top, dir)` for a `dir` inside `top`, with `/` +/// separators; `None` when `dir` is outside `top`. Windows compares +/// case-insensitively, like Node's win32 `relative`. +fn relative_inside(top: &str, dir: &str) -> Option { + let platform = host_path_platform(); + let top_n = normalize_path(top, platform); + let dir_n = normalize_path(dir, platform); + let top_s = segments(&top_n); + let dir_s = segments(&dir_n); + let same = |a: &str, b: &str| { + if cfg!(windows) { + a.to_lowercase() == b.to_lowercase() + } else { + a == b + } + }; + if top_s.len() > dir_s.len() || top_s.iter().zip(&dir_s).any(|(a, b)| !same(a, b)) { + return None; + } + Some(dir_s[top_s.len()..].join("/")) +} + +/// Reads the published catalog state at `base_ref` for the git repository +/// containing `catalog_dir`. `Ok(None)` when the ref has no catalog there. +pub fn read_published_state_at_ref( + catalog_dir: &str, + base_ref: &str, +) -> Result, PolicyCatalogError> { + if base_ref.is_empty() + || base_ref.starts_with('-') + || base_ref + .chars() + .any(|c| c == '\0' || crate::policy_store::text::js_is_space(c)) + { + return Err(validation_error(format!( + "base-ref check: '{base_ref}' is not a valid git ref" + ))); + } + let dir = realpath(catalog_dir) + .map_err(|e| validation_error(format!("{e}, realpath '{catalog_dir}'")))?; + let top = git(&["rev-parse", "--show-toplevel"], &dir) + .ok() + .and_then(|out| realpath(out.trim()).ok()) + .ok_or_else(|| { + validation_error(format!( + "base-ref check: '{catalog_dir}' is not inside a git work tree" + )) + })?; + let commit = format!("{base_ref}^{{commit}}"); + if !git_succeeds(&["rev-parse", "--verify", "--quiet", &commit], &top) { + return Err(validation_error(format!( + "base-ref check: '{base_ref}' does not name a commit" + ))); + } + let Some(prefix) = relative_inside(&top, &dir) else { + return Err(validation_error(format!( + "base-ref check: '{catalog_dir}' is outside its git work tree" + ))); + }; + let at = |file: &str| { + if prefix.is_empty() { + format!("{base_ref}:{file}") + } else { + format!("{base_ref}:{prefix}/{file}") + } + }; + if !git_succeeds(&["cat-file", "-e", &at("manifest.json")], &top) { + return Ok(None); + } + let manifest_text = git(&["show", &at("manifest.json")], &top).map_err(validation_error)?; + let manifest = Json::parse(&manifest_text).map_err(|e| validation_error(e.message))?; + let revisions = PublishedState::revisions_from_manifest(&manifest); + let mut files = HashMap::new(); + for revision in &revisions { + let file = match &revision.file { + Some(Json::String(s)) => s.clone(), + Some(other) => crate::policy_store::json::js_to_string(other), + None => "undefined".to_string(), + }; + if git_succeeds(&["cat-file", "-e", &at(&file)], &top) { + let text = git(&["show", &at(&file)], &top).map_err(validation_error)?; + if let Some(Json::String(key)) = &revision.file { + files.insert(key.clone(), text); + } + } + } + Ok(Some(PublishedState { revisions, files })) +} + +/// Compares a catalog directory's manifest and files with the revisions +/// published at `base_ref`. Never fails; problems are returned as errors. +pub fn check_against_base_ref( + catalog_dir: &str, + manifest: &CatalogManifest, + base_ref: &str, +) -> (usize, Vec) { + let dir = resolve_path(catalog_dir); + let base = match read_published_state_at_ref(&dir, base_ref) { + Ok(None) => return (0, Vec::new()), + Ok(Some(base)) => base, + Err(error) => return (0, vec![error.message().to_string()]), + }; + let mut files = HashMap::new(); + let revisions: Vec = manifest + .revisions + .iter() + .map(|r| { + let text = read_text(&Path::new(&dir).join(&r.file)).unwrap_or_default(); + files.insert(r.file.clone(), text); + PublishedRevision::new(&r.catalog_revision, &r.file) + }) + .collect(); + let proposed = PublishedState { revisions, files }; + let errors = check_published_immutability(&base, &proposed) + .into_iter() + .map(|message| format!("[immutability] {message}")) + .collect(); + (base.revisions.len(), errors) +} + +fn run_checks( + store: Result, + dir: String, + base_dir: &str, + base_ref: Option<&str>, +) -> CatalogValidationReport { + let mut report = CatalogValidationReport { + ok: false, + catalog_dir: dir, + default_revision: None, + revisions: None, + base_ref: None, + errors: Vec::new(), + }; + let store = match store { + Ok(store) => store, + Err(error) => { + report.errors.push(error.message().to_string()); + return report; + } + }; + report.default_revision = Some(store.default_revision().to_string()); + report.revisions = Some(store.available_revisions()); + report.errors.extend(check_store_history(&store)); + if let Some(base_ref) = base_ref { + let (compared, errors) = check_against_base_ref(base_dir, store.manifest(), base_ref); + report.base_ref = Some((base_ref.to_string(), compared)); + report.errors.extend(errors); + } + report.ok = report.errors.is_empty(); + report +} + +/// Validates a catalog directory: manifest and contract, per-revision +/// integrity, the full contract, entry-revision history, and, with +/// `base_ref`, immutability of every revision published at that ref. +pub fn validate_catalog_directory( + catalog_dir: &str, + base_ref: Option<&str>, +) -> CatalogValidationReport { + let dir = resolve_path(catalog_dir); + let store = DirectorySource::open(PathBuf::from(&dir)).and_then(CatalogStore::new); + run_checks(store, dir.clone(), &dir, base_ref) +} + +/// Validates the catalog embedded in this crate. `catalogDir` in the report +/// (and the base for `--base-ref`) is the directory it was embedded from. +pub fn validate_bundled_catalog(base_ref: Option<&str>) -> CatalogValidationReport { + let dir = resolve_path(crate::policy_store::store::bundled_catalog_source_dir()); + let store = crate::policy_store::store::bundled_catalog_source().and_then(CatalogStore::new); + run_checks(store, dir.clone(), &dir, base_ref) +} diff --git a/src/mxc-sdk/src/policy_store/vers.rs b/src/mxc-sdk/src/policy_store/vers.rs new file mode 100644 index 000000000..98929bc1a --- /dev/null +++ b/src/mxc-sdk/src/policy_store/vers.rs @@ -0,0 +1,1270 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Version schemes and purl `vers` ranges. +//! +//! Every catalog entry declares one `versionScheme`; its version variants and +//! the caller's `detectedVersion` are parsed and compared in that scheme. +//! Ranges use the purl `vers` syntax (`vers:/`), with +//! the validation and containment rules of the VERS specification (ECMA-427, +//! Clause 5). +//! +//! | Scheme | Parsing and ordering | +//! |---|---| +//! | `semver` | Strict SemVer 2.0.0; build metadata is ignored for precedence. | +//! | `npm` | node-semver strict parsing (surrounding whitespace and one leading `v` are accepted; at most 256 characters; major, minor, and patch at most 2^53 - 1), SemVer precedence. | +//! | `pypi` | PEP 440 (case-insensitive, normalized), ordered like `packaging.version`. | +//! | `nuget` | NuGet: one to four numeric parts (missing parts are zero), optional `-` release labels and ignored `+` metadata; labels compare numerically or ordinal-ignore-case. | +//! | `intdot` | Dot-separated non-negative integers, compared numerically with missing components as zero. | +//! +//! A caller-supplied `intdot` version is read up to its first character that +//! is not a digit or `.` (so `2.45.1.windows.1` is `2.45.1`); range literals +//! must be plain `intdot` versions. Integer components of any size compare +//! exactly. + +use std::cmp::Ordering; +use std::fmt; + +/// The version schemes an entry may declare. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum VersionScheme { + Npm, + Semver, + Pypi, + Nuget, + Intdot, +} + +impl VersionScheme { + pub const ALL: [VersionScheme; 5] = [ + VersionScheme::Npm, + VersionScheme::Semver, + VersionScheme::Pypi, + VersionScheme::Nuget, + VersionScheme::Intdot, + ]; + + /// Parses an exact, lower-case scheme name. + pub fn parse(text: &str) -> Option { + Self::ALL.into_iter().find(|s| s.as_str() == text) + } + + pub fn as_str(self) -> &'static str { + match self { + VersionScheme::Npm => "npm", + VersionScheme::Semver => "semver", + VersionScheme::Pypi => "pypi", + VersionScheme::Nuget => "nuget", + VersionScheme::Intdot => "intdot", + } + } + + /// Parses a caller-supplied version (for example `detectedVersion`). + pub fn parse_version(self, text: &str) -> Option { + let key = match self { + VersionScheme::Semver => VersionKey::Semver(parse_semver(text)?), + VersionScheme::Npm => VersionKey::Semver(parse_npm(text)?), + VersionScheme::Pypi => VersionKey::Pypi(parse_pep440(text)?), + VersionScheme::Nuget => VersionKey::Nuget(parse_nuget(text)?), + VersionScheme::Intdot => VersionKey::Intdot(parse_intdot_lenient(text)?), + }; + Some(Version { scheme: self, key }) + } + + /// Parses a version that appears literally in a `vers` range. `intdot` + /// literals must be plain dot-separated integers. + fn parse_literal(self, text: &str) -> Option { + match self { + VersionScheme::Intdot => Some(Version { + scheme: self, + key: VersionKey::Intdot(parse_intdot_strict(text)?), + }), + _ => self.parse_version(text), + } + } +} + +impl fmt::Display for VersionScheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A parsed version in one scheme. Versions of different schemes are never +/// compared by the resolver; [`Ord`] orders them by scheme first. +#[derive(Clone, Debug)] +pub struct Version { + scheme: VersionScheme, + key: VersionKey, +} + +impl Version { + pub fn scheme(&self) -> VersionScheme { + self.scheme + } +} + +impl PartialEq for Version { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for Version {} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + match (&self.key, &other.key) { + (VersionKey::Semver(a), VersionKey::Semver(b)) => a.cmp(b), + (VersionKey::Pypi(a), VersionKey::Pypi(b)) => a.cmp(b), + (VersionKey::Nuget(a), VersionKey::Nuget(b)) => a.cmp(b), + (VersionKey::Intdot(a), VersionKey::Intdot(b)) => cmp_intdot(a, b), + _ => (self.scheme as u8).cmp(&(other.scheme as u8)), + } + } +} + +#[derive(Clone, Debug)] +enum VersionKey { + Semver(Semver), + Pypi(Pep440), + Nuget(NugetVersion), + Intdot(Vec), +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn all_digits(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// Strips leading zeros, keeping at least one digit. +fn normalize_digits(s: &str) -> String { + let trimmed = s.trim_start_matches('0'); + if trimmed.is_empty() { + "0".to_string() + } else { + trimmed.to_string() + } +} + +/// Compares two normalized digit strings numerically. +fn cmp_num(a: &str, b: &str) -> Ordering { + a.len().cmp(&b.len()).then_with(|| a.cmp(b)) +} + +fn is_ident_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'-' +} + +// --------------------------------------------------------------------------- +// SemVer 2.0.0 and npm +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Ident { + Num(String), + Alnum(String), +} + +impl Ord for Ident { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Ident::Num(a), Ident::Num(b)) => cmp_num(a, b), + (Ident::Num(_), Ident::Alnum(_)) => Ordering::Less, + (Ident::Alnum(_), Ident::Num(_)) => Ordering::Greater, + (Ident::Alnum(a), Ident::Alnum(b)) => a.as_bytes().cmp(b.as_bytes()), + } + } +} + +impl PartialOrd for Ident { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Semver { + core: [String; 3], + pre: Vec, +} + +impl Ord for Semver { + fn cmp(&self, other: &Self) -> Ordering { + for i in 0..3 { + match cmp_num(&self.core[i], &other.core[i]) { + Ordering::Equal => {} + o => return o, + } + } + match (self.pre.is_empty(), other.pre.is_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => self.pre.cmp(&other.pre), + } + } +} + +impl PartialOrd for Semver { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// A SemVer numeric identifier: `0` or no leading zero. +fn semver_numeric(s: &str) -> bool { + all_digits(s) && (s == "0" || !s.starts_with('0')) +} + +fn parse_semver(text: &str) -> Option { + let (rest, build) = match text.split_once('+') { + Some((r, b)) => (r, Some(b)), + None => (text, None), + }; + if let Some(build) = build { + if build + .split('.') + .any(|p| p.is_empty() || !p.bytes().all(is_ident_char)) + { + return None; + } + } + let (core, pre) = match rest.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (rest, None), + }; + let parts: Vec<&str> = core.split('.').collect(); + if parts.len() != 3 || !parts.iter().all(|p| semver_numeric(p)) { + return None; + } + let mut idents = Vec::new(); + if let Some(pre) = pre { + for p in pre.split('.') { + if p.is_empty() || !p.bytes().all(is_ident_char) { + return None; + } + if all_digits(p) { + if !semver_numeric(p) { + return None; + } + idents.push(Ident::Num(p.to_string())); + } else { + idents.push(Ident::Alnum(p.to_string())); + } + } + } + Some(Semver { + core: [ + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + ], + pre: idents, + }) +} + +/// node-semver `new SemVer(text)` without `loose`. +fn parse_npm(text: &str) -> Option { + const MAX_LENGTH: usize = 256; + const MAX_SAFE_INTEGER: &str = "9007199254740991"; + if text.len() > MAX_LENGTH { + return None; + } + let trimmed = text.trim(); + let body = trimmed.strip_prefix('v').unwrap_or(trimmed); + let parsed = parse_semver(body)?; + if parsed + .core + .iter() + .any(|n| cmp_num(n, MAX_SAFE_INTEGER) == Ordering::Greater) + { + return None; + } + Some(parsed) +} + +// --------------------------------------------------------------------------- +// PEP 440 (pypi) +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum PreKey { + /// A dev release with no pre or post segment sorts before any pre-release. + NegInf, + Pre(u8, String), + PosInf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum LocalPart { + Str(String), + Num(String), +} + +impl Ord for LocalPart { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (LocalPart::Num(a), LocalPart::Num(b)) => cmp_num(a, b), + (LocalPart::Str(_), LocalPart::Num(_)) => Ordering::Less, + (LocalPart::Num(_), LocalPart::Str(_)) => Ordering::Greater, + (LocalPart::Str(a), LocalPart::Str(b)) => a.cmp(b), + } + } +} + +impl PartialOrd for LocalPart { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Pep440 { + epoch: String, + /// Release with trailing zeros removed. + release: Vec, + pre: PreKey, + /// `None` sorts before any post release. + post: Option, + /// `None` sorts after any dev release. + dev: Option, + /// `None` sorts before any local version. + local: Option>, +} + +fn cmp_num_list(a: &[String], b: &[String]) -> Ordering { + for (x, y) in a.iter().zip(b) { + match cmp_num(x, y) { + Ordering::Equal => {} + o => return o, + } + } + a.len().cmp(&b.len()) +} + +impl Ord for Pep440 { + fn cmp(&self, other: &Self) -> Ordering { + cmp_num(&self.epoch, &other.epoch) + .then_with(|| cmp_num_list(&self.release, &other.release)) + .then_with(|| match (&self.pre, &other.pre) { + (PreKey::Pre(la, na), PreKey::Pre(lb, nb)) => { + la.cmp(lb).then_with(|| cmp_num(na, nb)) + } + (a, b) => a.cmp(b), + }) + .then_with(|| match (&self.post, &other.post) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(a), Some(b)) => cmp_num(a, b), + }) + .then_with(|| match (&self.dev, &other.dev) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => cmp_num(a, b), + }) + .then_with(|| match (&self.local, &other.local) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(a), Some(b)) => a.cmp(b), + }) + } +} + +impl PartialOrd for Pep440 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +struct Cursor<'a> { + s: &'a [u8], + pos: usize, +} + +impl Cursor<'_> { + fn peek(&self) -> Option { + self.s.get(self.pos).copied() + } + + fn peek_at(&self, offset: usize) -> Option { + self.s.get(self.pos + offset).copied() + } + + fn digits(&mut self) -> Option { + let start = self.pos; + while self.peek().is_some_and(|b| b.is_ascii_digit()) { + self.pos += 1; + } + (self.pos > start).then(|| String::from_utf8_lossy(&self.s[start..self.pos]).into_owned()) + } + + fn eat_word(&mut self, words: &[&'static str]) -> Option<&'static str> { + // Longest match, so `preview` wins over `pre`. + let mut best: Option<&'static str> = None; + for word in words { + if self.s[self.pos..].starts_with(word.as_bytes()) + && best.is_none_or(|b| word.len() > b.len()) + { + best = Some(word); + } + } + let word = best?; + self.pos += word.len(); + Some(word) + } + + fn is_sep(b: Option) -> bool { + matches!(b, Some(b'-' | b'_' | b'.')) + } + + /// An optional separator followed by digits; consumes nothing if no + /// digits follow. + fn sep_digits(&mut self) -> Option { + if Self::is_sep(self.peek()) && self.peek_at(1).is_some_and(|b| b.is_ascii_digit()) { + self.pos += 1; + } + self.digits() + } + + /// An optional separator followed by one of `words`; consumes nothing on + /// failure. + fn sep_word(&mut self, words: &[&'static str]) -> Option<&'static str> { + let start = self.pos; + if Self::is_sep(self.peek()) { + self.pos += 1; + } + let found = self.eat_word(words); + if found.is_none() { + self.pos = start; + } + found + } +} + +fn parse_pep440(text: &str) -> Option { + let lower = text.trim().to_ascii_lowercase(); + let mut c = Cursor { + s: lower.as_bytes(), + pos: 0, + }; + if c.peek() == Some(b'v') { + c.pos += 1; + } + let mut epoch = "0".to_string(); + let mark = c.pos; + if let Some(d) = c.digits() { + if c.peek() == Some(b'!') { + c.pos += 1; + epoch = normalize_digits(&d); + } else { + c.pos = mark; + } + } + let mut release = vec![normalize_digits(&c.digits()?)]; + while c.peek() == Some(b'.') && c.peek_at(1).is_some_and(|b| b.is_ascii_digit()) { + c.pos += 1; + release.push(normalize_digits(&c.digits()?)); + } + let mut pre = None; + if let Some(word) = c.sep_word(&["a", "b", "c", "rc", "alpha", "beta", "pre", "preview"]) { + let rank = match word { + "a" | "alpha" => 0, + "b" | "beta" => 1, + _ => 2, + }; + let n = c.sep_digits().map(|d| normalize_digits(&d)); + pre = Some((rank, n.unwrap_or_else(|| "0".to_string()))); + } + let mut post = None; + if c.peek() == Some(b'-') && c.peek_at(1).is_some_and(|b| b.is_ascii_digit()) { + c.pos += 1; + post = Some(normalize_digits(&c.digits()?)); + } else if c.sep_word(&["post", "rev", "r"]).is_some() { + post = Some( + c.sep_digits() + .map(|d| normalize_digits(&d)) + .unwrap_or_else(|| "0".to_string()), + ); + } + let mut dev = None; + if c.sep_word(&["dev"]).is_some() { + dev = Some( + c.sep_digits() + .map(|d| normalize_digits(&d)) + .unwrap_or_else(|| "0".to_string()), + ); + } + let mut local = None; + if c.peek() == Some(b'+') { + c.pos += 1; + let rest = std::str::from_utf8(&c.s[c.pos..]).ok()?; + let mut parts = Vec::new(); + for part in rest.split(['-', '_', '.']) { + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_alphanumeric()) { + return None; + } + parts.push(if all_digits(part) { + LocalPart::Num(normalize_digits(part)) + } else { + LocalPart::Str(part.to_string()) + }); + } + c.pos = c.s.len(); + local = Some(parts); + } + if c.pos != c.s.len() { + return None; + } + while release.len() > 1 && release.last().is_some_and(|r| r == "0") { + release.pop(); + } + if release.len() == 1 && release[0] == "0" { + release.clear(); + } + let pre_key = match (&pre, &post, &dev) { + (None, None, Some(_)) => PreKey::NegInf, + (None, _, _) => PreKey::PosInf, + (Some((rank, n)), _, _) => PreKey::Pre(*rank, n.clone()), + }; + Some(Pep440 { + epoch, + release, + pre: pre_key, + post, + dev, + local, + }) +} + +// --------------------------------------------------------------------------- +// NuGet +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq)] +struct NugetVersion { + parts: [u32; 4], + labels: Vec, +} + +fn nuget_label_number(label: &str) -> Option { + if all_digits(label) { + label.parse::().ok() + } else { + None + } +} + +fn cmp_nuget_label(a: &str, b: &str) -> Ordering { + match (nuget_label_number(a), nuget_label_number(b)) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a + .bytes() + .map(|c| c.to_ascii_uppercase()) + .cmp(b.bytes().map(|c| c.to_ascii_uppercase())), + } +} + +impl Ord for NugetVersion { + fn cmp(&self, other: &Self) -> Ordering { + self.parts.cmp(&other.parts).then_with(|| { + match (self.labels.is_empty(), other.labels.is_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => { + for (x, y) in self.labels.iter().zip(&other.labels) { + match cmp_nuget_label(x, y) { + Ordering::Equal => {} + o => return o, + } + } + self.labels.len().cmp(&other.labels.len()) + } + } + }) + } +} + +impl PartialOrd for NugetVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn parse_nuget(text: &str) -> Option { + let text = text.trim(); + let (rest, metadata) = match text.split_once('+') { + Some((r, m)) => (r, Some(m)), + None => (text, None), + }; + if let Some(metadata) = metadata { + if metadata + .split('.') + .any(|p| p.is_empty() || !p.bytes().all(is_ident_char)) + { + return None; + } + } + let (core, release) = match rest.split_once('-') { + Some((c, r)) => (c, Some(r)), + None => (rest, None), + }; + let numbers: Vec<&str> = core.split('.').collect(); + if numbers.is_empty() || numbers.len() > 4 { + return None; + } + let mut parts = [0u32; 4]; + for (i, n) in numbers.iter().enumerate() { + if !all_digits(n) { + return None; + } + let value = n.parse::().ok()?; + parts[i] = value as u32; + } + let mut labels = Vec::new(); + if let Some(release) = release { + for label in release.split('.') { + if label.is_empty() || !label.bytes().all(is_ident_char) { + return None; + } + if all_digits(label) && label.len() > 1 && label.starts_with('0') { + return None; + } + labels.push(label.to_string()); + } + } + Some(NugetVersion { parts, labels }) +} + +// --------------------------------------------------------------------------- +// intdot +// --------------------------------------------------------------------------- + +fn parse_intdot_strict(text: &str) -> Option> { + let mut out = Vec::new(); + for part in text.split('.') { + if !all_digits(part) { + return None; + } + out.push(normalize_digits(part)); + } + Some(out) +} + +fn parse_intdot_lenient(text: &str) -> Option> { + let text = text.trim(); + let end = text + .bytes() + .position(|b| !(b.is_ascii_digit() || b == b'.')) + .unwrap_or(text.len()); + let prefix = text[..end].trim_end_matches('.'); + if !prefix.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + parse_intdot_strict(prefix) +} + +fn cmp_intdot(a: &[String], b: &[String]) -> Ordering { + let zero = "0".to_string(); + for i in 0..a.len().max(b.len()) { + let x = a.get(i).unwrap_or(&zero); + let y = b.get(i).unwrap_or(&zero); + match cmp_num(x, y) { + Ordering::Equal => {} + o => return o, + } + } + Ordering::Equal +} + +// --------------------------------------------------------------------------- +// vers ranges +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Comparator { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +impl Comparator { + fn is_lesser(self) -> bool { + matches!(self, Comparator::Lt | Comparator::Le) + } + + fn is_greater(self) -> bool { + matches!(self, Comparator::Gt | Comparator::Ge) + } + + fn holds(self, ordering: Ordering) -> bool { + match self { + Comparator::Eq => ordering == Ordering::Equal, + Comparator::Ne => ordering != Ordering::Equal, + Comparator::Lt => ordering == Ordering::Less, + Comparator::Le => ordering != Ordering::Greater, + Comparator::Gt => ordering == Ordering::Greater, + Comparator::Ge => ordering != Ordering::Less, + } + } +} + +#[derive(Clone, Debug)] +struct Constraint { + comparator: Comparator, + version: Version, +} + +/// A validated purl `vers` range. +#[derive(Clone, Debug)] +pub struct VersRange { + text: String, + scheme: VersionScheme, + /// Empty means `*` (every version). + constraints: Vec, +} + +fn percent_decode(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = text.get(i + 1..i + 3)?; + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +impl VersRange { + /// Parses and validates a `vers` range. The error is a human-readable + /// reason. + pub fn parse(text: &str) -> Result { + if text.chars().any(char::is_whitespace) { + return Err("must not contain whitespace".into()); + } + let rest = text + .strip_prefix("vers:") + .ok_or("must start with 'vers:'")?; + let (scheme_text, constraints_text) = rest + .split_once('/') + .ok_or("must have the form 'vers:/'")?; + let scheme = VersionScheme::parse(scheme_text) + .ok_or_else(|| format!("unsupported versioning scheme '{scheme_text}'"))?; + if constraints_text.is_empty() { + return Err("must have at least one constraint".into()); + } + let raw: Vec<&str> = constraints_text.split('|').collect(); + if raw.iter().any(|c| c.is_empty()) { + return Err("must not have an empty constraint".into()); + } + if raw.contains(&"*") { + if raw.len() != 1 { + return Err("'*' must be the only constraint".into()); + } + return Ok(Self { + text: text.to_string(), + scheme, + constraints: Vec::new(), + }); + } + let mut constraints = Vec::with_capacity(raw.len()); + for item in raw { + let (comparator, encoded) = if let Some(v) = item.strip_prefix(">=") { + (Comparator::Ge, v) + } else if let Some(v) = item.strip_prefix("<=") { + (Comparator::Le, v) + } else if let Some(v) = item.strip_prefix("!=") { + (Comparator::Ne, v) + } else if let Some(v) = item.strip_prefix('>') { + (Comparator::Gt, v) + } else if let Some(v) = item.strip_prefix('<') { + (Comparator::Lt, v) + } else { + (Comparator::Eq, item) + }; + if encoded.is_empty() { + return Err(format!("constraint '{item}' has no version")); + } + if encoded.contains(['<', '>', '=', '!', '*', '|']) { + return Err(format!( + "constraint '{item}' has an invalid comparator or an unencoded reserved character" + )); + } + let decoded = percent_decode(encoded) + .ok_or_else(|| format!("constraint '{item}' has an invalid percent-encoding"))?; + let version = scheme + .parse_literal(&decoded) + .ok_or_else(|| format!("'{decoded}' is not a valid {scheme} version"))?; + constraints.push(Constraint { + comparator, + version, + }); + } + for pair in constraints.windows(2) { + match pair[0].version.cmp(&pair[1].version) { + Ordering::Less => {} + Ordering::Equal => return Err("constraint versions must be unique".into()), + Ordering::Greater => { + return Err("constraints must be sorted by ascending version".into()) + } + } + } + let no_ne: Vec = constraints + .iter() + .map(|c| c.comparator) + .filter(|c| *c != Comparator::Ne) + .collect(); + for pair in no_ne.windows(2) { + if pair[0] == Comparator::Eq + && !matches!(pair[1], Comparator::Eq | Comparator::Gt | Comparator::Ge) + { + return Err("an '=' constraint may only be followed by '=', '>', or '>='".into()); + } + } + let bounds: Vec = no_ne.into_iter().filter(|c| *c != Comparator::Eq).collect(); + for pair in bounds.windows(2) { + if pair[0].is_lesser() == pair[1].is_lesser() { + return Err("'<'/'<=' and '>'/'>=' constraints must alternate".into()); + } + } + Ok(Self { + text: text.to_string(), + scheme, + constraints, + }) + } + + pub fn as_str(&self) -> &str { + &self.text + } + + pub fn scheme(&self) -> VersionScheme { + self.scheme + } + + /// Whether `version` is in the range (VERS containment algorithm). A + /// range made only of `!=` constraints contains every other version. + pub fn contains(&self, version: &Version) -> bool { + if self.constraints.is_empty() { + return true; + } + for c in &self.constraints { + let equal = version.cmp(&c.version) == Ordering::Equal; + if equal && c.comparator == Comparator::Eq { + return true; + } + if equal && c.comparator == Comparator::Ne { + return false; + } + } + let bounds: Vec<&Constraint> = self + .constraints + .iter() + .filter(|c| !matches!(c.comparator, Comparator::Eq | Comparator::Ne)) + .collect(); + if bounds.is_empty() { + return self + .constraints + .iter() + .any(|c| c.comparator == Comparator::Ne); + } + let holds = |c: &Constraint| c.comparator.holds(version.cmp(&c.version)); + if bounds[0].comparator.is_lesser() && holds(bounds[0]) { + return true; + } + let last = bounds[bounds.len() - 1]; + if last.comparator.is_greater() && holds(last) { + return true; + } + bounds.windows(2).any(|pair| { + pair[0].comparator.is_greater() + && pair[1].comparator.is_lesser() + && holds(pair[0]) + && holds(pair[1]) + }) + } + + /// The range as intervals plus excluded points. + fn intervals(&self) -> (Vec>, Vec<&Version>) { + let excluded: Vec<&Version> = self + .constraints + .iter() + .filter(|c| c.comparator == Comparator::Ne) + .map(|c| &c.version) + .collect(); + if self.constraints.is_empty() { + return (vec![Interval::default()], excluded); + } + let mut out = Vec::new(); + for c in &self.constraints { + if c.comparator == Comparator::Eq { + out.push(Interval { + lo: Some((&c.version, true)), + hi: Some((&c.version, true)), + }); + } + } + let bounds: Vec<&Constraint> = self + .constraints + .iter() + .filter(|c| !matches!(c.comparator, Comparator::Eq | Comparator::Ne)) + .collect(); + if bounds.is_empty() { + if out.is_empty() { + out.push(Interval::default()); + } + return (out, excluded); + } + fn bound(c: &Constraint) -> (&Version, bool) { + ( + &c.version, + matches!(c.comparator, Comparator::Le | Comparator::Ge), + ) + } + let mut i = 0; + if bounds[0].comparator.is_lesser() { + out.push(Interval { + lo: None, + hi: Some(bound(bounds[0])), + }); + i = 1; + } + while i < bounds.len() { + let lo = Some(bound(bounds[i])); + let hi = bounds.get(i + 1).map(|c| bound(c)); + out.push(Interval { lo, hi }); + i += 2; + } + (out, excluded) + } + + /// Whether some version is in both ranges. Intervals are treated as + /// dense: a non-degenerate intersection always contains a version that + /// no finite set of `!=` constraints excludes. + pub fn overlaps(&self, other: &VersRange) -> bool { + let (a, a_excluded) = self.intervals(); + let (b, b_excluded) = other.intervals(); + for x in &a { + for y in &b { + let lo = max_lo(x.lo, y.lo); + let hi = min_hi(x.hi, y.hi); + match (lo, hi) { + (Some((l, li)), Some((h, hi_inc))) => match l.cmp(h) { + Ordering::Less => return true, + Ordering::Equal if li && hi_inc => { + if !a_excluded.contains(&l) && !b_excluded.contains(&l) { + return true; + } + } + _ => {} + }, + _ => return true, + } + } + } + false + } +} + +impl fmt::Display for VersRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.text) + } +} + +type Bound<'a> = Option<(&'a Version, bool)>; + +#[derive(Clone, Copy, Default)] +struct Interval<'a> { + lo: Bound<'a>, + hi: Bound<'a>, +} + +fn max_lo<'a>(a: Bound<'a>, b: Bound<'a>) -> Bound<'a> { + match (a, b) { + (None, x) | (x, None) => x, + (Some((va, ia)), Some((vb, ib))) => match va.cmp(vb) { + Ordering::Greater => Some((va, ia)), + Ordering::Less => Some((vb, ib)), + Ordering::Equal => Some((va, ia && ib)), + }, + } +} + +fn min_hi<'a>(a: Bound<'a>, b: Bound<'a>) -> Bound<'a> { + match (a, b) { + (None, x) | (x, None) => x, + (Some((va, ia)), Some((vb, ib))) => match va.cmp(vb) { + Ordering::Less => Some((va, ia)), + Ordering::Greater => Some((vb, ib)), + Ordering::Equal => Some((va, ia && ib)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(scheme: VersionScheme, text: &str) -> Version { + scheme + .parse_version(text) + .unwrap_or_else(|| panic!("{scheme} {text}")) + } + + fn assert_ascending(scheme: VersionScheme, versions: &[&str]) { + for pair in versions.windows(2) { + assert!( + v(scheme, pair[0]) < v(scheme, pair[1]), + "{scheme}: {} < {}", + pair[0], + pair[1] + ); + } + } + + fn range(text: &str) -> VersRange { + VersRange::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) + } + + #[test] + fn semver_parsing_and_precedence() { + let s = VersionScheme::Semver; + for bad in [ + "1", + "1.2", + "01.2.3", + "1.2.3-01", + "1.2.3-", + "1.2.3+", + "v1.2.3", + " 1.2.3", + "1.2.3-a..b", + ] { + assert!(s.parse_version(bad).is_none(), "{bad}"); + } + assert_ascending( + s, + &[ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "99999999999999999999.0.0", + ], + ); + assert_eq!(v(s, "1.0.0+a"), v(s, "1.0.0+b")); + } + + #[test] + fn npm_accepts_v_and_whitespace_but_not_loose_forms() { + let n = VersionScheme::Npm; + assert_eq!(v(n, " v1.2.3 "), v(n, "1.2.3")); + for bad in [ + "=1.2.3", + "1.2", + "1.2.3.4", + "9007199254740992.0.0", + "vv1.2.3", + ] { + assert!(n.parse_version(bad).is_none(), "{bad}"); + } + assert!(n.parse_version("9007199254740991.0.0").is_some()); + assert!(n + .parse_version(&format!("1.2.3-{}", "a".repeat(260))) + .is_none()); + } + + #[test] + fn pep440_parsing_and_ordering() { + let p = VersionScheme::Pypi; + assert_eq!(v(p, "1.0"), v(p, "1.0.0")); + assert_eq!(v(p, "1.0ALPHA1"), v(p, "1.0a1")); + assert_eq!(v(p, "1.0-1"), v(p, "1.0.post1")); + assert_eq!(v(p, "1.0.rev"), v(p, "1.0.post0")); + assert_eq!(v(p, "v1.0c1"), v(p, "1.0rc1")); + assert_eq!(v(p, "1.0-preview_2"), v(p, "1.0rc2")); + for bad in ["", "1.0+", "1.0+a..b", "1.0x", "a1", "1.0.", "1!"] { + assert!(p.parse_version(bad).is_none(), "{bad}"); + } + assert_ascending( + p, + &[ + "1.0.dev0", + "1.0a1.dev1", + "1.0a1", + "1.0b1", + "1.0rc1", + "1.0", + "1.0+abc", + "1.0+1", + "1.0.post1.dev0", + "1.0.post1", + "1.1", + "1!0.1", + ], + ); + } + + #[test] + fn nuget_parsing_and_ordering() { + let n = VersionScheme::Nuget; + assert_eq!(v(n, "1"), v(n, "1.0.0.0")); + assert_eq!(v(n, "1.0.0+meta"), v(n, "1.0.0")); + assert_eq!(v(n, "1.0.0-BETA"), v(n, "1.0.0-beta")); + for bad in ["1.2.3.4.5", "1.0.0-01", "1.0.0-", "a", "2147483648", "1..2"] { + assert!(n.parse_version(bad).is_none(), "{bad}"); + } + assert_ascending( + n, + &[ + "1.0.0-1", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-beta", + "1.0.0", + "1.0.0.1", + "1.0.1", + ], + ); + } + + #[test] + fn intdot_parsing_and_ordering() { + let i = VersionScheme::Intdot; + assert_eq!(v(i, "2.40"), v(i, "2.40.0")); + assert_eq!(v(i, "2.45.1.windows.1"), v(i, "2.45.1")); + assert_eq!(v(i, "2.45.1-rc0"), v(i, "2.45.1")); + for bad in ["", "windows", ".1", "1..2", "x2"] { + assert!(i.parse_version(bad).is_none(), "{bad}"); + } + assert_ascending( + i, + &["2.9", "2.10", "2.40", "2.40.0.1", "100000000000000000000"], + ); + assert!(VersRange::parse("vers:intdot/>=2.45.1.windows.1").is_err()); + } + + #[test] + fn vers_syntax_validation() { + for bad in [ + "", + "2.0", + "vers:git/1.0", + "vers:NPM/1.0.0", + "vers:npm/", + "vers:npm/ 1.0.0", + "vers:npm/=1.0.0", + "vers:npm/>=1.0.0||<2.0.0", + "vers:npm/*|>=1.0.0", + "vers:npm/>=2.0.0|<1.0.0", + "vers:npm/1.0.0|1.0.0", + "vers:npm/>=1.0.0|>=2.0.0", + "vers:npm/<1.0.0|<2.0.0", + "vers:npm/1.0.0|<2.0.0", + "vers:npm/>=1.0.0|<2.0.0|<3.0.0", + "vers:npm/>=1.0.0-a%2", + "vers:npm/>=1.0.0-a%zz", + "vers:npm/>=1.0.0-a!b", + "vers:intdot/>=1.x", + ] { + assert!(VersRange::parse(bad).is_err(), "{bad}"); + } + for good in [ + "vers:npm/*", + "vers:npm/1.0.0", + "vers:npm/>=1.0.0|<2.0.0", + "vers:npm/<1.0.0|>=2.0.0", + "vers:npm/1.0.0|>=2.0.0", + "vers:npm/!=1.5.0", + "vers:npm/>=1.0.0|!=1.5.0|<2.0.0", + "vers:pypi/>=1.0%2Blocal", + "vers:intdot/>=2.40|<2.50", + "vers:nuget/>=1.0|<2", + "vers:semver/>=1.0.0-rc.1", + ] { + assert!(VersRange::parse(good).is_ok(), "{good}"); + } + } + + #[test] + fn vers_containment() { + let i = VersionScheme::Intdot; + let r = range("vers:intdot/>=2.40|<2.50"); + assert!(r.contains(&v(i, "2.40"))); + assert!(r.contains(&v(i, "2.49.9"))); + assert!(!r.contains(&v(i, "2.50"))); + assert!(!r.contains(&v(i, "2.39.9"))); + let r = range("vers:intdot/<1|>=2|<3|5|>7"); + for (ver, inside) in [ + ("0.9", true), + ("1", false), + ("2.5", true), + ("3", false), + ("5", true), + ("6", false), + ("8", true), + ] { + assert_eq!(r.contains(&v(i, ver)), inside, "{ver}"); + } + let r = range("vers:intdot/!=2"); + assert!(r.contains(&v(i, "1")) && !r.contains(&v(i, "2"))); + let r = range("vers:intdot/>=1|!=1.5|<2"); + assert!(r.contains(&v(i, "1.4")) && !r.contains(&v(i, "1.5"))); + assert!(range("vers:intdot/*").contains(&v(i, "0"))); + assert!(!range("vers:intdot/1|2").contains(&v(i, "1.5"))); + } + + #[test] + fn vers_overlap() { + let overlaps = |a: &str, b: &str| range(a).overlaps(&range(b)); + assert!(!overlaps( + "vers:intdot/>=2.40|<2.50", + "vers:intdot/>=2.50|<3" + )); + assert!(overlaps( + "vers:intdot/>=2.40|<=2.50", + "vers:intdot/>=2.50|<3" + )); + assert!(overlaps("vers:intdot/>=2.40|<2.50", "vers:intdot/>=2.45")); + assert!(overlaps("vers:intdot/*", "vers:intdot/5")); + assert!(!overlaps("vers:intdot/5", "vers:intdot/!=5")); + assert!(overlaps("vers:intdot/>=4|<6", "vers:intdot/!=5")); + assert!(!overlaps("vers:intdot/<1", "vers:intdot/>1")); + assert!(!overlaps("vers:intdot/1|3", "vers:intdot/>=1.5|<2.5")); + assert!(overlaps("vers:intdot/1|3", "vers:intdot/>=2.5")); + assert!(!overlaps("vers:intdot/>=1|!=2|<3", "vers:intdot/2")); + } +} diff --git a/src/mxc-sdk/src/policy_store/view.rs b/src/mxc-sdk/src/policy_store/view.rs new file mode 100644 index 000000000..66a0050fb --- /dev/null +++ b/src/mxc-sdk/src/policy_store/view.rs @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The rendered reviewer view of a catalog revision (design §7): every +//! effective policy, by entry, version option, platform, architecture, and +//! intent, so reviewers see what each combination actually grants. + +use crate::policy_store::catalog::{entry_index, CatalogContract, CatalogRevision}; +use crate::policy_store::effective::{materialize_entry, Materialized}; +use crate::policy_store::errors::Result; +use crate::policy_store::exact::{bind_fixture_symbols, exact_document}; +use crate::policy_store::json::{canonical_json, cmp_utf16, Json, JsonObject}; +use crate::policy_store::model::{Architecture, FilesystemRequirements, Platform}; + +fn cell(values: &[String]) -> String { + if values.is_empty() { + "—".to_string() + } else { + values + .iter() + .map(|v| format!("`{v}`")) + .collect::>() + .join("
") + } +} + +fn paths( + m: &Materialized, + field: fn(&FilesystemRequirements) -> &Option>, +) -> Vec { + m.requirements + .filesystem + .as_ref() + .and_then(|fs| field(fs).clone()) + .unwrap_or_default() +} + +fn describe_rule(rule: &Json) -> String { + let peers: Vec = rule + .get("to") + .and_then(Json::as_array) + .into_iter() + .flatten() + .map(|peer| { + let cidr = peer.get("cidr").and_then(Json::as_str).unwrap_or("?"); + let except: Vec<&str> = peer + .get("except") + .and_then(Json::as_array) + .into_iter() + .flatten() + .filter_map(Json::as_str) + .collect(); + if except.is_empty() { + cidr.to_string() + } else { + format!("{cidr} except {}", except.join(",")) + } + }) + .collect(); + let ports: Vec = rule + .get("ports") + .and_then(Json::as_array) + .into_iter() + .flatten() + .map(|port| { + let protocol = port.get("protocol").and_then(Json::as_str).unwrap_or("any"); + let number = |key| port.get(key).and_then(Json::as_f64).map(|n| n.to_string()); + match (number("port"), number("endPort")) { + (Some(start), Some(end)) => format!("{protocol}/{start}-{end}"), + (Some(start), None) => format!("{protocol}/{start}"), + _ => protocol.to_string(), + } + }) + .collect(); + let peers = if peers.is_empty() { + "*".to_string() + } else { + peers.join("+") + }; + if ports.is_empty() { + format!("{peers} any port") + } else { + format!("{peers} {}", ports.join("+")) + } +} + +fn egress(m: &Materialized) -> Vec { + let Some(network) = &m.requirements.network else { + return Vec::new(); + }; + let allow: Vec = network + .get("egress") + .and_then(|e| e.get("allow")) + .and_then(Json::as_array) + .into_iter() + .flatten() + .map(describe_rule) + .collect(); + if allow.is_empty() { + vec![format!("{network}")] + } else { + allow + } +} + +type PathField = fn(&FilesystemRequirements) -> &Option>; + +/// What the row adds to the common default for the same intent: paths, +/// outbound rules, and dependencies the default does not have. +fn added(m: &Materialized) -> Vec { + let baseline = Materialized { + requirements: m.default_requirements.clone(), + dependency_entry_ids: m.default_dependency_entry_ids.clone(), + ..m.clone() + }; + let mut out = Vec::new(); + let classes: [(&str, PathField); 2] = [ + ("ro", |f| &f.readonly_paths), + ("rw", |f| &f.readwrite_paths), + ]; + for (label, field) in classes { + let before = paths(&baseline, field); + out.extend( + paths(m, field) + .into_iter() + .filter(|p| !before.contains(p)) + .map(|p| format!("+{label} {p}")), + ); + } + let before = egress(&baseline); + out.extend( + egress(m) + .into_iter() + .filter(|r| !before.contains(r)) + .map(|r| format!("+allow {r}")), + ); + out.extend( + m.dependency_entry_ids + .iter() + .filter(|d| !baseline.dependency_entry_ids.contains(d)) + .map(|d| format!("+dependency {d}")), + ); + out +} + +fn row_cells(m: &Materialized) -> [String; 6] { + [ + cell(&paths(m, |f| &f.readonly_paths)), + cell(&paths(m, |f| &f.readwrite_paths)), + cell(&paths(m, |f| &f.denied_paths)), + cell(&egress(m)), + cell(&m.dependency_entry_ids), + cell(&added(m)), + ] +} + +/// Renders the reviewer view (Markdown). Architectures with identical rows +/// collapse into one `any` row. +pub fn render_reviewer_view( + revision: &CatalogRevision, + contract: &CatalogContract, +) -> Result { + let by_id = entry_index(&revision.entries); + let mut entries: Vec<_> = revision.entries.iter().collect(); + entries.sort_by(|a, b| cmp_utf16(&a.entry_id, &b.entry_id)); + let mut out = String::new(); + out.push_str(&format!( + "\n\n# Catalog revision {} — reviewer view\n\nEvery effective policy each entry can produce (its command-free container requirements): the default, plus platform additions, at most one version variant, and the selected intent. `(all)` means no intent was requested. Paths are unsubstituted templates. *Added to default* lists what the row adds to the common default (no platform or version overlay) for the same intent, or to the default's base for an intent the default does not declare.\n", + revision.catalog_revision + )); + for entry in entries { + let materialized = materialize_entry(entry, &by_id, contract)?; + out.push_str(&format!( + "\n## `{}` — {} (revision {}, `{}` versions)\n", + entry.entry_id, + entry.display_name, + crate::policy_store::catalog::revision_number(entry.entry_revision), + entry.version_scheme + )); + let mut options: Vec> = vec![None]; + options.extend( + entry + .version_variants + .iter() + .map(|v| Some(v.version_range.as_str().to_string())), + ); + for option in options { + out.push_str(&format!( + "\n### {}\n\n| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | Added to default |\n|---|---|---|---|---|---|---|---|---|\n", + option + .as_deref() + .map_or("Default (no detected version or out of range)".to_string(), |r| { + format!("Version `{r}`") + }) + )); + for platform in Platform::ALL { + let rows: Vec<&Materialized> = materialized + .iter() + .filter(|m| m.platform == platform && m.version_range == option) + .collect(); + let mut intents: Vec> = Vec::new(); + for m in &rows { + if !intents.contains(&m.intent) { + intents.push(m.intent.clone()); + } + } + for intent in intents { + let per_arch: Vec<(&Materialized, [String; 6])> = Architecture::ALL + .iter() + .filter_map(|arch| { + rows.iter() + .find(|m| m.architecture == *arch && m.intent == intent) + }) + .map(|m| (*m, row_cells(m))) + .collect(); + let same = per_arch.windows(2).all(|w| w[0].1 == w[1].1) + && per_arch.len() == Architecture::ALL.len(); + let shown: Vec<(String, &[String; 6])> = if same { + vec![("any".to_string(), &per_arch[0].1)] + } else { + per_arch + .iter() + .map(|(m, c)| (m.architecture.to_string(), c)) + .collect() + }; + for (arch, cells) in shown { + out.push_str(&format!( + "| {platform} | {arch} | {} | {} |\n", + intent.as_deref().unwrap_or("(all)"), + cells.join(" | ") + )); + } + } + } + } + } + Ok(out) +} + +/// Every distinct exact SDK target document the revision's materialized +/// combinations produce with fixture symbols and the validation-only command +/// bound (design §4.2), with the combinations that produce it. CI validates +/// each document against the registered schema for its version. +pub fn render_exact_requests( + revision: &CatalogRevision, + contract: &CatalogContract, +) -> Result { + let by_id = entry_index(&revision.entries); + let mut entries: Vec<_> = revision.entries.iter().collect(); + entries.sort_by(|a, b| cmp_utf16(&a.entry_id, &b.entry_id)); + let mut requests: Vec<(String, Json, Vec)> = Vec::new(); + for entry in entries { + for m in materialize_entry(entry, &by_id, contract)? { + let document = exact_document(&bind_fixture_symbols(&m.requirements, m.platform)); + let key = canonical_json(&document); + let combination = Json::String(format!( + "{} {}/{} {} {}", + entry.entry_id, + m.platform, + m.architecture, + m.version_range.as_deref().unwrap_or("default"), + m.intent.as_deref().unwrap_or("(all)") + )); + match requests.iter_mut().find(|(k, _, _)| *k == key) { + Some((_, _, combinations)) => combinations.push(combination), + None => requests.push((key, document, vec![combination])), + } + } + } + let mut root = JsonObject::new(); + root.insert( + "$comment", + "Generated by mxc_sdk policy_store::tooling::render_exact_requests. Do not edit. Each document is the exact SDK target request for the listed combinations, with fixture symbols and a validation-only command; scripts/versioning/validate-configs.js validates it against the registered schema.".into(), + ); + root.insert("catalogRevision", revision.catalog_revision.as_str().into()); + root.insert( + "sdkContractVersion", + revision.sdk_contract_version.as_str().into(), + ); + root.insert( + "requests", + Json::Array( + requests + .into_iter() + .map(|(_, document, combinations)| { + let mut o = JsonObject::new(); + o.insert("combinations", Json::Array(combinations)); + o.insert("document", document); + Json::Object(o) + }) + .collect(), + ), + ); + Ok(Json::Object(root)) +} diff --git a/src/mxc-sdk/tests/policy_store_catalog_validation.rs b/src/mxc-sdk/tests/policy_store_catalog_validation.rs new file mode 100644 index 000000000..e127c48f9 --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_catalog_validation.rs @@ -0,0 +1,305 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Contribution validation for the bundled catalog. These checks replace the +//! prototype's npm `validate` pipeline: +//! +//! - integrity, contract, and entry-revision history of every revision; +//! - immutability of published revisions against a base git ref, when +//! `MXC_POLICY_STORE_BASE_REF` names one (for example `origin/main`); +//! - every revision file is listed in the manifest; +//! - every entry resolves deterministically, through its own identity, for +//! every platform and architecture (every effective policy is also +//! materialized and validated by `validate_catalog_revision`); +//! - the default revision snapshot is the current assembly of the editable +//! `catalog/entries/` files (design §6.3); +//! - the rendered reviewer view and exact-request bundle in `catalog/views/` +//! are current (CI validates the bundle against the SDK target schema); +//! - every entry has a bundled-catalog conformance case, as a match or a +//! resolved dependency. +//! +//! The JSON Schemas in `schema/` document the same shape for editors. The +//! semantic validation exercised here is a superset of them. + +#[path = "policy_store_common/mod.rs"] +mod common; + +use common::{crate_dir, read_json}; +use mxc_sdk::__policy_store::tooling::{ + assemble_revision, canonical_json, collect_entry_sources, render_exact_requests, + render_reviewer_view, render_revision, validate_bundled_catalog, IdentityPredicate, Json, +}; +use mxc_sdk::__policy_store::{ + bundled_catalog_store, Architecture, FixedHost, Platform, PolicyCatalog, ResolveContext, + ToolCandidate, +}; +use std::collections::HashSet; +use std::sync::Arc; + +#[test] +fn bundled_catalog_passes_contribution_validation() { + let base_ref = std::env::var("MXC_POLICY_STORE_BASE_REF") + .ok() + .filter(|r| !r.is_empty()); + let report = validate_bundled_catalog(base_ref.as_deref()); + assert!(report.ok, "catalog validation failed: {:#?}", report.errors); + if let Some(base_ref) = base_ref { + let (reported, _) = report.base_ref.expect("base ref was checked"); + assert_eq!(reported, base_ref); + } +} + +#[test] +fn every_revision_file_is_listed_in_the_manifest() { + let store = bundled_catalog_store().unwrap(); + let listed: HashSet<&str> = store + .manifest() + .revisions + .iter() + .map(|r| r.file.as_str()) + .collect(); + for entry in std::fs::read_dir(crate_dir().join("catalog").join("revisions")).unwrap() { + let file = format!("revisions/{}", entry.unwrap().file_name().to_string_lossy()); + assert!( + listed.contains(file.as_str()), + "{file} is not listed in manifest.json" + ); + } +} + +fn context_for( + store: &mxc_sdk::__policy_store::CatalogStore, + platform: Platform, + revision: &str, +) -> ResolveContext { + let (base, sep) = match platform { + Platform::Windows => ("C:\\ci", "\\"), + _ => ("/ci", "/"), + }; + let mut ctx = ResolveContext::new() + .catalog_revision(revision) + .allow_weak(true) + .project_root(format!("{base}{sep}project")); + for (name, definition) in store.contract().symbols() { + if !matches!( + definition.source, + mxc_sdk::__policy_store::tooling::SymbolSource::Context + ) { + ctx = ctx.symbol(name, format!("{base}{sep}{name}")); + } + } + ctx +} + +#[test] +fn every_entry_resolves_deterministically_through_its_own_identity() { + let store = bundled_catalog_store().unwrap(); + let mut failures = Vec::new(); + for revision_id in store.available_revisions() { + let revision = store.revision(Some(&revision_id)).unwrap(); + for entry in &revision.entries { + let mut candidate = ToolCandidate::new("unused"); + for predicate in &entry.identity { + match predicate { + IdentityPredicate::InvocationName { names } => { + candidate.invocation_name = names[0].clone() + } + IdentityPredicate::Purl { value } => { + candidate.package_url = Some(value.clone()) + } + } + } + for platform in Platform::ALL { + for architecture in Architecture::ALL { + let catalog = PolicyCatalog::with_host( + store.clone(), + Arc::new(FixedHost::new(platform, architecture)), + ); + let ctx = context_for(&store, platform, &revision_id) + .platform(platform) + .architecture(architecture); + let label = + format!("{revision_id} {} {platform}/{architecture}", entry.entry_id); + let first = + catalog.resolve_requirements_with_diagnostics(candidate.clone(), &ctx); + let second = + catalog.resolve_requirements_with_diagnostics(candidate.clone(), &ctx); + match (first, second) { + (Ok(first), Ok(second)) => { + if first.requirements.is_none() { + failures.push(format!( + "{label} produced no requirements: {:?}", + first.diagnostics.warnings + )); + } else if first.to_json() != second.to_json() { + failures.push(format!("{label} did not resolve deterministically")); + } else if first.diagnostics.tools[0] + .selection + .as_ref() + .is_none_or(|m| m.entry_id != entry.entry_id) + { + failures + .push(format!("{label} was not matched by its own identity")); + } + } + (Err(error), _) | (_, Err(error)) => { + failures.push(format!("{label} failed: {error}")) + } + } + } + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +#[test] +fn every_entry_has_a_bundled_conformance_case() { + let mut covered = HashSet::new(); + for file in std::fs::read_dir(crate_dir().join("conformance").join("fixtures")).unwrap() { + let fixture = read_json(file.unwrap().path()); + if fixture.get("catalog").and_then(Json::as_str) != Some("bundled") { + continue; + } + for case in fixture + .get("cases") + .and_then(Json::as_array) + .into_iter() + .flatten() + { + let diagnostics = case.get("expect").and_then(|e| e.get("diagnostics")); + for dependency in diagnostics + .and_then(|d| d.get("resolvedDependencies")) + .and_then(Json::as_array) + .into_iter() + .flatten() + { + if let Some(id) = dependency.get("entryId").and_then(Json::as_str) { + covered.insert(id.to_string()); + } + } + let tools = diagnostics + .and_then(|d| d.get("tools")) + .and_then(Json::as_array); + for tool in tools.into_iter().flatten() { + for m in tool.get("selection").into_iter() { + if let Some(id) = m.get("entryId").and_then(Json::as_str) { + covered.insert(id.to_string()); + } + } + } + } + } + let store = bundled_catalog_store().unwrap(); + let missing: Vec = store + .revision(None) + .unwrap() + .entries + .iter() + .filter(|e| !covered.contains(&e.entry_id)) + .map(|e| e.entry_id.clone()) + .collect(); + assert!( + missing.is_empty(), + "entries without a bundled conformance case: {missing:?}" + ); +} + +/// The reviewer view and exact-request bundle are generated; set +/// `MXC_POLICY_STORE_UPDATE_VIEWS=1` to rewrite them after a catalog change. +#[test] +fn reviewer_views_are_current() { + let store = bundled_catalog_store().unwrap(); + let dir = crate_dir().join("catalog").join("views"); + let update = std::env::var("MXC_POLICY_STORE_UPDATE_VIEWS").is_ok_and(|v| v == "1"); + for revision_id in store.available_revisions() { + let revision = store.revision(Some(&revision_id)).unwrap(); + let rendered = render_reviewer_view(&revision, store.contract()) + .unwrap() + .replace('\n', "\r\n"); + let path = dir.join(format!("{revision_id}.md")); + let requests = render_exact_requests(&revision, store.contract()).unwrap(); + let requests_path = dir.join(format!("{revision_id}.requests.json")); + if update { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, &rendered).unwrap(); + std::fs::write(&requests_path, format!("{}\n", requests.to_pretty_string())).unwrap(); + continue; + } + let committed_requests = read_json(requests_path.clone()); + assert_eq!( + canonical_json(&committed_requests), + canonical_json(&requests), + "{} is stale; regenerate with MXC_POLICY_STORE_UPDATE_VIEWS=1", + requests_path.display() + ); + let committed = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + assert_eq!( + committed.replace("\r\n", "\n"), + rendered.replace("\r\n", "\n"), + "{} is stale; regenerate with MXC_POLICY_STORE_UPDATE_VIEWS=1", + path.display() + ); + } +} + +/// `revisions/.json` is generated from `catalog/entries/`. +/// Set `MXC_POLICY_STORE_UPDATE_REVISION=1` to rewrite it after editing an +/// entry, then `MXC_POLICY_STORE_UPDATE_VIEWS=1` for the views. Publishing a +/// change means pointing `defaultRevision` at a new revision first; the +/// `MXC_POLICY_STORE_BASE_REF` gate rejects rewriting a published snapshot. +#[test] +fn default_revision_matches_the_entry_sources() { + let catalog = crate_dir().join("catalog"); + let manifest = read_json(catalog.join("manifest.json")); + let schema_version = manifest + .get("catalogSchemaVersion") + .and_then(Json::as_str) + .unwrap(); + let default_revision = manifest + .get("defaultRevision") + .and_then(Json::as_str) + .unwrap(); + let file = manifest + .get("revisions") + .and_then(Json::as_array) + .unwrap() + .iter() + .find(|r| r.get("catalogRevision").and_then(Json::as_str) == Some(default_revision)) + .and_then(|r| r.get("file")) + .and_then(Json::as_str) + .expect("the default revision is listed in the manifest"); + let sources = collect_entry_sources(&catalog.join("entries")).unwrap(); + let assembled = assemble_revision(&sources, schema_version, default_revision) + .unwrap_or_else(|errors| panic!("catalog/entries does not assemble: {errors:#?}")); + let path = catalog.join(file); + if std::env::var("MXC_POLICY_STORE_UPDATE_REVISION").is_ok_and(|v| v == "1") { + std::fs::write(&path, render_revision(&assembled)).unwrap(); + return; + } + let committed = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + assert_eq!( + committed.replace("\r\n", "\n"), + render_revision(&assembled), + "{} is stale against catalog/entries; regenerate with MXC_POLICY_STORE_UPDATE_REVISION=1", + path.display() + ); +} + +/// Moving entry files between category directories, or renaming them, does +/// not change the assembled revision. +#[test] +fn entry_file_paths_do_not_affect_the_revision() { + let mut sources = collect_entry_sources(&crate_dir().join("catalog").join("entries")).unwrap(); + let baseline = render_revision(&assemble_revision(&sources, "1", "r").unwrap()); + sources.reverse(); + for (index, source) in sources.iter_mut().enumerate() { + source.path = format!("category-{index}/renamed-{index}.json"); + } + assert_eq!( + baseline, + render_revision(&assemble_revision(&sources, "1", "r").unwrap()) + ); +} diff --git a/src/mxc-sdk/tests/policy_store_common/mod.rs b/src/mxc-sdk/tests/policy_store_common/mod.rs new file mode 100644 index 000000000..e3484538b --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_common/mod.rs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared helpers for the integration tests. Tests read the crate's +//! `conformance/` and `catalog/` through `CARGO_MANIFEST_DIR`. + +#![allow(dead_code)] + +use mxc_sdk::__policy_store::tooling::{Json, JsonObject}; +use mxc_sdk::__policy_store::{ + Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, + PolicyCatalogError, ResolveContext, ToolInputs, +}; +use std::path::PathBuf; +use std::sync::Arc; + +pub fn crate_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("policy_store") +} + +pub fn read_json(path: PathBuf) -> Json { + let text = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + Json::parse(&text).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +/// The contract shipped with the embedded catalog. +pub fn contract() -> Json { + let files = mxc_sdk::__policy_store::tooling::bundled_catalog_files(); + let text = files + .iter() + .find(|(name, _)| *name == "contract.v1.json") + .unwrap() + .1; + Json::parse(text).unwrap() +} + +pub fn fixed_host(platform: Platform, architecture: Architecture) -> Arc { + Arc::new(FixedHost::new(platform, architecture)) +} + +pub fn linux_x64() -> Arc { + fixed_host(Platform::Linux, Architecture::X64) +} + +pub fn store_for(revisions: &[Json]) -> Result { + store_with(revisions, None) +} + +pub fn store_with( + revisions: &[Json], + default_revision: Option<&str>, +) -> Result { + CatalogStore::new(MemorySource::publishing( + contract(), + revisions, + default_revision, + )) +} + +pub fn catalog_for(revision: Json, host: Arc) -> PolicyCatalog { + PolicyCatalog::with_host(Arc::new(store_for(&[revision]).unwrap()), host) +} + +pub fn bundled_catalog(host: Arc) -> PolicyCatalog { + PolicyCatalog::with_host( + mxc_sdk::__policy_store::bundled_catalog_store().unwrap(), + host, + ) +} + +pub fn j(text: &str) -> Json { + Json::parse(text).unwrap_or_else(|e| panic!("{e}: {text}")) +} + +pub fn revision_with(entries: Vec, id: &str) -> Json { + let mut o = JsonObject::new(); + o.insert("catalogSchemaVersion", "1".into()); + o.insert("catalogRevision", id.into()); + o.insert("sdkContractVersion", "1.0.0".into()); + o.insert("entries", Json::Array(entries)); + Json::Object(o) +} + +pub fn revision(entries: Vec) -> Json { + revision_with(entries, "2000-01-01.1") +} + +/// Minimal valid entry (semver scheme, default read-write `${project_root}`) +/// with top-level overrides. +pub fn entry(entry_id: &str, overrides: &str) -> Json { + let name = entry_id.split(':').nth(1).unwrap(); + let mut base = j(&format!( + r#"{{"entryId":"{entry_id}","entryRevision":1,"displayName":"{name}","versionScheme":"semver", + "identity":[{{"kind":"invocation-name","names":["{name}"]}}], + "default":{{"requirements":{{"filesystem":{{"readwritePaths":["${{project_root}}"]}}}}}}, + "provenance":{{"method":"test","sourceRevision":"test"}}}}"# + )); + if !overrides.is_empty() { + if let (Json::Object(target), Json::Object(extra)) = (&mut base, j(overrides)) { + for (k, v) in extra.iter() { + target.insert(k, v.clone()); + } + } + } + base +} + +pub fn reason_of(result: Result) -> ErrorReason { + let error = result.expect_err("expected a PolicyCatalogError"); + assert_eq!(error.code(), error.reason().code()); + assert!(error + .message() + .starts_with(&format!("[{}] ", error.code().as_str()))); + error.reason() +} + +/// Fixture `tools` (single input or array), through the binding parser. +pub fn tools_from(value: &Json) -> ToolInputs { + mxc_sdk::__policy_store::request::parse_tool_inputs(value).expect("fixture tools parse") +} + +/// Fixture `context`, through the binding parser. +pub fn context_from(value: Option<&Json>) -> ResolveContext { + mxc_sdk::__policy_store::request::parse_resolve_context(value).expect("fixture context parses") +} diff --git a/src/mxc-sdk/tests/policy_store_conformance.rs b/src/mxc-sdk/tests/policy_store_conformance.rs new file mode 100644 index 000000000..cc27f6ab5 --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_conformance.rs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Runs every language-neutral conformance fixture in +//! `../conformance/fixtures/*.json`. The mxc-sdk and mxc_ffi suites replay +//! the same fixtures through their binding entry points. +//! +//! `MXC_POLICY_STORE_UPDATE_FIXTURES=` records each case's actual +//! outcome as `/` (one compact JSON array) instead of +//! asserting, so a catalog or resolver change can be reviewed and merged into +//! the fixtures by hand. + +#[path = "policy_store_common/mod.rs"] +mod common; + +use common::*; +use mxc_sdk::__policy_store::tooling::{Json, JsonObject}; +use mxc_sdk::__policy_store::{Architecture, FixedHost, Platform, PolicyCatalogError}; +use std::sync::Arc; + +/// The fixed host a case describes: symbols, discovery results, object +/// aliases, and paths whose identity cannot be established. +fn host_from( + host: Option<&Json>, + platform: Platform, + architecture: Architecture, +) -> Arc { + let mut fixed = FixedHost::new(platform, architecture); + let Some(host) = host else { + return Arc::new(fixed); + }; + let pairs = |key: &str| -> Vec<(String, String)> { + host.get(key) + .and_then(Json::as_object) + .map(|o| { + o.iter() + .map(|(k, v)| (k.to_string(), v.as_str().unwrap().to_string())) + .collect() + }) + .unwrap_or_default() + }; + for (name, value) in pairs("symbols") { + fixed = fixed.with_symbol(name, value); + } + for (name, value) in pairs("discovered") { + fixed = fixed.with_discovered(name, value); + } + for alias in host + .get("aliases") + .and_then(Json::as_array) + .into_iter() + .flatten() + { + let alias = alias.as_array().unwrap(); + fixed = fixed.with_alias(alias[0].as_str().unwrap(), alias[1].as_str().unwrap()); + } + for path in host + .get("unknownPaths") + .and_then(Json::as_array) + .into_iter() + .flatten() + { + fixed = fixed.with_unknown_path(path.as_str().unwrap()); + } + Arc::new(fixed) +} + +fn failure(result: Result) -> Option<(String, String)> { + result.err().map(|e| { + ( + e.code().as_str().to_string(), + e.reason().as_str().to_string(), + ) + }) +} + +#[test] +fn all_conformance_fixtures() { + let dir = crate_dir().join("conformance").join("fixtures"); + let mut names: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".json")) + .collect(); + names.sort(); + assert!(names.len() >= 2, "fixtures present"); + let update_dir = std::env::var("MXC_POLICY_STORE_UPDATE_FIXTURES") + .ok() + .filter(|d| !d.is_empty()); + let mut cases = 0; + for name in names { + let fixture = read_json(dir.join(&name)); + let mut recorded = Vec::new(); + for case in fixture.get("cases").and_then(Json::as_array).unwrap() { + let case_name = format!( + "{name}: {}", + case.get("name").and_then(Json::as_str).unwrap() + ); + // Object identity is examined only on the target's own host, so + // the fixed host defaults to the requested platform. + let host_platform = case + .get("host") + .and_then(|h| h.get("platform")) + .or_else(|| case.get("context").and_then(|c| c.get("platform"))) + .and_then(Json::as_str) + .map_or(Platform::Linux, |p| Platform::parse(p).unwrap()); + let host_arch = case + .get("host") + .and_then(|h| h.get("nativeArchitecture")) + .and_then(Json::as_str) + .map_or(Architecture::X64, |a| Architecture::parse(a).unwrap()); + let host = host_from(case.get("host"), host_platform, host_arch); + let catalog = match fixture.get("catalog") { + Some(Json::String(s)) if s == "bundled" => bundled_catalog(host), + Some(revision) => catalog_for(revision.clone(), host), + None => panic!("fixture without catalog"), + }; + let raw_tools = case.get("tools").unwrap(); + let tools = tools_from(raw_tools); + let ctx = context_from(case.get("context")); + if update_dir.is_some() { + let mut outcome = JsonObject::new(); + outcome.insert("name", case.get("name").unwrap().clone()); + match catalog.resolve_requirements_with_diagnostics(tools, &ctx) { + Ok(result) => outcome.insert("expect", result.to_json()), + Err(error) => { + let mut e = JsonObject::new(); + e.insert("code", error.code().as_str().into()); + e.insert("reason", error.reason().as_str().into()); + outcome.insert("expectError", Json::Object(e)); + } + } + recorded.push(Json::Object(outcome)); + continue; + } + if let Some(expect_error) = case.get("expectError") { + let expected = Some(( + expect_error + .get("code") + .and_then(Json::as_str) + .unwrap() + .to_string(), + expect_error + .get("reason") + .and_then(Json::as_str) + .unwrap() + .to_string(), + )); + assert_eq!( + failure(catalog.resolve_requirements_with_diagnostics(tools.clone(), &ctx)), + expected, + "{case_name}" + ); + assert_eq!( + failure(catalog.resolve_requirements(tools, &ctx)), + expected, + "{case_name}" + ); + cases += 1; + continue; + } + let expect = case.get("expect").unwrap(); + let mut expected = JsonObject::new(); + let expected_requirements = + expect.get("requirements").filter(|p| !p.is_null()).cloned(); + if let Some(requirements) = &expected_requirements { + expected.insert("requirements", requirements.clone()); + } + expected.insert("diagnostics", expect.get("diagnostics").unwrap().clone()); + let expected = Json::Object(expected); + + let actual = catalog + .resolve_requirements_with_diagnostics(tools.clone(), &ctx) + .unwrap(); + assert_eq!( + actual.to_json(), + expected, + "{case_name}\nactual: {}", + actual.to_json() + ); + let requirements = catalog.resolve_requirements(tools.clone(), &ctx).unwrap(); + assert_eq!( + requirements.map(|p| p.to_json()), + expected_requirements, + "{case_name}" + ); + if !matches!(raw_tools, Json::Array(_)) { + let as_array = tools_from(&Json::Array(vec![raw_tools.clone()])); + let again = catalog + .resolve_requirements_with_diagnostics(as_array, &ctx) + .unwrap(); + assert_eq!( + again, actual, + "{case_name}: single input == one-element array" + ); + } + cases += 1; + } + if let Some(update_dir) = &update_dir { + std::fs::create_dir_all(update_dir).unwrap(); + std::fs::write( + std::path::Path::new(update_dir).join(&name), + Json::Array(recorded).to_compact_string(), + ) + .unwrap(); + } + } + if update_dir.is_some() { + return; + } + assert!(cases >= 20, "ran {cases} conformance cases"); +} diff --git a/src/mxc-sdk/tests/policy_store_library.rs b/src/mxc-sdk/tests/policy_store_library.rs new file mode 100644 index 000000000..d376a8dd0 --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_library.rs @@ -0,0 +1,2011 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resolver, validation, and store suites for the entry model in the Policy +//! Store design: one unversioned default per entry, additive platform and +//! version overlays, and intent selection. + +#[path = "policy_store_common/mod.rs"] +mod common; + +use common::*; +use mxc_sdk::__policy_store::tooling::{ + canonical_json, check_entry_revisions, check_published_immutability, check_store_history, + validate_catalog_revision, validate_contract, Json, PublishedRevision, PublishedState, +}; +use mxc_sdk::__policy_store::{ + get_catalog_info, list_catalog_entries, resolve_requirements, + resolve_requirements_with_diagnostics, Architecture, ArchitectureFallback, DetailWarningKind, + ErrorReason, ExistingObjectComparison, FixedHost, HostEnvironment, IntentMode, Platform, + PolicyCatalog, PolicyCatalogError, PurlComponent, RequirementsResolution, ResolveContext, + SymbolValueSource, ToolCandidate, ToolInput, ToolInputs, ToolResolutionStatus, ToolWarningKind, + VersionStatus, Warning, +}; +use std::collections::HashMap; +use std::sync::Arc; + +fn weak() -> ResolveContext { + ResolveContext::new().allow_weak(true) +} + +fn validate(entries: Vec) -> Result<(), PolicyCatalogError> { + let contract = validate_contract(&contract()).unwrap(); + validate_catalog_revision(&revision(entries), &contract).map(|_| ()) +} + +fn validate_err(entries: Vec, needle: &str) { + let error = validate(entries).expect_err(needle); + assert_eq!(error.reason(), ErrorReason::InvalidCatalog); + assert!( + error.message().contains(needle), + "{} does not contain {needle}", + error.message() + ); +} + +/// `{"default":{"requirements":}}` as entry overrides. +fn with_default(policy: &str, extra: &str) -> String { + format!(r#"{{"default":{{"requirements":{policy}}}{extra}}}"#) +} + +fn fs_json(policy: &Option) -> Json { + policy + .as_ref() + .expect("a policy") + .filesystem + .as_ref() + .expect("a filesystem section") + .to_json() +} + +fn messages(warnings: &[Warning]) -> String { + warnings + .iter() + .map(Warning::message) + .collect::>() + .join("\n") +} + +fn egress(result: &RequirementsResolution) -> Option { + result + .requirements + .as_ref() + .and_then(|p| p.network.clone()) + .and_then(|n| n.get("egress").and_then(|e| e.get("allow")).cloned()) +} + +fn tcp(cidr: &str, port: u16) -> String { + format!(r#"{{"to":[{{"cidr":"{cidr}"}}],"ports":[{{"protocol":"tcp","port":{port}}}]}}"#) +} + +// --------------------------------------------------------------------------- +// Platform and architecture selection +// --------------------------------------------------------------------------- + +fn arch_catalog(host: Arc) -> PolicyCatalog { + catalog_for( + revision(vec![entry( + "tool:a", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/a"},{"kind":"invocation-name","names":["a"]}], + "platformVariants":[ + {"when":{"platform":"windows","architecture":"x64"},"policyAdditions":{"filesystem":{"readonlyPaths":["${git_prefix}/x64"]}}}, + {"when":{"platform":"windows","architecture":"arm64"},"policyAdditions":{"filesystem":{"readonlyPaths":["${git_prefix}/arm64"]}}}]}"#, + )]), + host, + ) +} + +#[test] +fn omitted_architecture_uses_the_native_system_architecture() { + let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); + let ctx = ResolveContext::new() + .project_root("C:\\p") + .symbol("git_prefix", "C:\\g"); + assert_eq!(catalog.resolve_requirements("a", &ctx).unwrap(), None); + let result = catalog + .resolve_requirements_with_diagnostics( + ToolCandidate::new("a").with_package_url("pkg:npm/a"), + &ctx, + ) + .unwrap(); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readonlyPaths":["C:\\g\\arm64"],"readwritePaths":["C:\\p"]}"#) + ); + assert_eq!(result.diagnostics.catalog_revision, "2000-01-01.1"); + assert!(messages(&result.diagnostics.warnings) + .contains("native system architecture 'arm64'; the tool's architecture was not verified")); +} + +#[test] +fn explicit_architecture_wins_and_suppresses_the_host_default_warning() { + let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); + let result = catalog + .resolve_requirements_with_diagnostics( + ToolCandidate::new("a").with_package_url("pkg:npm/a"), + &ResolveContext::new() + .architecture(Architecture::X64) + .project_root("C:\\p") + .symbol("git_prefix", "C:\\g"), + ) + .unwrap(); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readonlyPaths":["C:\\g\\x64"],"readwritePaths":["C:\\p"]}"#) + ); + // Only the caller-supplied symbols are reported; no architecture warning. + assert!(result + .diagnostics + .warnings + .iter() + .all(|w| w.code() == "symbol_resolved")); +} + +#[test] +fn another_architecture_is_never_used_and_neutral_additions_are_reported() { + let catalog = catalog_for( + revision(vec![ + entry( + "tool:a", + r#"{"platformVariants":[{"when":{"platform":"linux","architecture":"arm64"},"policyAdditions":{"filesystem":{"readonlyPaths":["${git_prefix}"]}}}]}"#, + ), + entry( + "tool:b", + r#"{"platformVariants":[ + {"when":{"platform":"linux"},"policyAdditions":{"filesystem":{"readonlyPaths":["${node_prefix}"]}}}, + {"when":{"platform":"linux","architecture":"arm64"},"policyAdditions":{"filesystem":{"readonlyPaths":["${git_prefix}"]}}}]}"#, + ), + ]), + linux_x64(), + ); + let ctx = weak() + .architecture(Architecture::X64) + .project_root("/p") + .symbol("node_prefix", "/n"); + let a = catalog + .resolve_requirements_with_diagnostics("a", &ctx) + .unwrap(); + assert_eq!(fs_json(&a.requirements), j(r#"{"readwritePaths":["/p"]}"#)); + let fallback = |result: &RequirementsResolution| -> Vec { + result + .diagnostics + .warnings + .iter() + .filter_map(|w| match w { + Warning::Detail(d) if w.code() != "symbol_resolved" => Some(d.kind.clone()), + _ => None, + }) + .collect() + }; + assert_eq!( + fallback(&a), + [DetailWarningKind::ArchitectureFallback { + platform: Platform::Linux, + architecture: Architecture::X64, + selected: ArchitectureFallback::Default + }] + ); + let b = catalog + .resolve_requirements_with_diagnostics("b", &ctx) + .unwrap(); + assert_eq!( + fs_json(&b.requirements), + j(r#"{"readonlyPaths":["/n"],"readwritePaths":["/p"]}"#) + ); + assert_eq!( + fallback(&b), + [DetailWarningKind::ArchitectureFallback { + platform: Platform::Linux, + architecture: Architecture::X64, + selected: ArchitectureFallback::Platform + }] + ); + assert!(messages(&b.diagnostics.warnings).contains( + "tool:b has no x64-specific linux overlay; its architecture-neutral platform data was used" + )); +} + +struct FailingHost; +impl HostEnvironment for FailingHost { + fn platform(&self) -> Result { + Ok(Platform::Linux) + } + fn native_architecture(&self) -> Result { + Err(PolicyCatalogError::new( + ErrorReason::UnsupportedHost, + "unknown machine", + )) + } + fn symbol(&self, _: &str) -> Option { + None + } + fn discover(&self, _: &str) -> Result, PolicyCatalogError> { + Ok(None) + } + fn compare_objects(&self, _: &str, _: &str) -> ExistingObjectComparison { + ExistingObjectComparison::Different + } +} + +#[test] +fn host_architecture_is_detected_only_when_an_overlay_needs_it() { + let store = Arc::new( + store_for(&[revision(vec![ + entry("tool:t", ""), + entry( + "tool:u", + r#"{"platformVariants":[{"when":{"platform":"linux","architecture":"x64"}}]}"#, + ), + ])]) + .unwrap(), + ); + let catalog = PolicyCatalog::with_host(store, Arc::new(FailingHost)); + let error = catalog + .resolve_requirements("u", &weak().project_root("/p")) + .unwrap_err(); + assert!(error.message().contains("unknown machine")); + assert_eq!(error.reason(), ErrorReason::UnsupportedHost); + let result = catalog + .resolve_requirements_with_diagnostics("t", &weak().project_root("/p")) + .unwrap(); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readwritePaths":["/p"]}"#) + ); + assert!(!messages(&result.diagnostics.warnings).contains("architecture")); + assert_eq!( + catalog.resolve_requirements("nothing", &weak()).unwrap(), + None + ); + assert!(catalog + .resolve_requirements( + "u", + &weak().architecture(Architecture::X64).project_root("/p") + ) + .unwrap() + .is_some()); +} + +// --------------------------------------------------------------------------- +// Symbols +// --------------------------------------------------------------------------- + +#[test] +fn never_fabricates_project_root_or_caller_symbols() { + let result = bundled_catalog(linux_x64()) + .resolve_requirements_with_diagnostics("git", &weak().architecture(Architecture::X64)) + .unwrap(); + assert_eq!(result.requirements, None); + // No output means no input contributes, even though one was selected. + assert!(!result.diagnostics.tools[0].contributes); + assert!(result.diagnostics.tools[0].selection.is_some()); + let warnings = messages(&result.diagnostics.warnings); + let a = warnings.find("required symbol 'git_prefix'").unwrap(); + let b = warnings.find("required symbol 'project_root'").unwrap(); + assert!(a < b); +} + +#[test] +fn project_root_binds_through_either_context_form() { + let catalog = catalog_for(revision(vec![entry("tool:t", "")]), linux_x64()); + for ctx in [ + weak().project_root("/p"), + weak().symbol("project_root", "/p"), + weak().project_root("/p").symbol("project_root", "/p"), + ] { + let result = catalog + .resolve_requirements_with_diagnostics("t", &ctx) + .unwrap(); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readwritePaths":["/p"]}"#), + "{ctx:?}" + ); + let resolved = result + .diagnostics + .warnings + .iter() + .find(|w| w.code() == "symbol_resolved") + .expect("symbol_resolved"); + assert!( + resolved.to_json().get("source") == Some(&Json::String("caller".into())), + "{ctx:?}" + ); + assert!(result.diagnostics.tools[0].contributes); + } +} + +#[test] +fn host_symbols_only_for_the_current_host_platform_and_caller_overrides_win() { + let rev = revision(vec![entry( + "tool:t", + &with_default( + r#"{"filesystem":{"readonlyPaths":["${user_home}/.cfg"]}}"#, + "", + ), + )]); + let host = Arc::new( + FixedHost::new(Platform::Linux, Architecture::X64).with_symbol("user_home", "/home/me"), + ); + let catalog = catalog_for(rev, host); + let derived = catalog + .resolve_requirements_with_diagnostics("t", &weak()) + .unwrap(); + assert_eq!( + fs_json(&derived.requirements), + j(r#"{"readonlyPaths":["/home/me/.cfg"]}"#) + ); + assert!(messages(&derived.diagnostics.warnings) + .contains("symbol 'user_home' resolved to '/home/me' from the host environment")); + let Some(Warning::Detail(resolved)) = derived.diagnostics.warnings.last() else { + panic!("a symbol_resolved detail warning"); + }; + assert_eq!( + resolved.kind, + DetailWarningKind::SymbolResolved { + symbol: "user_home".into(), + value: "/home/me".into(), + source: SymbolValueSource::Host + } + ); + assert_eq!(resolved.input_indexes, [0]); + assert_eq!(resolved.entry_ids, ["tool:t"]); + assert_eq!( + catalog + .resolve_requirements( + "t", + &weak() + .platform(Platform::Macos) + .architecture(Architecture::Arm64) + ) + .unwrap(), + None + ); + let overridden = catalog + .resolve_requirements_with_diagnostics("t", &weak().symbol("user_home", "/srv/u")) + .unwrap(); + assert_eq!( + fs_json(&overridden.requirements), + j(r#"{"readonlyPaths":["/srv/u/.cfg"]}"#) + ); + assert!(!messages(&overridden.diagnostics.warnings).contains("host environment")); +} + +#[test] +fn windows_overlay_adds_program_data_git() { + let ctx = ResolveContext::new() + .platform(Platform::Windows) + .architecture(Architecture::X64) + .project_root("C:\\p") + .symbol("git_prefix", "C:\\Program Files\\Git") + .symbol("programData", "C:\\ProgramData"); + // Object identity is examined on the target's own host. + let policy = bundled_catalog(fixed_host(Platform::Windows, Architecture::X64)) + .resolve_requirements( + ToolCandidate::new("git") + .with_package_url("pkg:generic/git") + .with_intent("local"), + &ctx, + ) + .unwrap(); + assert_eq!( + fs_json(&policy), + j( + r#"{"readonlyPaths":["C:\\Program Files\\Git","C:\\ProgramData\\Git"],"readwritePaths":["C:\\p"]}"# + ) + ); +} + +// --------------------------------------------------------------------------- +// Inputs and failures +// --------------------------------------------------------------------------- + +fn git_ctx() -> ResolveContext { + weak() + .architecture(Architecture::X64) + .project_root("/p") + .symbol("git_prefix", "/g") +} + +#[test] +fn string_object_and_one_element_array_are_equivalent() { + let catalog = bundled_catalog(linux_x64()); + let ctx = git_ctx(); + let a = catalog + .resolve_requirements_with_diagnostics("git", &ctx) + .unwrap(); + assert_eq!( + catalog + .resolve_requirements_with_diagnostics(ToolCandidate::new("git"), &ctx) + .unwrap(), + a + ); + assert_eq!( + catalog + .resolve_requirements_with_diagnostics(vec!["git"], &ctx) + .unwrap(), + a + ); + assert_eq!(a.diagnostics.tools[0].input_index, 0); + let strict = ResolveContext { + allow_weak_identity_fallback: false, + ..ctx + }; + assert_eq!(catalog.resolve_requirements("git", &strict).unwrap(), None); + assert_eq!( + catalog + .resolve_requirements(ToolCandidate::new("git").with_intent("fetch"), &strict) + .unwrap(), + None + ); +} + +#[test] +fn invalid_context_and_inputs_are_failures_not_absence() { + let catalog = bundled_catalog(linux_x64()); + let cases: Vec<(ToolInputs, ResolveContext)> = vec![ + ("".into(), ResolveContext::new()), + ("/usr/bin/git".into(), ResolveContext::new()), + ( + ToolCandidate::new("npm").with_detected_version("").into(), + ResolveContext::new(), + ), + ( + ToolCandidate::new("npm").with_package_url("").into(), + ResolveContext::new(), + ), + ( + ToolCandidate::new("git").with_intent("").into(), + ResolveContext::new(), + ), + ( + "git".into(), + ResolveContext { + platform: Some("plan9".into()), + ..Default::default() + }, + ), + ( + "git".into(), + ResolveContext { + architecture: Some("x86".into()), + ..Default::default() + }, + ), + ("git".into(), ResolveContext::new().symbol("nope", "/x")), + ( + "git".into(), + ResolveContext::new() + .project_root("/x") + .symbol("project_root", "/y"), + ), + ( + Vec::::new().into(), + ResolveContext::new() + .project_root("/x") + .symbol("project_root", "/x/"), + ), + ( + "git".into(), + ResolveContext::new().symbol("project_root", ""), + ), + ( + "git".into(), + ResolveContext::new().symbol("__proto__", "/x"), + ), + ("git".into(), ResolveContext::new().project_root("")), + ]; + for (tools, ctx) in cases { + assert_eq!( + reason_of(catalog.resolve_requirements(tools.clone(), &ctx)), + ErrorReason::InvalidContext, + "{tools:?} {ctx:?}" + ); + } + // An invalid candidate package URL is a per-pair outcome, not an error. + let invalid = catalog + .resolve_requirements_with_diagnostics( + ToolCandidate::new("npm").with_package_url("npm"), + &weak(), + ) + .unwrap(); + assert_eq!( + invalid.diagnostics.tools[0].status, + ToolResolutionStatus::ToolUnmatched + ); + assert_eq!(invalid.diagnostics.warnings[0].code(), "purl_invalid"); + for value in ["bin", "/x/${node_prefix}"] { + let ctx = weak() + .architecture(Architecture::X64) + .symbol("node_prefix", value); + let error = catalog.resolve_requirements("node", &ctx).unwrap_err(); + assert!( + error.message().starts_with( + "[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path" + ), + "{}", + error.message() + ); + } +} + +#[test] +fn results_are_caller_owned_and_deterministic() { + let catalog = bundled_catalog(linux_x64()); + let ctx = ResolveContext::new() + .architecture(Architecture::X64) + .project_root("/p") + .symbol("npm_prefix", "/n") + .symbol("npm_cache", "/c") + .symbol("node_prefix", "/node"); + let tool = ToolCandidate::new("npm").with_package_url("pkg:npm/npm"); + let mut first = catalog + .resolve_requirements_with_diagnostics(tool.clone(), &ctx) + .unwrap(); + first + .requirements + .as_mut() + .unwrap() + .filesystem + .as_mut() + .unwrap() + .readwrite_paths + .as_mut() + .unwrap() + .push("/mutated".into()); + first.diagnostics.warnings.clear(); + let second = catalog + .resolve_requirements_with_diagnostics(tool, &ctx) + .unwrap(); + assert_eq!( + fs_json(&second.requirements), + j(r#"{"readonlyPaths":["/n","/node"],"readwritePaths":["/p","/c"]}"#) + ); + assert_eq!( + second.diagnostics.warnings, + catalog + .resolve_requirements_with_diagnostics( + ToolCandidate::new("npm").with_package_url("pkg:npm/npm"), + &ctx + ) + .unwrap() + .diagnostics + .warnings + ); +} + +#[test] +fn invocation_name_casing_per_platform() { + let catalog = catalog_for(revision(vec![entry("tool:gh", "")]), linux_x64()); + let on = |platform: Platform, name: &str| -> ToolResolutionStatus { + catalog + .resolve_requirements_with_diagnostics( + name, + &weak() + .platform(platform) + .architecture(Architecture::X64) + .project_root(if platform == Platform::Windows { + "C:\\p" + } else { + "/p" + }), + ) + .unwrap() + .diagnostics + .tools[0] + .status + }; + let matched = ToolResolutionStatus::Version(VersionStatus::MatchedDefault); + assert_eq!(on(Platform::Linux, "gh"), matched); + assert_eq!( + on(Platform::Linux, "GH"), + ToolResolutionStatus::ToolUnmatched + ); + assert_eq!(on(Platform::Macos, "GH"), matched); + assert_eq!(on(Platform::Windows, "Gh"), matched); +} + +// --------------------------------------------------------------------------- +// Matching +// --------------------------------------------------------------------------- + +#[test] +fn package_identity_then_intent_then_architecture_and_ties_are_ambiguous() { + let named = |id: &str, extra: &str| { + entry( + id, + &format!(r#"{{"identity":[{{"kind":"invocation-name","names":["x"]}}]{extra}}}"#), + ) + }; + let entries = vec![ + named("tool:a", ""), + named( + "tool:b", + r#","default":{"requirements":{},"intents":{"build":{}}}"#, + ), + entry( + "tool:c", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"},{"kind":"invocation-name","names":["x"]}],"default":{"requirements":{}}}"#, + ), + ]; + let mut reversed = entries.clone(); + reversed.reverse(); + for list in [entries, reversed] { + let catalog = catalog_for(revision(list), linux_x64()); + let ctx = weak().project_root("/p"); + let chosen = |tool: ToolCandidate| { + catalog + .resolve_requirements_with_diagnostics(tool, &ctx) + .unwrap() + .diagnostics + .tools[0] + .selection + .as_ref() + .unwrap() + .entry_id + .clone() + }; + assert_eq!( + chosen(ToolCandidate::new("x").with_package_url("pkg:npm/x")), + "tool:c" + ); + assert_eq!( + chosen(ToolCandidate::new("x").with_intent("build")), + "tool:b" + ); + let error = catalog.resolve_requirements("x", &ctx).unwrap_err(); + assert_eq!(error.reason(), ErrorReason::AmbiguousMatch); + assert_eq!( + error.message(), + "[policy_validation] input 0 ('x') matches 3 entries with equal rank (tool:a, tool:b, tool:c); no entry was selected" + ); + } +} + +#[test] +fn exact_architecture_outranks_neutral_and_default() { + let catalog = catalog_for( + revision(vec![ + entry( + "tool:a", + r#"{"identity":[{"kind":"invocation-name","names":["x"]}],"platformVariants":[{"when":{"platform":"linux"}}]}"#, + ), + entry( + "tool:b", + r#"{"identity":[{"kind":"invocation-name","names":["x"]}],"platformVariants":[{"when":{"platform":"linux","architecture":"x64"}}]}"#, + ), + ]), + linux_x64(), + ); + let result = catalog + .resolve_requirements_with_diagnostics("x", &weak().project_root("/p")) + .unwrap(); + assert_eq!( + result.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() + .entry_id, + "tool:b" + ); +} + +#[test] +fn package_url_versions_are_ignored_with_a_warning() { + let result = bundled_catalog(linux_x64()) + .resolve_requirements_with_diagnostics( + ToolCandidate::new("git").with_package_url("pkg:generic/git@2.45.0"), + &git_ctx(), + ) + .unwrap(); + let tool = &result.diagnostics.tools[0]; + assert_eq!( + tool.status, + ToolResolutionStatus::Version(VersionStatus::MatchedDefault) + ); + let Warning::Tool(warning) = &result.diagnostics.warnings[0] else { + panic!("a per-input warning"); + }; + assert_eq!( + warning.kind, + ToolWarningKind::PurlComponentsIgnored { + package_url: "pkg:generic/git@2.45.0".into(), + ignored_components: vec![PurlComponent::Version] + } + ); +} + +// --------------------------------------------------------------------------- +// Version and intent selection +// --------------------------------------------------------------------------- + +fn git(version: Option<&str>, intent: Option<&str>) -> ToolCandidate { + let mut tool = ToolCandidate::new("git").with_package_url("pkg:generic/git"); + tool.detected_version = version.map(str::to_string); + tool.intent = intent.map(str::to_string); + tool +} + +fn resolve_git(tools: impl Into) -> RequirementsResolution { + bundled_catalog(linux_x64()) + .resolve_requirements_with_diagnostics( + tools, + &git_ctx() + .symbol("ssh_prefix", "/ssh") + .symbol("temp_dir", "/tmp"), + ) + .unwrap() +} + +#[test] +fn version_selection_statuses() { + let none = resolve_git(git(None, Some("push"))); + let record = &none.diagnostics.tools[0].selection.as_ref().unwrap(); + assert_eq!( + none.diagnostics.tools[0].status, + ToolResolutionStatus::Version(VersionStatus::MatchedDefault) + ); + assert_eq!(record.version_selection.detected_version, None); + assert!(none.diagnostics.resolved_dependencies.is_empty()); + + let inside = resolve_git(git(Some("2.45.1"), Some("push"))); + let record = &inside.diagnostics.tools[0].selection.as_ref().unwrap(); + assert_eq!( + record.version_selection.status, + VersionStatus::MatchedVersion + ); + assert_eq!( + record.version_selection.selected_version_range.as_deref(), + Some("vers:intdot/>=2.40|<2.50") + ); + assert_eq!( + inside.diagnostics.resolved_dependencies[0].entry_id, + "tool:ssh" + ); + assert_eq!( + fs_json(&inside.requirements), + j(r#"{"readonlyPaths":["/g","/ssh"],"readwritePaths":["/p"]}"#) + ); + assert_eq!( + egress(&inside), + Some(j(&format!("[{}]", tcp("192.0.2.10/32", 22)))) + ); + + let outside = resolve_git(git(Some("2.30.0"), Some("push"))); + assert_eq!( + outside.diagnostics.tools[0].status, + ToolResolutionStatus::Version(VersionStatus::VersionOutOfRange) + ); + assert_eq!(outside.requirements, none.requirements); + let Warning::Tool(warning) = &outside.diagnostics.warnings[0] else { + panic!("structured warning expected"); + }; + assert_eq!( + warning.kind, + ToolWarningKind::VersionOutOfRange { + entry_id: "tool:git".into(), + detected_version: "2.30.0".into() + } + ); + assert_eq!(warning.input_index, 0); + + let bad = resolve_git(git(Some("banana"), Some("push"))); + assert_eq!(bad.requirements, None); + assert_eq!( + bad.diagnostics.tools[0].status, + ToolResolutionStatus::Version(VersionStatus::VersionUnparseable) + ); + assert_eq!( + bad.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() + .intent_selection, + None + ); +} + +#[test] +fn intent_selection_and_unsupported_intents() { + let all = resolve_git(git(Some("2.55"), None)); + let selection = all.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() + .intent_selection + .clone() + .unwrap(); + assert_eq!(selection.mode, IntentMode::All); + assert_eq!( + selection.selected, + ["bundle-fetch", "fetch", "local", "push"] + ); + assert_eq!( + fs_json(&all.requirements), + j(r#"{"readonlyPaths":["/g"],"readwritePaths":["/p","/tmp/git-bundles"]}"#) + ); + assert_eq!( + egress(&all), + Some(j(&format!( + "[{},{},{}]", + tcp("198.51.100.20/32", 443), + tcp("192.0.2.10/32", 443), + tcp("192.0.2.10/32", 22) + ))) + ); + + let local = resolve_git(git(None, Some("local"))); + assert_eq!(local.requirements.as_ref().unwrap().network, None); + + let unsupported = resolve_git(git(Some("2.45"), Some("bundle-fetch"))); + let tool = &unsupported.diagnostics.tools[0]; + assert_eq!(tool.status, ToolResolutionStatus::IntentUnsupported); + assert_eq!( + tool.selection.as_ref().unwrap().version_selection.status, + VersionStatus::MatchedVersion + ); + assert_eq!( + tool.selection + .as_ref() + .unwrap() + .intent_selection + .as_ref() + .unwrap() + .mode, + IntentMode::Unsupported + ); + assert_eq!(unsupported.requirements, None); + + let both = resolve_git(git(Some("2.30"), Some("bundle-fetch"))); + let tool = &both.diagnostics.tools[0]; + assert_eq!(tool.status, ToolResolutionStatus::IntentUnsupported); + assert_eq!( + tool.selection.as_ref().unwrap().version_selection.status, + VersionStatus::VersionOutOfRange + ); + let codes: Vec<&str> = both + .diagnostics + .warnings + .iter() + .filter_map(|w| match w { + Warning::Tool(t) => Some(t.kind.code()), + Warning::Detail(_) => None, + }) + .collect(); + assert_eq!(codes, ["version_out_of_range", "intent_unsupported"]); +} + +#[test] +fn pairs_compose_and_a_tool_without_network_does_not_veto_another() { + let result = resolve_git(vec![ + ToolInput::from(git(None, Some("local"))), + ToolInput::from(git(None, Some("fetch"))), + ToolInput::from(git(None, Some("push"))), + ToolInput::from(git(None, Some("fetch"))), + ToolInput::from("definitely-unknown"), + ]); + assert_eq!( + egress(&result), + Some(j(&format!( + "[{},{}]", + tcp("192.0.2.10/32", 443), + tcp("192.0.2.10/32", 22) + ))) + ); + // Duplicate inputs both contribute; the unmatched one does not. + assert_eq!( + result + .diagnostics + .tools + .iter() + .map(|t| t.contributes) + .collect::>(), + [true, true, true, true, false] + ); + assert_eq!( + result.diagnostics.tools[4].status, + ToolResolutionStatus::ToolUnmatched + ); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readonlyPaths":["/g"],"readwritePaths":["/p"]}"#) + ); +} + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +fn paths_entry(id: &str, policy_fs: &str) -> Json { + entry( + id, + &with_default(&format!(r#"{{"filesystem":{policy_fs}}}"#), ""), + ) +} + +fn compose(entries: Vec, names: Vec<&str>) -> RequirementsResolution { + catalog_for(revision(entries), linux_x64()) + .resolve_requirements_with_diagnostics( + names, + &weak() + .project_root("/work") + .symbol("git_prefix", "/data") + .symbol("node_prefix", "/secrets"), + ) + .unwrap() +} + +#[test] +fn filesystem_floor_composition_rules() { + let ro_rw = compose( + vec![ + paths_entry("tool:a", r#"{"readonlyPaths":["${project_root}"]}"#), + paths_entry("tool:b", r#"{"readwritePaths":["${project_root}"]}"#), + ], + vec!["a", "b"], + ); + assert_eq!( + fs_json(&ro_rw.requirements), + j(r#"{"readwritePaths":["/work"]}"#) + ); + assert!(messages(&ro_rw.diagnostics.warnings).contains( + "read-only '/work' (tool:a) is covered by read-write '/work' (tool:b); the read-only entry was omitted" + )); + + let nested_ro = compose( + vec![paths_entry( + "tool:a", + r#"{"readwritePaths":["${project_root}"],"readonlyPaths":["${project_root}/tools"]}"#, + )], + vec!["a"], + ); + assert_eq!( + fs_json(&nested_ro.requirements), + j(r#"{"readwritePaths":["/work"]}"#) + ); + + let nested_rw = compose( + vec![paths_entry( + "tool:a", + r#"{"readonlyPaths":["${project_root}"],"readwritePaths":["${project_root}/cache"]}"#, + )], + vec!["a"], + ); + assert_eq!( + fs_json(&nested_rw.requirements), + j(r#"{"readonlyPaths":["/work"],"readwritePaths":["/work/cache"]}"#) + ); + + let denies = compose( + vec![ + paths_entry( + "tool:a", + r#"{"deniedPaths":["${git_prefix}","${node_prefix}"]}"#, + ), + paths_entry( + "tool:b", + r#"{"readwritePaths":["${project_root}","${git_prefix}/cache"]}"#, + ), + ], + vec!["a", "b"], + ); + assert_eq!( + fs_json(&denies.requirements), + j(r#"{"deniedPaths":["/secrets"],"readwritePaths":["/work","/data/cache"]}"#) + ); + assert!(messages(&denies.diagnostics.warnings).contains( + "removed catalog deny '/data' (tool:a) because it overlaps required read-write '/data/cache' (tool:b); the entire deny scope '/data' was removed, so other grants may now apply throughout it" + )); +} + +#[test] +fn unknown_case_sensitivity_keeps_case_variants_with_a_diagnostic() { + let catalog = catalog_for( + revision(vec![paths_entry( + "tool:w", + r#"{"readonlyPaths":["${git_prefix}","${node_prefix}\\"]}"#, + )]), + linux_x64(), + ); + let ctx = weak() + .platform(Platform::Windows) + .architecture(Architecture::X64) + .project_root("C:\\p") + .symbol("git_prefix", "C:\\Tools") + .symbol("node_prefix", "c:\\tools"); + let result = catalog + .resolve_requirements_with_diagnostics("w", &ctx) + .unwrap(); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readonlyPaths":["C:\\Tools","c:\\tools"]}"#) + ); + assert!(messages(&result.diagnostics.warnings).contains( + "read-only 'C:\\Tools' (tool:w) and read-only 'c:\\tools' (tool:w) differ only by case; filesystem case sensitivity was not determined, so they were compared case-sensitively and kept distinct" + )); + let same = catalog + .resolve_requirements( + "w", + &ctx.symbol("git_prefix", "C:\\tools") + .symbol("node_prefix", "C:/tools/"), + ) + .unwrap(); + assert_eq!(fs_json(&same), j(r#"{"readonlyPaths":["C:\\tools"]}"#)); +} + +#[test] +fn dependency_chain_composes_each_entry_once_in_order() { + let node = |deps: &[&str], path: &str| { + let deps = deps + .iter() + .map(|d| format!(r#"{{"entryId":"{d}"}}"#)) + .collect::>() + .join(","); + format!( + r#"{{"default":{{"requirements":{{"filesystem":{{"readonlyPaths":["${{project_root}}/{path}"]}}}},"dependencies":[{deps}]}}}}"# + ) + }; + let catalog = catalog_for( + revision(vec![ + entry("tool:top", &node(&["tool:left", "tool:right"], "top")), + entry("tool:left", &node(&["tool:leaf"], "left")), + entry("tool:right", &node(&["tool:leaf"], "right")), + entry("tool:leaf", &node(&[], "leaf")), + ]), + linux_x64(), + ); + let result = catalog + .resolve_requirements_with_diagnostics("top", &weak().project_root("/r")) + .unwrap(); + let deps: Vec<&str> = result + .diagnostics + .resolved_dependencies + .iter() + .map(|d| d.entry_id.as_str()) + .collect(); + assert_eq!(deps, ["tool:leaf", "tool:left", "tool:right"]); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readonlyPaths":["/r/top","/r/left","/r/leaf","/r/right"]}"#) + ); +} + +#[test] +fn dependency_diagnostics_keep_distinct_ranges_sorted() { + let a = r#"{"default":{"requirements":{},"dependencies":[{"entryId":"tool:c","versionRange":"vers:semver/>=2.0.0"}]}}"#.to_string(); + let b = r#"{"default":{"requirements":{},"dependencies":[{"entryId":"tool:c"}]}}"#.to_string(); + let catalog = catalog_for( + revision(vec![ + entry("tool:a", &a), + entry("tool:b", &b), + entry( + "tool:c", + r#"{"default":{"requirements":{},"intents":{"run":{}}}}"#, + ), + ]), + linux_x64(), + ); + let result = catalog + .resolve_requirements_with_diagnostics(vec!["a", "b", "a"], &weak()) + .unwrap(); + assert_eq!( + result + .to_json() + .get("diagnostics") + .unwrap() + .get("resolvedDependencies") + .unwrap(), + &j(r#"[ + {"entryId":"tool:c","entryRevision":1,"inputIndexes":[1],"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"none","selected":[]}}, + {"entryId":"tool:c","entryRevision":1,"inputIndexes":[0,2],"requiredVersionRange":"vers:semver/>=2.0.0","versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"none","selected":[]}}]"#) + ); + assert_eq!(result.requirements.unwrap().to_json(), j(r#"{}"#)); +} + +#[test] +fn unsupported_combinations_fail_rather_than_broaden() { + let catalog = catalog_for( + revision(vec![ + entry("tool:b", &with_default(r#"{}"#, "")), + entry( + "tool:d", + &with_default( + r#"{"network":{"egress":{"deny":[{"ports":[{"port":22}]}]}}}"#, + "", + ), + ), + entry( + "tool:n", + &with_default( + &format!( + r#"{{"network":{{"egress":{{"default":"deny","allow":[{}]}}}}}}"#, + tcp("192.0.2.1/32", 443) + ), + "", + ), + ), + entry("tool:u", &with_default(r#"{"timeoutMs":5}"#, "")), + entry("tool:u2", &with_default(r#"{"timeoutMs":5}"#, "")), + entry("tool:v", &with_default(r#"{"ui":{"disable":true}}"#, "")), + entry("tool:v2", &with_default(r#"{"ui":{"disable":true}}"#, "")), + entry( + "tool:i", + &with_default(r#"{"network":{"ingress":{"default":"deny"}}}"#, ""), + ), + ]), + linux_x64(), + ); + // A deny that does not overlap another pair's required allow stays. + assert_eq!( + catalog + .resolve_requirements(vec!["d", "n"], &weak()) + .unwrap() + .unwrap() + .network, + Some(j(&format!( + r#"{{"egress":{{"default":"deny","allow":[{}],"deny":[{{"ports":[{{"port":22}}]}}]}}}}"#, + tcp("192.0.2.1/32", 443) + ))) + ); + // Non-egress network keys cannot be combined across network requirements. + assert_eq!( + reason_of(catalog.resolve_requirements(vec!["i", "n"], &weak())), + ErrorReason::CompositionConflict + ); + // One source's ui/timeoutMs is kept; omission elsewhere adds nothing. + assert_eq!( + catalog + .resolve_requirements(vec!["u", "b", "v", "u"], &weak()) + .unwrap() + .unwrap() + .to_json(), + j(r#"{"ui":{"disable":true},"timeoutMs":5}"#) + ); + // Distinct sources supplying the same field conflict, even when equal. + for pair in [vec!["u", "u2"], vec!["v", "v2"]] { + assert_eq!( + reason_of(catalog.resolve_requirements(pair, &weak())), + ErrorReason::CompositionConflict + ); + } + // A single selected policy without additions passes through whole. + assert_eq!( + catalog + .resolve_requirements("u", &weak()) + .unwrap() + .unwrap() + .to_json(), + j(r#"{"timeoutMs":5}"#) + ); + // Only one component needs network: its network passes through. + assert_eq!( + catalog + .resolve_requirements(vec!["d", "b"], &weak()) + .unwrap() + .unwrap() + .network, + Some(j(r#"{"egress":{"deny":[{"ports":[{"port":22}]}]}}"#)) + ); +} + +// --------------------------------------------------------------------------- +// Setup and inspection +// --------------------------------------------------------------------------- + +#[test] +fn inspection_over_the_bundled_catalog() { + let info = get_catalog_info().unwrap(); + assert_eq!( + info.to_json(), + j( + r#"{"catalogSchemaVersion":"1","catalogRevision":"2026-10-06.1","sdkContractVersion":"1.0.0"}"# + ) + ); + let entries = list_catalog_entries().unwrap(); + let ids: Vec<&str> = entries.iter().map(|e| e.entry_id.as_str()).collect(); + assert_eq!(ids, ["tool:git", "tool:node", "tool:npm", "tool:ssh"]); + let git = entries + .iter() + .find(|e| e.entry_id == "tool:git") + .unwrap() + .to_json(); + assert_eq!(git.get("versionScheme").unwrap(), &j(r#""intdot""#)); + assert_eq!( + git.get("versionVariants").unwrap(), + &j(r#"[ + {"versionRange":"vers:intdot/>=2.40|<2.50","dependencyEntryIds":[],"intentAdditions":[{"name":"push","dependencyEntryIds":["tool:ssh"]}],"newIntents":[]}, + {"versionRange":"vers:intdot/>=2.50|<3","dependencyEntryIds":[],"intentAdditions":[],"newIntents":[{"name":"bundle-fetch","exampleSubcommands":["clone --bundle-uri="],"dependencyEntryIds":[]}]}]"#) + ); + let text = Json::Array(entries.iter().map(|e| e.to_json()).collect()).to_compact_string(); + assert!(!text.contains("Paths") && !text.contains("${") && !text.contains("requirements\"")); +} + +#[test] +fn module_level_functions_use_the_bundled_catalog() { + let ctx = weak() + .platform(Platform::Linux) + .architecture(Architecture::X64); + assert_eq!( + resolve_requirements("definitely-unknown-tool", &ctx).unwrap(), + None + ); + let result = resolve_requirements_with_diagnostics( + vec![ToolInput::from("definitely-unknown-tool")], + &ctx, + ) + .unwrap(); + assert_eq!(result.requirements, None); + assert_eq!( + result.diagnostics.to_json().get("tools").unwrap(), + &j(r#"[{"inputIndex":0,"contributes":false,"status":"tool_unmatched"}]"#) + ); + assert_eq!( + reason_of(resolve_requirements( + "git", + &ctx.catalog_revision("1999-01-01.1") + )), + ErrorReason::RevisionUnavailable + ); +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +fn dep(id: &str) -> String { + format!(r#"{{"default":{{"requirements":{{}},"dependencies":[{{"entryId":"{id}"}}]}}}}"#) +} + +#[test] +fn validation_accepts_minimal_and_rejects_cycles_and_missing_dependencies() { + validate(vec![entry("tool:a", "")]).unwrap(); + let error = validate(vec![ + entry("tool:a", &dep("tool:b")), + entry("tool:b", &dep("tool:c")), + entry("tool:c", &dep("tool:a")), + ]) + .unwrap_err(); + assert_eq!( + error.message(), + "[policy_validation] 'tool:a' on windows/x64 (default) intent (all): dependency resolution failed: cycle (tool:a -> tool:b -> tool:c -> tool:a)" + ); + validate_err(vec![entry("tool:a", &dep("tool:a"))], "depends on itself"); + validate_err( + vec![entry("tool:a", &dep("tool:missing"))], + "unknown entry 'tool:missing'", + ); + let ranged = |range: &str| { + format!( + r#"{{"default":{{"requirements":{{}},"dependencies":[{{"entryId":"tool:b","versionRange":"{range}"}}]}}}}"# + ) + }; + validate_err( + vec![entry("tool:a", &ranged("^1.x")), entry("tool:b", "")], + "is not a valid vers range", + ); + validate_err( + vec![ + entry("tool:a", &ranged("vers:npm/>=1.0.0")), + entry("tool:b", ""), + ], + "does not use that entry's versionScheme 'semver'", + ); + validate(vec![ + entry("tool:a", &ranged("vers:semver/>=1.0.0")), + entry("tool:b", ""), + ]) + .unwrap(); +} + +#[test] +fn validation_requires_exactly_one_default_and_a_scheme() { + let mut no_default = entry("tool:a", ""); + if let Json::Object(o) = &mut no_default { + o.remove("default"); + } + validate_err( + vec![no_default], + "every entry has exactly one unversioned default", + ); + validate_err( + vec![entry("tool:a", r#"{"versionScheme":"maven"}"#)], + "'entries[0].versionScheme' must be one of npm, semver, pypi, nuget, intdot", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"default":{"requirements":{},"versionRange":"vers:semver/>=1"}}"#, + )], + "unsupported field 'entries[0].default.versionRange'", + ); +} + +#[test] +fn validation_selectors() { + let v = |arch: &str| { + let a = if arch.is_empty() { + String::new() + } else { + format!(r#","architecture":"{arch}""#) + }; + format!(r#"{{"when":{{"platform":"linux"{a}}}}}"#) + }; + let variants = |list: &[String]| format!(r#"{{"platformVariants":[{}]}}"#, list.join(",")); + validate_err( + vec![entry("tool:a", &variants(&[v("x64"), v("x64")]))], + "duplicates selector", + ); + validate_err( + vec![entry("tool:a", &variants(&[v(""), v("")]))], + "second architecture-neutral", + ); + validate(vec![entry( + "tool:a", + &variants(&[v(""), v("x64"), v("arm64")]), + )]) + .unwrap(); + validate_err( + vec![entry("tool:a", &variants(&[v("riscv")]))], + "'entries[0].platformVariants[0].when.architecture' must be one of x64, arm64", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"platformVariants":[{"when":{"platform":"linux"},"requirements":{}}]}"#, + )], + "unsupported field 'entries[0].platformVariants[0].requirements'", + ); +} + +#[test] +fn validation_policy_rules() { + let p = |policy: &str| vec![entry("tool:a", &with_default(policy, ""))]; + validate_err(p(r#"{"processContainer":{}}"#), "containment backend"); + validate_err(p(r#"{"containment":"lxc"}"#), "containment backend"); + validate_err(p(r#"{"version":"1.0.0"}"#), "wire version"); + validate_err( + vec![entry("tool:a", r#"{"extra":1}"#)], + "unsupported field 'entries[0].extra'", + ); + validate_err( + p(r#"{"filesystem":{"clearPolicyOnExit":true}}"#), + "unsupported field", + ); + let path = |value: &str| { + p(&format!( + r#"{{"filesystem":{{"readonlyPaths":[{}]}}}}"#, + Json::from(value) + )) + }; + validate_err(path("/home/alice/.npm"), "literal paths are not allowed"); + validate_err(path("C:\\Users\\alice"), "literal paths are not allowed"); + validate_err(path("${project_root}/*"), "wildcard"); + validate_err(path("${project_root}/../x"), "'..'"); + validate_err(path("${unknown_thing}"), "unknown symbol 'unknown_thing'"); + validate_err(path("${project_root"), "malformed symbol"); + validate_err(path("${project_root}x"), "literal paths are not allowed"); + validate(path("${project_root}/node_modules")).unwrap(); + validate(path("${programData}\\Git")).unwrap(); + let net = |n: &str| p(&format!(r#"{{"network":{n}}}"#)); + validate_err(net(r#"{"egress":{"default":"allow"}}"#), "default-allow"); + validate_err(net(r#"{"ingress":{"default":"allow"}}"#), "default-allow"); + validate_err( + net(r#"{"egress":{"allow":[{"ports":[{"port":443}]}]}}"#), + "wildcard network grant", + ); + validate_err( + net(r#"{"egress":{"allow":[{"to":[{"cidr":"0.0.0.0/0"}]}]}}"#), + "wildcard network grant", + ); + validate_err( + net( + r#"{"egress":{"allow":[{"to":[{"cidr":"10.0.0.0/8"}],"ports":[{"port":10,"endPort":5}]}]}}"#, + ), + "requires a lower or equal 'port'", + ); + validate(net(r#"{"egress":{"deny":[{"ports":[{"port":22}]}]}}"#)).unwrap(); + validate_err( + p(r#"{"timeoutMs":1.5}"#), + "timeoutMs' must be a positive unsigned 32-bit integer", + ); + validate_err( + p(r#"{"ui":{"disable":false,"clipboard":"x"}}"#), + "clipboard' is unsupported", + ); +} + +#[test] +fn validation_identity_rules() { + validate(vec![ + entry("tool:a", ""), + entry( + "tool:b", + r#"{"identity":[{"kind":"invocation-name","names":["A"]}]}"#, + ), + ]) + .unwrap(); + validate_err( + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"invocation-name","names":["a","A"]}]}"#, + )], + "repeats identity 'invocation-name:a'", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/a"},{"kind":"purl","value":"pkg:NPM/a"}]}"#, + )], + "repeats identity 'purl:npm/a'", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/a@1.0.0"}]}"#, + )], + "must name only type, namespace, and name", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/a","versionRange":">=1"}]}"#, + )], + "unsupported field 'entries[0].identity[0].versionRange'", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"invocation-name","names":["bin/a"]}]}"#, + )], + "bare invocation name", + ); + validate_err( + vec![ + entry("tool:a", ""), + entry( + "tool:a", + r#"{"identity":[{"kind":"invocation-name","names":["z"]}]}"#, + ), + ], + "duplicate entryId", + ); + validate_err( + vec![entry("tool:a", r#"{"entryId":"noNamespace"}"#)], + "must be namespaced", + ); +} + +#[test] +fn validation_overlays_are_additive_only() { + let overlay = |body: &str| { + vec![entry( + "tool:a", + &format!( + r#"{{"default":{{"requirements":{{}},"intents":{{"run":{{}}}}}},"platformVariants":[{{"when":{{"platform":"linux"}},{body}}}]}}"# + ), + )] + }; + validate(overlay( + r#""policyAdditions":{"filesystem":{"readonlyPaths":["${git_prefix}"]}}"#, + )) + .unwrap(); + validate_err( + overlay(r#""policyAdditions":{"filesystem":{"deniedPaths":["${git_prefix}"]}}"#), + "'entries[0].platformVariants[0].policyAdditions.filesystem.deniedPaths' is not additive", + ); + validate_err( + overlay(r#""policyAdditions":{"network":{"egress":{"deny":[]}}}"#), + "'entries[0].platformVariants[0].policyAdditions.network.egress.deny' is not additive", + ); + validate_err( + overlay(r#""policyAdditions":{"timeoutMs":5}"#), + "is not additive", + ); + validate_err( + overlay(r#""intentAdditions":{"other":{}}"#), + "extends an intent the default does not declare", + ); + validate_err( + overlay(r#""newIntents":{"run":{}}"#), + "redeclares an inherited intent", + ); + validate_err( + overlay(r#""newIntents":{"Run":{}}"#), + "is not a valid intent name", + ); + validate_err( + overlay(r#""newIntents":{"x":{"exampleSubcommands":[]}}"#), + "exampleSubcommands", + ); + validate(overlay(r#""newIntents":{"x":{}}"#)).unwrap(); + validate_err( + overlay(r#""intents":{"x":{}}"#), + "unsupported field 'entries[0].platformVariants[0].intents'", + ); +} + +#[test] +fn validation_version_variants() { + let ranges = |list: &[&str]| { + let variants = list + .iter() + .map(|r| format!(r#"{{"versionRange":"{r}"}}"#)) + .collect::>() + .join(","); + vec![entry( + "tool:a", + &format!(r#"{{"versionVariants":[{variants}]}}"#), + )] + }; + validate(ranges(&[ + "vers:semver/>=1.0.0|<2.0.0", + "vers:semver/>=2.0.0", + ])) + .unwrap(); + validate_err( + ranges(&["vers:semver/>=1.0.0|<2.0.0", "vers:semver/>=1.5.0"]), + "overlaps 'entries[0].versionVariants[0].versionRange'", + ); + validate_err( + ranges(&["vers:npm/>=1.0.0"]), + "uses 'npm', but the entry's versionScheme is 'semver'", + ); + validate_err(ranges(&[">=1.0.0"]), "is not a valid vers range"); + validate_err( + vec![entry( + "tool:a", + r#"{"platformVariants":[{"when":{"platform":"linux"},"newIntents":{"x":{}}}], + "versionVariants":[{"versionRange":"vers:semver/>=1.0.0","newIntents":{"x":{}}}]}"#, + )], + "'tool:a' on linux/x64 (vers:semver/>=1.0.0): intent 'x' from version range 'vers:semver/>=1.0.0' is already declared by another overlay", + ); +} + +#[test] +fn validation_materializes_composition_limits() { + let err = |entries: Vec, needle: &str| { + let m = validate(entries).unwrap_err().message().to_string(); + assert!(m.contains(needle), "{m}"); + }; + err( + vec![ + entry( + "tool:a", + r#"{"default":{"requirements":{"timeoutMs":5},"dependencies":[{"entryId":"tool:b"}]}}"#, + ), + entry("tool:b", &with_default(r#"{"timeoutMs":5}"#, "")), + ], + "'timeoutMs' is supplied by tool:a and tool:b; distinct sources cannot both supply it", + ); + validate(vec![ + entry("tool:a", &dep("tool:b")), + entry("tool:b", &with_default(r#"{"timeoutMs":5}"#, "")), + ]) + .unwrap(); + // An overlapping catalog deny is removed during materialization, not rejected. + validate(vec![entry( + "tool:a", + &format!( + r#"{{"default":{{"requirements":{{"network":{{"egress":{{"deny":[{{"ports":[{{"port":443}}]}}]}}}}}}, + "intents":{{"net":{{"policyAdditions":{{"network":{{"egress":{{"allow":[{}]}}}}}}}}}}}}}}"#, + tcp("192.0.2.1/32", 443) + ), + )]) + .unwrap(); + err( + vec![ + entry("tool:a", &dep("tool:b")), + entry( + "tool:a2", + &format!( + r#"{{"default":{{"requirements":{{"network":{{"egress":{{"allow":[{}]}}}}}},"dependencies":[{{"entryId":"tool:b"}}]}}}}"#, + tcp("192.0.2.1/32", 443) + ), + ), + entry( + "tool:b", + &with_default( + &format!( + r#"{{"network":{{"ingress":{{"default":"deny"}},"egress":{{"allow":[{}]}}}}}}"#, + tcp("192.0.2.2/32", 443) + ), + "", + ), + ), + ], + "'tool:b' uses 'network.ingress', which cannot be combined with other network requirements", + ); +} + +// --------------------------------------------------------------------------- +// Dependency intent references and egress-deny removal +// --------------------------------------------------------------------------- + +fn dep_intents(id: &str, intents: &str) -> String { + format!( + r#"{{"default":{{"requirements":{{}},"dependencies":[{{"entryId":"{id}","intents":{intents}}}]}}}}"# + ) +} + +fn helper() -> Json { + entry( + "tool:h", + &format!( + r#"{{"default":{{"requirements":{{"filesystem":{{"readonlyPaths":["${{git_prefix}}/h"]}}}}, + "intents":{{"store":{{"policyAdditions":{{"filesystem":{{"readwritePaths":["${{temp_dir}}/h"]}}}}}}, + "sync":{{"policyAdditions":{{"network":{{"egress":{{"allow":[{}]}}}}}}}}}}}}, + "versionVariants":[{{"versionRange":"vers:semver/>=1.0.0","newIntents":{{"late":{{}}}}}}]}}"#, + tcp("203.0.113.0/24", 443) + ), + ) +} + +#[test] +fn dependency_references_contribute_base_only_unless_they_name_intents() { + let catalog = catalog_for( + revision(vec![ + entry("tool:a", &dep("tool:h")), + entry("tool:b", &dep_intents("tool:h", r#"["sync","store"]"#)), + helper(), + ]), + linux_x64(), + ); + let ctx = weak().symbol("git_prefix", "/g").symbol("temp_dir", "/t"); + let base_only = catalog + .resolve_requirements_with_diagnostics("a", &ctx) + .unwrap(); + assert_eq!( + fs_json(&base_only.requirements), + j(r#"{"readonlyPaths":["/g/h"]}"#) + ); + assert_eq!(egress(&base_only), None); + let named = catalog + .resolve_requirements_with_diagnostics("b", &ctx) + .unwrap(); + assert_eq!( + fs_json(&named.requirements), + j(r#"{"readonlyPaths":["/g/h"],"readwritePaths":["/t/h"]}"#) + ); + assert_eq!( + named.requirements.as_ref().unwrap().network, + Some(j(&format!( + r#"{{"egress":{{"default":"deny","allow":[{}]}}}}"#, + tcp("203.0.113.0/24", 443) + ))) + ); + assert_eq!( + named + .to_json() + .get("diagnostics") + .unwrap() + .get("resolvedDependencies") + .unwrap(), + &j( + r#"[{"entryId":"tool:h","entryRevision":1,"inputIndexes":[0],"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"named","selected":["store","sync"]}}]"# + ) + ); +} + +#[test] +fn dependency_intent_references_are_validated() { + validate(vec![ + entry("tool:a", &dep_intents("tool:h", r#"["sync"]"#)), + helper(), + ]) + .unwrap(); + // Dependencies use the default only, so a version-range intent is missing. + validate_err( + vec![ + entry("tool:a", &dep_intents("tool:h", r#"["late"]"#)), + helper(), + ], + "tool:a -> tool:h names intent 'late', which tool:h does not define", + ); + validate_err( + vec![ + entry("tool:a", &dep_intents("tool:h", r#"["nope"]"#)), + helper(), + ], + "names intent 'nope'", + ); + validate_err( + vec![entry("tool:a", &dep_intents("tool:h", "[]")), helper()], + "intents", + ); + validate_err( + vec![ + entry("tool:a", &dep_intents("tool:h", r#"["sync","sync"]"#)), + helper(), + ], + "intents", + ); + validate_err( + vec![ + entry("tool:a", &dep_intents("tool:h", r#"["Sync"]"#)), + helper(), + ], + "intent name", + ); +} + +#[test] +fn overlapping_catalog_egress_denies_are_removed_in_full_with_diagnostics() { + let deny = |rules: &str| { + with_default( + &format!(r#"{{"network":{{"egress":{{"deny":[{rules}]}}}}}}"#), + "", + ) + }; + let catalog = catalog_for( + revision(vec![ + entry( + "tool:n", + &with_default( + &format!( + r#"{{"network":{{"egress":{{"allow":[{}]}}}}}}"#, + tcp("192.0.2.10/32", 443) + ), + "", + ), + ), + entry( + "tool:d", + &deny( + r#"{"to":[{"cidr":"192.0.2.0/24","except":["192.0.2.128/25"]}],"ports":[{"protocol":"tcp","port":400,"endPort":500}]}, + {"to":[{"cidr":"192.0.2.0/24","except":["192.0.2.0/28"]}]}, + {"to":[{"cidr":"2001:db8::/32"}]}, + {"ports":[{"protocol":"udp","port":443}]}"#, + ), + ), + ]), + linux_x64(), + ); + let result = catalog + .resolve_requirements_with_diagnostics(vec!["n", "d"], &weak()) + .unwrap(); + assert_eq!( + result.requirements.as_ref().unwrap().network, + Some(j(&format!( + r#"{{"egress":{{"default":"deny","allow":[{}],"deny":[ + {{"to":[{{"cidr":"192.0.2.0/24","except":["192.0.2.0/28"]}}]}}, + {{"to":[{{"cidr":"2001:db8::/32"}}]}}, + {{"ports":[{{"protocol":"udp","port":443}}]}}]}}}}"#, + tcp("192.0.2.10/32", 443) + ))) + ); + assert_eq!( + messages(&result.diagnostics.warnings), + "input 0 ('n') matched tool:n only by invocation name (weak identity)\n\ + input 1 ('d') matched tool:d only by invocation name (weak identity)\n\ + removed catalog egress deny [192.0.2.0/24 except 192.0.2.128/25; tcp/400-500] (tool:d) because it overlaps required egress allow [192.0.2.10/32; tcp/443] (tool:n); the entire deny rule was removed, so other grants may now apply throughout [192.0.2.0/24 except 192.0.2.128/25; tcp/400-500]" + ); +} + +// --------------------------------------------------------------------------- +// Store and history +// --------------------------------------------------------------------------- + +#[test] +fn bundled_store_verifies_and_canonical_json_ignores_formatting() { + let store = mxc_sdk::__policy_store::bundled_catalog_store().unwrap(); + assert!(check_store_history(&store).is_empty()); + assert_eq!( + store.revision(None).unwrap().catalog_revision, + store.default_revision() + ); + assert_eq!( + canonical_json(&j(r#"{"b":1,"a":[2,{"d":3,"c":4}]}"#)), + r#"{"a":[2,{"c":4,"d":3}],"b":1}"# + ); + assert_eq!( + canonical_json(&j(r#"{"a":1,"b":2}"#)), + canonical_json(&j(r#"{"b":2,"a":1}"#)) + ); +} + +#[test] +fn unreadable_revision_is_an_integrity_error_everywhere() { + let source = mxc_sdk::__policy_store::MemorySource { + files: HashMap::new(), + ..mxc_sdk::__policy_store::MemorySource::publishing( + contract(), + &[revision(vec![entry("tool:a", "")])], + None, + ) + }; + let store = Arc::new(mxc_sdk::__policy_store::CatalogStore::new(source).unwrap()); + assert_eq!(reason_of(store.revision(None)), ErrorReason::Integrity); + let catalog = PolicyCatalog::with_host(store, linux_x64()); + assert_eq!( + reason_of(catalog.resolve_requirements("a", &weak())), + ErrorReason::Integrity + ); + assert_eq!( + reason_of(catalog.resolve_requirements_with_diagnostics("a", &weak())), + ErrorReason::Integrity + ); + assert_eq!( + reason_of(catalog.list_catalog_entries()), + ErrorReason::Integrity + ); + assert_eq!( + reason_of(catalog.get_catalog_info()), + ErrorReason::Integrity + ); +} + +#[test] +fn revision_id_mismatch_and_invalid_manifest() { + let other = revision_with(vec![entry("tool:a", "")], "2000-01-02.1"); + let mut relabeled = other.clone(); + if let Json::Object(o) = &mut relabeled { + o.insert("catalogRevision", "2000-01-01.1".into()); + } + // A file that declares another revision id → integrity. + let source = mxc_sdk::__policy_store::MemorySource { + files: HashMap::from([("revisions/2000-01-01.1.json".to_string(), other.clone())]), + ..mxc_sdk::__policy_store::MemorySource::publishing(contract(), &[relabeled], None) + }; + let error = mxc_sdk::__policy_store::CatalogStore::new(source) + .unwrap() + .revision(None) + .unwrap_err(); + assert_eq!( + error.message(), + "[backend_error] file 'revisions/2000-01-01.1.json' declares revision '2000-01-02.1', expected '2000-01-01.1'" + ); + let r = revision(vec![entry("tool:a", "")]); + assert_eq!( + reason_of(store_with(std::slice::from_ref(&r), Some("2001-01-01.1"))), + ErrorReason::InvalidCatalog + ); + assert_eq!( + reason_of(store_for(&[ + revision_with(vec![entry("tool:a", "")], "2000-01-02.1"), + r + ])), + ErrorReason::InvalidCatalog + ); +} + +#[test] +fn explicit_revisions_are_never_substituted() { + let r1 = revision_with( + vec![entry("tool:a", ""), entry("tool:b", "")], + "2000-01-01.1", + ); + let r2 = revision_with( + vec![ + entry("tool:a", r#"{"entryRevision":2,"displayName":"renamed"}"#), + entry("tool:b", ""), + ], + "2000-01-02.1", + ); + let catalog = PolicyCatalog::with_host(Arc::new(store_for(&[r1, r2]).unwrap()), linux_x64()); + let ctx = weak().project_root("/p"); + let of = |ctx: &ResolveContext| { + let r = catalog + .resolve_requirements_with_diagnostics("a", ctx) + .unwrap(); + ( + r.diagnostics.catalog_revision.clone(), + r.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() + .entry_revision, + ) + }; + assert_eq!(of(&ctx), ("2000-01-02.1".into(), 2.0)); + assert_eq!( + of(&ctx.clone().catalog_revision("2000-01-01.1")), + ("2000-01-01.1".into(), 1.0) + ); + let error = catalog + .resolve_requirements("a", &ctx.catalog_revision("2000-01-03.1")) + .unwrap_err(); + assert_eq!( + error.message(), + "[backend_error] catalog revision '2000-01-03.1' is not installed" + ); +} + +#[test] +fn entry_revision_history() { + let contract = validate_contract(&contract()).unwrap(); + let v = |r: Json| validate_catalog_revision(&r, &contract).unwrap(); + let base = v(revision_with( + vec![entry("tool:a", ""), entry("tool:b", "")], + "2000-01-01.1", + )); + let changed = v(revision_with( + vec![ + entry("tool:a", r#"{"displayName":"x"}"#), + entry("tool:b", ""), + ], + "2000-01-02.1", + )); + let bumped = v(revision_with( + vec![ + entry("tool:a", ""), + entry("tool:b", r#"{"entryRevision":2}"#), + ], + "2000-01-02.1", + )); + let ok = v(revision_with( + vec![ + entry("tool:a", r#"{"displayName":"x","entryRevision":2}"#), + entry("tool:b", ""), + ], + "2000-01-02.1", + )); + assert_eq!( + check_entry_revisions(&base, &changed), + ["2000-01-02.1: 'tool:a' changed but entryRevision did not increase (1 -> 1)"] + ); + assert_eq!( + check_entry_revisions(&base, &bumped), + ["2000-01-02.1: 'tool:b' is unchanged but entryRevision moved (1 -> 2)"] + ); + assert!(check_entry_revisions(&base, &ok).is_empty()); + assert!(check_entry_revisions(&ok, &base)[0].contains("must be newer")); +} + +#[test] +fn published_immutability() { + let published = PublishedRevision::new("2000-01-01.1", "revisions/2000-01-01.1.json"); + let files = HashMap::from([( + "revisions/2000-01-01.1.json".to_string(), + "{\"x\":1}\n".to_string(), + )]); + let base = PublishedState { + revisions: vec![published.clone()], + files: files.clone(), + }; + let appended = PublishedState { + revisions: vec![ + published.clone(), + PublishedRevision::new("2000-01-02.1", "revisions/2000-01-02.1.json"), + ], + files: HashMap::from([( + "revisions/2000-01-01.1.json".to_string(), + "{\r\n \"x\": 1}".to_string(), + )]), + }; + assert!(check_published_immutability(&base, &appended).is_empty()); + let edited = PublishedState { + revisions: vec![published.clone()], + files: HashMap::from([( + "revisions/2000-01-01.1.json".to_string(), + "{\"x\":2}\n".to_string(), + )]), + }; + assert_eq!( + check_published_immutability(&base, &edited), + ["published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead"] + ); + let moved = PublishedState { + revisions: vec![PublishedRevision::new( + "2000-01-01.1", + "revisions/moved.json", + )], + files, + }; + assert_eq!( + check_published_immutability(&base, &moved), + ["published revision '2000-01-01.1' manifest entry was modified"] + ); + let removed = PublishedState::default(); + assert_eq!( + check_published_immutability(&base, &removed), + ["published revision '2000-01-01.1' was removed or reordered in the manifest"] + ); +} + +#[test] +fn error_code_for_every_reason() { + for reason in ErrorReason::ALL { + let error = PolicyCatalogError::new(reason, "m"); + assert_eq!(error.message(), format!("[{}] m", reason.code().as_str())); + } +} diff --git a/src/mxc-sdk/tests/policy_store_sdk.rs b/src/mxc-sdk/tests/policy_store_sdk.rs new file mode 100644 index 000000000..e6809149d --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_sdk.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The public `mxc_sdk::v1` tool-requirements surface: error mapping, +//! catalog metadata, and command-free requirements. + +use mxc_sdk::v1::tool_requirements::{ + Architecture, Platform, ResolveContext, SymbolMap, ToolCandidate, ToolResolutionStatus, +}; +use mxc_sdk::v1::{ + get_catalog_info, list_catalog_entries, resolve_tool_requirements, + resolve_tool_requirements_with_diagnostics, ErrorCode, +}; + +#[test] +fn invalid_context_maps_to_malformed_request() { + let error = resolve_tool_requirements(&["/usr/bin/git".into()], None).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); +} + +#[test] +fn name_only_conversion_sets_only_the_invocation_name() { + let converted: ToolCandidate = "git".into(); + assert_eq!( + converted, + ToolCandidate { + invocation_name: "git".to_owned(), + package_url: None, + detected_version: None, + intent: None, + } + ); +} + +#[test] +fn borrowed_slices_resolve_one_and_many_tools() { + let context = ResolveContext::new() + .platform(Platform::Linux) + .architecture(Architecture::X64) + .allow_weak(true); + let selected = |tools: &[ToolCandidate]| -> Vec { + resolve_tool_requirements_with_diagnostics(tools, Some(&context)) + .unwrap() + .diagnostics + .tools + .iter() + .filter_map(|t| t.selection.as_ref().map(|s| s.entry_id.clone())) + .collect() + }; + assert_eq!(selected(&["git".into()]), ["tool:git"]); + assert_eq!( + selected(&["git".into(), "node".into()]), + ["tool:git", "tool:node"] + ); + // Name-only input does not opt into weak matching by itself. + assert!(resolve_tool_requirements(&["git".into()], None) + .unwrap() + .is_none()); +} + +#[test] +fn project_root_and_symbol_form_must_be_identical() { + let symbols = |value: &str| [("project_root", value)].into_iter().collect::(); + let both = |root: &str, symbol: &str| ResolveContext { + project_root: Some(root.to_owned()), + symbols: Some(symbols(symbol)), + ..ResolveContext::default() + }; + // Rejected before any input is resolved, even with no inputs. + let error = resolve_tool_requirements(&[], Some(&both("/a", "/b"))).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + // No path normalization: a trailing separator is a different string. + let error = resolve_tool_requirements(&[], Some(&both("/a", "/a/"))).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + assert!(resolve_tool_requirements(&[], Some(&both("/a", "/a"))) + .unwrap() + .is_none()); +} + +#[test] +fn unmatched_tools_resolve_to_none_with_status() { + let result = + resolve_tool_requirements_with_diagnostics(&["no-such-tool-xyz".into()], None).unwrap(); + assert!(result.requirements.is_none()); + let tool = &result.diagnostics.tools[0]; + assert_eq!(tool.status, ToolResolutionStatus::ToolUnmatched); + assert!(!tool.contributes); + assert!(tool.selection.is_none()); +} + +#[test] +fn catalog_metadata_reports_the_sdk_contract_version() { + let info = get_catalog_info().unwrap(); + assert_eq!(info.sdk_contract_version, "1.0.0"); + assert!(list_catalog_entries() + .unwrap() + .iter() + .any(|entry| entry.entry_id == "tool:git")); +} diff --git a/src/mxc-sdk/tests/policy_store_vectors.rs b/src/mxc-sdk/tests/policy_store_vectors.rs new file mode 100644 index 000000000..753574f5e --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_vectors.rs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Frozen vectors in `../conformance/vectors/`. They were generated from the +//! original TypeScript prototype and are now maintained by hand. + +#[path = "policy_store_common/mod.rs"] +mod common; + +use common::*; +use mxc_sdk::__policy_store::tooling::{ + canonical_json, is_absolute_path, normalize_path, parse_purl, path_key_segments, Json, +}; +use mxc_sdk::__policy_store::Platform; + +#[test] +fn canonical_json_vectors() { + let vectors = read_json( + crate_dir() + .join("conformance") + .join("vectors") + .join("canonical-json.json"), + ); + let cases = vectors.get("cases").and_then(Json::as_array).unwrap(); + assert!(cases.len() >= 5); + for case in cases { + let text = case.get("json").and_then(Json::as_str).unwrap(); + let value = Json::parse(text).unwrap(); + assert_eq!( + canonical_json(&value), + case.get("canonical").and_then(Json::as_str).unwrap(), + "{text}" + ); + } +} + +#[test] +fn path_vectors() { + let vectors = read_json( + crate_dir() + .join("conformance") + .join("vectors") + .join("paths.json"), + ); + for platform in Platform::ALL { + let cases = vectors + .get(platform.as_str()) + .and_then(Json::as_array) + .unwrap(); + assert!(cases.len() > 5); + for v in cases { + let input = v.get("input").and_then(Json::as_str).unwrap(); + assert_eq!( + is_absolute_path(input, platform), + v.get("absolute").and_then(Json::as_bool).unwrap(), + "{platform} isAbsolute {input:?}" + ); + if let Some(expected) = v.get("normalized").and_then(Json::as_str) { + assert_eq!( + normalize_path(input, platform), + expected, + "{platform} normalize {input:?}" + ); + } + let segments: Vec = v + .get("keySegments") + .and_then(Json::as_array) + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + path_key_segments(input, platform), + segments, + "{platform} keySegments {input:?}" + ); + } + } +} + +#[test] +fn malformed_percent_encoding_is_an_invalid_purl() { + assert_eq!(parse_purl("pkg:npm/npm@%E0%A4%A"), None); + let parsed = parse_purl("pkg:npm/npm@10.9.0").expect("valid purl"); + assert_eq!(parsed.package_type, "npm"); + assert_eq!(parsed.name, "npm"); + assert_eq!(parsed.version.as_deref(), Some("10.9.0")); + assert!(!parsed.has_qualifiers && !parsed.has_subpath); +} + +/// The embedded catalog must equal `catalog/` on disk (canonical content), +/// so `build.rs` can never silently skip or misorder a revision. +#[test] +fn embedded_catalog_matches_repository_catalog() { + let dir = crate_dir().join("catalog"); + let embedded = mxc_sdk::__policy_store::tooling::bundled_catalog_files(); + let mut on_disk = vec!["contract.v1.json".to_string(), "manifest.json".to_string()]; + let mut revisions: Vec = std::fs::read_dir(dir.join("revisions")) + .unwrap() + .map(|e| format!("revisions/{}", e.unwrap().file_name().to_string_lossy())) + .filter(|n| n.ends_with(".json")) + .collect(); + revisions.sort(); + on_disk.extend(revisions); + let names: Vec = embedded.iter().map(|(n, _)| n.to_string()).collect(); + assert_eq!(names, on_disk); + for (name, text) in embedded { + let disk = read_json(dir.join(name)); + assert_eq!( + canonical_json(&Json::parse(text).unwrap()), + canonical_json(&disk), + "{name}" + ); + } +}