From 2610457711924022f3010de6f5a9b00c0cfcc82d Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 22:06:37 -0700 Subject: [PATCH 01/50] feat(policy-catalog): add catalog contract, schemas, and first revision Prototype the known-tool policy catalog data from the MXC Policy Store spec (microsoft/mxc#1309) as a self-contained, extractable directory. Revision 2026-09-29.1 migrates the git/node/npm entries from the earlier config-floor prototype into per-platform variants with ordered identity and a published canonical SHA-256 digest. --- policy-catalog/LICENSE.md | 21 +++ policy-catalog/catalog/contract.v1.json | 35 ++++ policy-catalog/catalog/manifest.json | 11 ++ .../catalog/revisions/2026-09-29.1.json | 133 +++++++++++++ policy-catalog/schema/catalog.v1.schema.json | 174 ++++++++++++++++++ policy-catalog/schema/manifest.v1.schema.json | 27 +++ 6 files changed, 401 insertions(+) create mode 100644 policy-catalog/LICENSE.md create mode 100644 policy-catalog/catalog/contract.v1.json create mode 100644 policy-catalog/catalog/manifest.json create mode 100644 policy-catalog/catalog/revisions/2026-09-29.1.json create mode 100644 policy-catalog/schema/catalog.v1.schema.json create mode 100644 policy-catalog/schema/manifest.v1.schema.json diff --git a/policy-catalog/LICENSE.md b/policy-catalog/LICENSE.md new file mode 100644 index 000000000..79656060d --- /dev/null +++ b/policy-catalog/LICENSE.md @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE \ No newline at end of file diff --git a/policy-catalog/catalog/contract.v1.json b/policy-catalog/catalog/contract.v1.json new file mode 100644 index 000000000..5a4bac525 --- /dev/null +++ b/policy-catalog/catalog/contract.v1.json @@ -0,0 +1,35 @@ +{ + "$comment": "Catalog contract v1: the exact SandboxPolicy contract versions catalog entries may embed, and the symbol vocabulary entries may reference. 'context' symbols come only from ResolveContext.projectRoot; 'caller' symbols come only from ResolveContext.symbols; 'host' symbols are OS well-known folders the resolver may derive for the current host, which ResolveContext.symbols may override.", + "catalogSchemaVersion": "1", + "sandboxPolicyVersions": ["0.8.0-alpha", "0.9.0-alpha"], + "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." + }, + "node_prefix": { + "source": "caller", + "description": "Directory containing the node executable." + }, + "npm_prefix": { + "source": "caller", + "description": "Directory containing the npm and npx launchers." + }, + "npm_cache": { + "source": "caller", + "description": "The npm cache directory in effect for the invocation." + }, + "user_home": { + "source": "host", + "description": "The current user's home directory." + }, + "temp_dir": { + "source": "host", + "description": "The current user's temporary directory." + } + } +} diff --git a/policy-catalog/catalog/manifest.json b/policy-catalog/catalog/manifest.json new file mode 100644 index 000000000..46950ba88 --- /dev/null +++ b/policy-catalog/catalog/manifest.json @@ -0,0 +1,11 @@ +{ + "catalogSchemaVersion": "1", + "defaultRevision": "2026-09-29.1", + "revisions": [ + { + "catalogRevision": "2026-09-29.1", + "file": "revisions/2026-09-29.1.json", + "sha256": "00f0c644d5fc67783520a12b0d4eca565457d99a713efb61d97daa558c1a7a10" + } + ] +} diff --git a/policy-catalog/catalog/revisions/2026-09-29.1.json b/policy-catalog/catalog/revisions/2026-09-29.1.json new file mode 100644 index 000000000..823e5827c --- /dev/null +++ b/policy-catalog/catalog/revisions/2026-09-29.1.json @@ -0,0 +1,133 @@ +{ + "catalogSchemaVersion": "1", + "catalogRevision": "2026-09-29.1", + "entries": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "displayName": "Git", + "identity": [ + { "kind": "invocation-name", "names": ["git", "git.exe"] } + ], + "platformVariants": [ + { + "when": { "platform": "windows" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${git_prefix}"], + "readwritePaths": ["${project_root}"] + } + } + }, + { + "when": { "platform": "linux" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${git_prefix}"], + "readwritePaths": ["${project_root}"] + } + } + }, + { + "when": { "platform": "macos" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${git_prefix}"], + "readwritePaths": ["${project_root}"] + } + } + } + ], + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } + }, + { + "entryId": "tool:node", + "entryRevision": 1, + "displayName": "Node.js", + "identity": [ + { "kind": "invocation-name", "names": ["node", "node.exe"] } + ], + "platformVariants": [ + { + "when": { "platform": "windows" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["${node_prefix}"] } + } + }, + { + "when": { "platform": "linux" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["${node_prefix}"] } + } + }, + { + "when": { "platform": "macos" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["${node_prefix}"] } + } + } + ], + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } + }, + { + "entryId": "tool:npm", + "entryRevision": 1, + "displayName": "npm / npx", + "identity": [ + { "kind": "purl", "value": "pkg:npm/npm" }, + { "kind": "invocation-name", "names": ["npm", "npm.cmd", "npx", "npx.cmd"] } + ], + "platformVariants": [ + { + "when": { "platform": "windows" }, + "dependencies": [{ "entryId": "tool:node" }], + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${npm_prefix}"], + "readwritePaths": ["${project_root}", "${npm_cache}"] + } + } + }, + { + "when": { "platform": "linux" }, + "dependencies": [{ "entryId": "tool:node" }], + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${npm_prefix}"], + "readwritePaths": ["${project_root}", "${npm_cache}"] + } + } + }, + { + "when": { "platform": "macos" }, + "dependencies": [{ "entryId": "tool:node" }], + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${npm_prefix}"], + "readwritePaths": ["${project_root}", "${npm_cache}"] + } + } + } + ], + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } + } + ] +} diff --git a/policy-catalog/schema/catalog.v1.schema.json b/policy-catalog/schema/catalog.v1.schema.json new file mode 100644 index 000000000..a65202275 --- /dev/null +++ b/policy-catalog/schema/catalog.v1.schema.json @@ -0,0 +1,174 @@ +{ + "$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 (identity uniqueness, dependency closure, cycle freedom, symbol validity, unsafe paths, composition limits) are enforced by the catalog validator, not by this schema.", + "type": "object", + "additionalProperties": false, + "required": ["catalogSchemaVersion", "catalogRevision", "entries"], + "properties": { + "catalogSchemaVersion": { "const": "1" }, + "catalogRevision": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*$" }, + "entries": { "type": "array", "items": { "$ref": "#/$defs/entry" } } + }, + "$defs": { + "entry": { + "type": "object", + "additionalProperties": false, + "required": ["entryId", "entryRevision", "displayName", "identity", "platformVariants", "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 }, + "identity": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/identity" } }, + "platformVariants": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/platformVariant" } }, + "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:" }, + "versionRange": { "type": "string", "minLength": 1 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "names"], + "properties": { + "kind": { "const": "invocation-name" }, + "names": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + } + } + ] + }, + "platformVariant": { + "type": "object", + "additionalProperties": false, + "required": ["when", "sandboxPolicy"], + "properties": { + "when": { + "type": "object", + "additionalProperties": false, + "required": ["platform"], + "properties": { + "platform": { "enum": ["windows", "linux", "macos"] }, + "architecture": { "enum": ["x64", "arm64"] } + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["entryId"], + "properties": { + "entryId": { "type": "string" }, + "versionRange": { "type": "string", "minLength": 1 } + } + } + }, + "sandboxPolicy": { "$ref": "#/$defs/sandboxPolicy" } + } + }, + "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 } + } + } + } + } + }, + "sandboxPolicy": { + "description": "Catalog-supported subset of the MXC SandboxPolicy contract. Backend-specific keys (processContainer, seatbelt, lxc, containment, ...) are rejected so variants stay backend-neutral.", + "type": "object", + "additionalProperties": false, + "required": ["version"], + "properties": { + "version": { "type": "string" }, + "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, + "properties": { + "allowWindows": { "type": "boolean" }, + "clipboard": { "enum": ["none", "read", "write", "all"] }, + "allowInputInjection": { "type": "boolean" } + } + }, + "timeoutMs": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/policy-catalog/schema/manifest.v1.schema.json b/policy-catalog/schema/manifest.v1.schema.json new file mode 100644 index 000000000..0d7ad485f --- /dev/null +++ b/policy-catalog/schema/manifest.v1.schema.json @@ -0,0 +1,27 @@ +{ + "$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 and their integrity digests. sha256 is computed over the canonical JSON form of the revision file (recursively sorted keys, no insignificant whitespace, UTF-8).", + "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", "sha256"], + "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$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + } + } +} From a0af41c6d000fa8282e181d4f6a2e7fab5ed5faa Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 22:06:38 -0700 Subject: [PATCH 02/50] feat(policy-catalog): add TypeScript resolver and inspection library Implements resolveCatalogEntry (runtime lookup) separately from getCatalogInfo/listCatalogEntries (metadata inspection), integrity-verified read-only revision store, strong/weak identity matching with opt-in weak fallback, exact-then-neutral architecture variant selection, deterministic cycle-rejecting dependency closure, and the v1 filesystem-only composition vocabulary. No imports from, or changes to, the MXC SDKs. --- policy-catalog/README.md | 163 ++++ policy-catalog/typescript/.gitignore | 5 + policy-catalog/typescript/package-lock.json | 110 +++ policy-catalog/typescript/package.json | 41 + .../typescript/scripts/copy-catalog.mjs | 12 + .../typescript/src/canonical-json.ts | 31 + policy-catalog/typescript/src/catalog.ts | 718 ++++++++++++++++++ policy-catalog/typescript/src/errors.ts | 31 + policy-catalog/typescript/src/history.ts | 94 +++ policy-catalog/typescript/src/index.ts | 38 + policy-catalog/typescript/src/purl.ts | 40 + policy-catalog/typescript/src/resolver.ts | 428 +++++++++++ policy-catalog/typescript/src/store.ts | 200 +++++ policy-catalog/typescript/src/tooling.ts | 29 + policy-catalog/typescript/src/types.ts | 109 +++ .../typescript/src/version-range.ts | 119 +++ policy-catalog/typescript/tsconfig.json | 19 + 17 files changed, 2187 insertions(+) create mode 100644 policy-catalog/README.md create mode 100644 policy-catalog/typescript/.gitignore create mode 100644 policy-catalog/typescript/package-lock.json create mode 100644 policy-catalog/typescript/package.json create mode 100644 policy-catalog/typescript/scripts/copy-catalog.mjs create mode 100644 policy-catalog/typescript/src/canonical-json.ts create mode 100644 policy-catalog/typescript/src/catalog.ts create mode 100644 policy-catalog/typescript/src/errors.ts create mode 100644 policy-catalog/typescript/src/history.ts create mode 100644 policy-catalog/typescript/src/index.ts create mode 100644 policy-catalog/typescript/src/purl.ts create mode 100644 policy-catalog/typescript/src/resolver.ts create mode 100644 policy-catalog/typescript/src/store.ts create mode 100644 policy-catalog/typescript/src/tooling.ts create mode 100644 policy-catalog/typescript/src/types.ts create mode 100644 policy-catalog/typescript/src/version-range.ts create mode 100644 policy-catalog/typescript/tsconfig.json diff --git a/policy-catalog/README.md b/policy-catalog/README.md new file mode 100644 index 000000000..a66c0ab02 --- /dev/null +++ b/policy-catalog/README.md @@ -0,0 +1,163 @@ +# Known-tool policy catalog (prototype) + +> **Prototype.** This directory prototypes the standalone catalog and library +> proposed in the MXC Policy Store feature spec +> ([microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309), +> `docs/mxc-policy-store.md`). It is not an approved, shipped, or supported +> catalog. The spec describes the catalog as a time-limited bridge that is +> expected to be retired when Learning Mode provides the replacement workflow. + +The directory is self-contained so it can be moved to its own repository +(spec §6) without changes: + +```text +git filter-repo --subdirectory-filter policy-catalog +``` + +Nothing here imports from the rest of the MXC tree, and no MXC SDK imports +from here. The spec states that the proposal makes no MXC SDK changes. + +## Layout + +| Path | Contents | +|---|---| +| `catalog/contract.v1.json` | Catalog contract v1: the exact registered `SandboxPolicy` versions entries may embed, plus the symbol vocabulary. | +| `catalog/manifest.json` | Append-only list of published revisions, each with its canonical SHA-256 digest, plus the default revision. | +| `catalog/revisions/.json` | Immutable published catalog revisions. | +| `schema/` | JSON Schemas for revisions and the manifest. | +| `conformance/fixtures/` | Language-neutral conformance cases that every language binding must pass. | +| `typescript/` | TypeScript/JavaScript library, tests, and CI/contribution tooling. | +| `.github/workflows/ci.yml` | CI for the future standalone repository. It is inactive while this directory is nested. | + +## Library API (TypeScript) + +Runtime lookup and inspection are separate (spec §5): + +```ts +import { resolveCatalogEntry, listCatalogEntries, getCatalogInfo } from '@mxc-prototype/policy-catalog'; + +getCatalogInfo(); // { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' } +listCatalogEntries(); // metadata only; no policy bodies + +const result = resolveCatalogEntry( + { invocationName: 'npm', packageUrl: 'pkg:npm/npm@10.9.0' }, + { projectRoot: '/work/app', symbols: { npm_prefix: '/usr/local/bin', npm_cache: '/home/me/.npm', node_prefix: '/usr/local/bin' } }, +); +// result?.policy is a candidate minimum SandboxPolicy (a lower bound, not authorization). +``` + +- A no-match result is `undefined`, not an empty policy. Library failures + throw `PolicyCatalogError` with a stable `category`: `integrity`, + `validation`, `revision-unavailable`, `invalid-context`, `unsupported-host`, + or `composition-conflict`. +- Omitted `platform` and `architecture` values use the host. Architecture + comes from the OS machine type, not `process.arch`. An omitted + `catalogRevision` uses the installed default. An explicitly requested + revision that is missing is an error, never a substitution. +- Weak (invocation-name-only) identity matches only with + `allowWeakIdentityFallback: true`. A package-URL (`purl`) match is strong. A + version-range mismatch returns a warning, not a refusal. +- `projectRoot` and caller symbols are never fabricated. A required symbol + that is unresolved produces `undefined`. The resolver can derive only + `host` symbols (`user_home`, `temp_dir`), and only for the current host + platform. +- Lookup is local and synchronous. It makes no network calls and does not + run the tool. + +### Consumer boundary + +The library does not grant access, create a sandbox, write consumer state, or +compose with consumer policy. Consumers own the following decisions (spec +§2, §5.3): + +- whether lookup is enabled +- access-profile mapping, authorization, and elevation +- persistence, keyed by `entryId`, `entryRevision`, and `catalogRevision` +- composition with their own layers and ceilings +- approval UX and audit +- the final call to `createConfigFromPolicy()` or sandbox creation + +## Data model and rules implemented + +- **Versions (spec §4.1):** The four dimensions are `catalogSchemaVersion`, + `catalogRevision`, per-entry `entryRevision`, and per-variant + `sandboxPolicy.version`. The tool `versionRange` is advisory evidence only. +- **Identity (spec §4.3):** Predicates are ordered strongest first and + validated. `purl` is strong and `invocation-name` is weak. Identity keys + must be unique across entries; invocation names are compared + case-insensitively for this check. +- **Platform variants (spec §4.4):** Each variant is a complete policy per + `windows`/`linux`/`macos`, with an optional `x64`/`arm64` selector. Variants + are never merged. An exact architecture wins over the neutral variant. + Duplicate exact selectors and more than one neutral variant are rejected. + Backend-specific keys are rejected. +- **Dependencies and composition (spec §4.5):** Dependency closure is + depth-first, deterministic, and rejects cycles. Only + `filesystem.deniedPaths`, `readonlyPaths`, and `readwritePaths` compose + across entries: paths are normalized with platform rules and de-duplicated + per class. Cross-class equal or ancestor/descendant paths are rejected. + Mixed policy versions are rejected. `network`, `ui`, `timeoutMs`, and every + other field are rejected in a multi-entry closure. A dependency + `versionRange` is checked only for syntax and is returned as unevaluated + metadata. +- **Integrity and immutability (spec §10):** Each revision must match its + manifest digest, computed over canonical JSON. Loaded data is deep-frozen. + CI rejects edits to, or removal of, published revisions. A change requires + a new revision. + +## Contribution / CI pipeline (spec §7, §12 "Data") + +```text +cd typescript +npm ci +npm test # unit + conformance tests +npm run validate # full catalog validation pipeline +POLICY_CATALOG_BASE_REF=origin/main npm run validate # + immutability vs base +npm run smoke:package # pack, offline install, public-API smoke test +``` + +`validate` checks the following: + +- JSON Schema conformance +- manifest integrity digests +- the semantic contract (registered `SandboxPolicy` versions, identity + uniqueness and ordering, selectors, dependency closure and cycle freedom, + symbol validity, no literal, user-specific, or `..` paths, no wildcard + filesystem or network grants, and unsupported-field rejection) +- `entryRevision` monotonicity across revisions +- published-revision immutability against a base ref +- deterministic resolution for every selector +- package inclusion +- a conformance fixture for every entry + +### Publishing a new revision + +1. Copy the latest revision to `catalog/revisions/.json` and edit it. + Bump `entryRevision` for every entry that changed semantically. +2. Run `npm run build && npm run digest -- ../catalog/revisions/.json`. + Append the result to `manifest.json` and update `defaultRevision`. +3. Add or update conformance fixtures, then run `npm run validate`. + +Human review requirements (spec §7) apply to every catalog change. These +include catalog-owner and security-reviewer approval, identity, platform, and +version evidence, and regression evidence for reductions. This prototype +cannot enforce them. + +## Deferred / not implemented + +- **Rust (Cargo) and C#/.NET (NuGet) libraries (spec §6.1):** Not + implemented. The fixtures in `conformance/fixtures/` are the shared + contract those bindings must satisfy. +- **Validation of embedded policies against the real MXC `SandboxPolicy` + schemas:** This prototype validates a catalog-supported subset of the 0.8 + and 0.9 contracts and pins the registered versions in + `contract.v1.json`. Full validation against MXC's released schemas would + need a vendored or published schema artifact after extraction. +- **Cross-platform package CI and host-default tests on real ARM64 or + emulated hosts:** The workflow runs a Windows/Linux/macOS matrix, but the + emulation cases are covered only by unit tests with an injected host. +- **Private or enterprise overlays, and a hosted service:** Out of scope + (spec §13). +- **Catalog entries:** `tool:git`, `tool:node`, and `tool:npm` were migrated + from the earlier config-floor prototype. They have not been observed or + reviewed; `provenance.method` is `prototype-migration`. diff --git a/policy-catalog/typescript/.gitignore b/policy-catalog/typescript/.gitignore new file mode 100644 index 000000000..6aceb3e4e --- /dev/null +++ b/policy-catalog/typescript/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +dist-test/ +catalog/ +*.tgz diff --git a/policy-catalog/typescript/package-lock.json b/policy-catalog/typescript/package-lock.json new file mode 100644 index 000000000..d68394f75 --- /dev/null +++ b/policy-catalog/typescript/package-lock.json @@ -0,0 +1,110 @@ +{ + "name": "@mxc-prototype/policy-catalog", + "version": "0.0.0-prototype", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mxc-prototype/policy-catalog", + "version": "0.0.0-prototype", + "license": "MIT", + "devDependencies": { + "@types/node": "24.13.4", + "ajv": "8.20.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/policy-catalog/typescript/package.json b/policy-catalog/typescript/package.json new file mode 100644 index 000000000..5e5a3003b --- /dev/null +++ b/policy-catalog/typescript/package.json @@ -0,0 +1,41 @@ +{ + "name": "@mxc-prototype/policy-catalog", + "version": "0.0.0-prototype", + "private": true, + "description": "PROTOTYPE of the known-tool policy catalog library (TypeScript/JavaScript). Not published; package name is a placeholder pending the dedicated repository.", + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tooling": { + "types": "./dist/tooling.d.ts", + "import": "./dist/tooling.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "catalog/", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json && node scripts/copy-catalog.mjs", + "build:test": "tsc -p tsconfig.test.json", + "test": "npm run build && npm run build:test && node --test dist-test/*.test.js", + "validate": "npm run build && node scripts/validate-catalog.mjs", + "digest": "node scripts/catalog-digest.mjs", + "smoke:package": "npm run build && node scripts/package-smoke.mjs" + }, + "license": "MIT", + "devDependencies": { + "@types/node": "24.13.4", + "ajv": "8.20.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/policy-catalog/typescript/scripts/copy-catalog.mjs b/policy-catalog/typescript/scripts/copy-catalog.mjs new file mode 100644 index 000000000..f2c6e4510 --- /dev/null +++ b/policy-catalog/typescript/scripts/copy-catalog.mjs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Bundles the repository's catalog data into the package (`/catalog/`), +// so the library resolves locally without network access (spec §6.1). +import { cpSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const source = fileURLToPath(new URL('../../catalog/', import.meta.url)); +const target = fileURLToPath(new URL('../catalog/', import.meta.url)); +rmSync(target, { recursive: true, force: true }); +cpSync(source, target, { recursive: true }); diff --git a/policy-catalog/typescript/src/canonical-json.ts b/policy-catalog/typescript/src/canonical-json.ts new file mode 100644 index 000000000..403dfc586 --- /dev/null +++ b/policy-catalog/typescript/src/canonical-json.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createHash } from 'node:crypto'; + +/** + * Canonical JSON used for revision integrity digests: object keys sorted by + * UTF-16 code unit order at every level, arrays kept in order, and no + * insignificant whitespace. Formatting-only edits to a revision file therefore + * do not change its digest, while every semantic edit does. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + const encoded = JSON.stringify(value); + if (encoded === undefined) { + throw new TypeError('canonicalJson: value is not JSON-serializable'); + } + return encoded; + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; +} + +/** Lower-case hex SHA-256 of the canonical JSON form. */ +export function canonicalSha256(value: unknown): string { + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); +} diff --git a/policy-catalog/typescript/src/catalog.ts b/policy-catalog/typescript/src/catalog.ts new file mode 100644 index 000000000..94ed45a1f --- /dev/null +++ b/policy-catalog/typescript/src/catalog.ts @@ -0,0 +1,718 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { PolicyCatalogError } from './errors.js'; +import { isValidVersionRange } from './version-range.js'; +import { parsePurl } from './purl.js'; +import type { + CatalogArchitecture, + CatalogPlatform, + CatalogSandboxPolicy, +} from './types.js'; + +export const CATALOG_SCHEMA_VERSION = '1'; +export const PLATFORMS: readonly CatalogPlatform[] = ['windows', 'linux', 'macos']; +export const ARCHITECTURES: readonly CatalogArchitecture[] = ['x64', 'arm64']; + +/** Fields that the v1 contract can compose across entries (spec §4.5). */ +export const COMPOSABLE_FILESYSTEM_FIELDS = ['deniedPaths', 'readonlyPaths', 'readwritePaths'] as const; +export type AccessClass = (typeof COMPOSABLE_FILESYSTEM_FIELDS)[number]; + +export type SymbolSource = 'context' | 'caller' | 'host'; + +export interface CatalogContract { + catalogSchemaVersion: string; + sandboxPolicyVersions: string[]; + symbols: Record; +} + +export type IdentityPredicate = + | { kind: 'purl'; value: string; versionRange?: string } + | { kind: 'invocation-name'; names: string[] }; + +export interface PlatformVariant { + when: { platform: CatalogPlatform; architecture?: CatalogArchitecture }; + dependencies?: Array<{ entryId: string; versionRange?: string }>; + sandboxPolicy: CatalogSandboxPolicy; +} + +export interface CatalogEntry { + entryId: string; + entryRevision: number; + displayName: string; + identity: IdentityPredicate[]; + platformVariants: PlatformVariant[]; + provenance: { method: string; sourceRevision: string }; +} + +export interface CatalogRevision { + catalogSchemaVersion: string; + catalogRevision: string; + entries: CatalogEntry[]; +} + +/** Strength of an identity predicate kind. Order in this map is strongest first. */ +export const IDENTITY_STRENGTH: Record = { + purl: 'strong', + 'invocation-name': 'weak', +}; + +const REVISION_PATTERN = /^(\d{4}-\d{2}-\d{2})\.([1-9]\d*)$/; +const ENTRY_ID_PATTERN = /^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$/; +const SYMBOL_PATTERN = /\$\{([a-z][a-z0-9_]*)\}/g; +const ANCHORED_SYMBOL = /^\$\{([a-z][a-z0-9_]*)\}(?:[\\/]|$)/; + +function fail(message: string): never { + throw new PolicyCatalogError('validation', message); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function onlyFields(value: Record, allowed: readonly string[], at: string): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + fail(`unsupported field '${at}.${key}'`); + } + } +} + +function nonEmptyString(value: unknown, at: string): string { + if (typeof value !== 'string' || value.length === 0) { + fail(`'${at}' must be a non-empty string`); + } + return value; +} + +function stringArray(value: unknown, at: string, minItems = 0): string[] { + if (!Array.isArray(value) || value.length < minItems) { + fail(`'${at}' must be an array with at least ${minItems} item(s)`); + } + return value.map((item, index) => nonEmptyString(item, `${at}[${index}]`)); +} + +/** Compares two catalog revision identifiers (`YYYY-MM-DD.N`). */ +export function compareCatalogRevisions(left: string, right: string): number { + const a = REVISION_PATTERN.exec(left); + const b = REVISION_PATTERN.exec(right); + if (!a || !b) { + fail(`cannot compare malformed catalog revisions '${left}' and '${right}'`); + } + if (a[1] !== b[1]) { + return a[1] < b[1] ? -1 : 1; + } + return Math.sign(Number(a[2]) - Number(b[2])); +} + +export function isCatalogRevisionId(value: string): boolean { + return REVISION_PATTERN.test(value); +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +export function validateContract(raw: unknown): CatalogContract { + if (!isRecord(raw)) { + fail('contract root must be an object'); + } + onlyFields(raw, ['$comment', 'catalogSchemaVersion', 'sandboxPolicyVersions', 'symbols'], 'contract'); + if (raw.catalogSchemaVersion !== CATALOG_SCHEMA_VERSION) { + fail(`contract.catalogSchemaVersion must be '${CATALOG_SCHEMA_VERSION}'`); + } + const sandboxPolicyVersions = stringArray(raw.sandboxPolicyVersions, 'contract.sandboxPolicyVersions', 1); + if (!isRecord(raw.symbols)) { + fail('contract.symbols must be an object'); + } + const symbols: CatalogContract['symbols'] = {}; + for (const [name, definition] of Object.entries(raw.symbols)) { + if (!/^[a-z][a-z0-9_]*$/.test(name) || !isRecord(definition)) { + fail(`contract.symbols.${name} is malformed`); + } + onlyFields(definition, ['source', 'description'], `contract.symbols.${name}`); + if (definition.source !== 'context' && definition.source !== 'caller' && definition.source !== 'host') { + fail(`contract.symbols.${name}.source is unsupported`); + } + symbols[name] = { + source: definition.source, + description: nonEmptyString(definition.description, `contract.symbols.${name}.description`), + }; + } + return { catalogSchemaVersion: CATALOG_SCHEMA_VERSION, sandboxPolicyVersions, symbols }; +} + +// --------------------------------------------------------------------------- +// Paths and symbols +// --------------------------------------------------------------------------- + +function validateTemplatePath(value: string, at: string, contract: CatalogContract): void { + if (/[*?]/.test(value)) { + fail(`'${at}' contains a wildcard`); + } + if (value.replace(SYMBOL_PATTERN, '').includes('${')) { + fail(`'${at}' contains malformed symbol syntax`); + } + for (const match of value.matchAll(SYMBOL_PATTERN)) { + if (!(match[1] in contract.symbols)) { + fail(`'${at}' references unknown symbol '${match[1]}'`); + } + } + // Catalog data never ships a literal, machine-specific path (spec §4.2): + // every filesystem requirement must be anchored at a declared symbol. + if (!ANCHORED_SYMBOL.test(value)) { + fail(`'${at}' must start with a declared symbol; literal paths are not allowed`); + } + if (value.split(/[\\/]/).some(segment => segment === '..')) { + fail(`'${at}' must not contain '..' segments`); + } +} + +/** Returns the symbols a policy references, in first-seen order. */ +export function policySymbols(policy: CatalogSandboxPolicy): string[] { + const seen = new Set(); + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + for (const value of policy.filesystem?.[field] ?? []) { + for (const match of value.matchAll(SYMBOL_PATTERN)) { + seen.add(match[1]); + } + } + } + return [...seen]; +} + +/** Splits a path into comparable segments using the platform's path rules. */ +export function pathKeySegments(value: string, platform: CatalogPlatform): string[] { + const separators = platform === 'windows' ? /[\\/]+/ : /\/+/; + const segments = value.split(separators).filter((segment, index) => segment !== '.' && (segment !== '' || index === 0)); + const normalized = segments.length > 1 && segments[segments.length - 1] === '' ? segments.slice(0, -1) : segments; + return platform === 'windows' ? normalized.map(segment => segment.toLowerCase()) : normalized; +} + +function isSameOrNested(left: string[], right: string[]): boolean { + const shorter = left.length <= right.length ? left : right; + const longer = shorter === left ? right : left; + return shorter.every((segment, index) => longer[index] === segment); +} + +/** + * Finds the first equal or ancestor/descendant pair of paths that appear in + * different access classes. Returns a description or `undefined`. + */ +export function findCrossClassOverlap( + classes: Partial>, + platform: CatalogPlatform, +): string | undefined { + const fields = COMPOSABLE_FILESYSTEM_FIELDS.filter(field => (classes[field]?.length ?? 0) > 0); + for (let i = 0; i < fields.length; i += 1) { + for (let j = i + 1; j < fields.length; j += 1) { + for (const left of classes[fields[i]] ?? []) { + for (const right of classes[fields[j]] ?? []) { + if (isSameOrNested(pathKeySegments(left, platform), pathKeySegments(right, platform))) { + return `'${left}' (${fields[i]}) overlaps '${right}' (${fields[j]})`; + } + } + } + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Embedded SandboxPolicy (catalog-supported subset) +// --------------------------------------------------------------------------- + +const BACKEND_KEYS = ['containment', 'processContainer', 'appContainer', 'lxc', 'seatbelt', 'wslc', 'hyperlight', 'bwrap']; + +function validateNetworkRules(value: unknown, at: string, denyList: boolean): void { + if (!Array.isArray(value)) { + fail(`'${at}' must be an array`); + } + value.forEach((rule, index) => { + const ruleAt = `${at}[${index}]`; + if (!isRecord(rule)) { + fail(`'${ruleAt}' must be an object`); + } + onlyFields(rule, ['to', 'ports'], ruleAt); + if (rule.to === undefined && !denyList) { + fail(`'${ruleAt}' has no 'to'; wildcard network grants are not allowed`); + } + if (rule.to !== undefined) { + if (!Array.isArray(rule.to) || rule.to.length === 0) { + fail(`'${ruleAt}.to' must be a non-empty array`); + } + rule.to.forEach((peer, peerIndex) => { + const peerAt = `${ruleAt}.to[${peerIndex}]`; + if (!isRecord(peer)) { + fail(`'${peerAt}' must be an object`); + } + onlyFields(peer, ['cidr', 'except'], peerAt); + const cidr = nonEmptyString(peer.cidr, `${peerAt}.cidr`); + if (!denyList && /\/0$/.test(cidr)) { + fail(`'${peerAt}.cidr' is a wildcard network grant`); + } + if (peer.except !== undefined) { + stringArray(peer.except, `${peerAt}.except`); + } + }); + } + if (rule.ports !== undefined) { + if (!Array.isArray(rule.ports) || rule.ports.length === 0) { + fail(`'${ruleAt}.ports' must be a non-empty array`); + } + rule.ports.forEach((port, portIndex) => { + const portAt = `${ruleAt}.ports[${portIndex}]`; + if (!isRecord(port)) { + fail(`'${portAt}' must be an object`); + } + onlyFields(port, ['protocol', 'port', 'endPort'], portAt); + if (port.protocol !== undefined && !['tcp', 'udp', 'icmp', 'any'].includes(port.protocol as string)) { + fail(`'${portAt}.protocol' is unsupported`); + } + for (const key of ['port', 'endPort'] as const) { + const n = port[key]; + if (n !== undefined && (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 65535)) { + fail(`'${portAt}.${key}' must be an integer in 1..65535`); + } + } + if (port.endPort !== undefined && (port.port === undefined || (port.endPort as number) < (port.port as number))) { + fail(`'${portAt}.endPort' requires a lower or equal 'port'`); + } + }); + } + }); +} + +function validateSandboxPolicy(raw: unknown, at: string, contract: CatalogContract): CatalogSandboxPolicy { + if (!isRecord(raw)) { + fail(`'${at}' must be an object`); + } + for (const key of BACKEND_KEYS) { + if (key in raw) { + fail(`'${at}.${key}' names a containment backend; platform variants must stay backend-neutral`); + } + } + onlyFields(raw, ['version', 'filesystem', 'network', 'ui', 'timeoutMs'], at); + const version = nonEmptyString(raw.version, `${at}.version`); + if (!contract.sandboxPolicyVersions.includes(version)) { + fail(`'${at}.version' '${version}' is not a SandboxPolicy version registered in the catalog contract`); + } + if (raw.filesystem !== undefined) { + if (!isRecord(raw.filesystem)) { + fail(`'${at}.filesystem' must be an object`); + } + onlyFields(raw.filesystem, COMPOSABLE_FILESYSTEM_FIELDS, `${at}.filesystem`); + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + if (raw.filesystem[field] !== undefined) { + stringArray(raw.filesystem[field], `${at}.filesystem.${field}`).forEach((value, index) => + validateTemplatePath(value, `${at}.filesystem.${field}[${index}]`, contract)); + } + } + } + if (raw.network !== undefined) { + if (!isRecord(raw.network)) { + fail(`'${at}.network' must be an object`); + } + onlyFields(raw.network, ['egress', 'ingress'], `${at}.network`); + const { egress, ingress } = raw.network; + if (egress !== undefined) { + if (!isRecord(egress)) { + fail(`'${at}.network.egress' must be an object`); + } + onlyFields(egress, ['default', 'allow', 'deny'], `${at}.network.egress`); + if (egress.default !== undefined && egress.default !== 'deny') { + fail(`'${at}.network.egress.default' must be 'deny'; a default-allow grant is a wildcard`); + } + if (egress.allow !== undefined) { + validateNetworkRules(egress.allow, `${at}.network.egress.allow`, false); + } + if (egress.deny !== undefined) { + validateNetworkRules(egress.deny, `${at}.network.egress.deny`, true); + } + } + if (ingress !== undefined) { + if (!isRecord(ingress)) { + fail(`'${at}.network.ingress' must be an object`); + } + onlyFields(ingress, ['default', 'hostLoopback'], `${at}.network.ingress`); + if (ingress.default !== undefined && ingress.default !== 'deny') { + fail(`'${at}.network.ingress.default' must be 'deny'; a default-allow grant is a wildcard`); + } + if (ingress.hostLoopback !== undefined && ingress.hostLoopback !== 'allow' && ingress.hostLoopback !== 'deny') { + fail(`'${at}.network.ingress.hostLoopback' is unsupported`); + } + } + } + if (raw.ui !== undefined) { + if (!isRecord(raw.ui)) { + fail(`'${at}.ui' must be an object`); + } + onlyFields(raw.ui, ['allowWindows', 'clipboard', 'allowInputInjection'], `${at}.ui`); + for (const key of ['allowWindows', 'allowInputInjection'] as const) { + if (raw.ui[key] !== undefined && typeof raw.ui[key] !== 'boolean') { + fail(`'${at}.ui.${key}' must be a boolean`); + } + } + if (raw.ui.clipboard !== undefined && !['none', 'read', 'write', 'all'].includes(raw.ui.clipboard as string)) { + fail(`'${at}.ui.clipboard' is unsupported`); + } + } + if (raw.timeoutMs !== undefined + && (typeof raw.timeoutMs !== 'number' || !Number.isInteger(raw.timeoutMs) || raw.timeoutMs < 1)) { + fail(`'${at}.timeoutMs' must be a positive integer`); + } + return raw as unknown as CatalogSandboxPolicy; +} + +// --------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------- + +function validateIdentity(raw: unknown, at: string): IdentityPredicate[] { + if (!Array.isArray(raw) || raw.length === 0) { + fail(`'${at}' must be a non-empty array`); + } + const predicates = raw.map((item, index): IdentityPredicate => { + const itemAt = `${at}[${index}]`; + if (!isRecord(item)) { + fail(`'${itemAt}' must be an object`); + } + if (item.kind === 'purl') { + onlyFields(item, ['kind', 'value', 'versionRange'], itemAt); + const value = nonEmptyString(item.value, `${itemAt}.value`); + const parsed = parsePurl(value); + if (!parsed) { + fail(`'${itemAt}.value' is not a valid package URL`); + } + if (parsed.version !== undefined) { + fail(`'${itemAt}.value' must not pin a version; use 'versionRange'`); + } + if (item.versionRange !== undefined && !isValidVersionRange(nonEmptyString(item.versionRange, `${itemAt}.versionRange`))) { + fail(`'${itemAt}.versionRange' is not a valid version range`); + } + return { kind: 'purl', value, ...(item.versionRange !== undefined ? { versionRange: item.versionRange as string } : {}) }; + } + if (item.kind === 'invocation-name') { + onlyFields(item, ['kind', 'names'], itemAt); + const names = stringArray(item.names, `${itemAt}.names`, 1); + for (const name of names) { + if (/[\\/]/.test(name)) { + fail(`'${itemAt}.names' entry '${name}' must be a bare invocation name, not a path`); + } + } + return { kind: 'invocation-name', names }; + } + fail(`'${itemAt}.kind' is not a supported identity kind`); + }); + // identity is ordered strongest to weakest (spec §4.3). + let sawWeak = false; + predicates.forEach((predicate, index) => { + const weak = IDENTITY_STRENGTH[predicate.kind] === 'weak'; + if (!weak && sawWeak) { + fail(`'${at}[${index}]' is a strong predicate after a weak one; identity must be ordered strongest first`); + } + sawWeak ||= weak; + }); + return predicates; +} + +function validateVariants(raw: unknown, at: string, contract: CatalogContract): PlatformVariant[] { + if (!Array.isArray(raw) || raw.length === 0) { + fail(`'${at}' must be a non-empty array`); + } + const selectors = new Set(); + return raw.map((item, index): PlatformVariant => { + const itemAt = `${at}[${index}]`; + if (!isRecord(item)) { + fail(`'${itemAt}' must be an object`); + } + onlyFields(item, ['when', 'dependencies', 'sandboxPolicy'], itemAt); + if (!isRecord(item.when)) { + fail(`'${itemAt}.when' must be an object`); + } + onlyFields(item.when, ['platform', 'architecture'], `${itemAt}.when`); + const platform = item.when.platform as CatalogPlatform; + if (!PLATFORMS.includes(platform)) { + fail(`'${itemAt}.when.platform' must be one of ${PLATFORMS.join(', ')}`); + } + const architecture = item.when.architecture as CatalogArchitecture | undefined; + if (architecture !== undefined && !ARCHITECTURES.includes(architecture)) { + fail(`'${itemAt}.when.architecture' must be one of ${ARCHITECTURES.join(', ')}`); + } + const selector = `${platform}/${architecture ?? '*'}`; + if (selectors.has(selector)) { + fail(architecture === undefined + ? `'${itemAt}' is a second architecture-neutral variant for '${platform}'` + : `'${itemAt}' duplicates selector '${selector}'`); + } + selectors.add(selector); + let dependencies: PlatformVariant['dependencies']; + if (item.dependencies !== undefined) { + if (!Array.isArray(item.dependencies)) { + fail(`'${itemAt}.dependencies' must be an array`); + } + const seen = new Set(); + dependencies = item.dependencies.map((dependency, depIndex) => { + const depAt = `${itemAt}.dependencies[${depIndex}]`; + if (!isRecord(dependency)) { + fail(`'${depAt}' must be an object`); + } + onlyFields(dependency, ['entryId', 'versionRange'], depAt); + const entryId = nonEmptyString(dependency.entryId, `${depAt}.entryId`); + if (seen.has(entryId)) { + fail(`'${depAt}.entryId' '${entryId}' is listed twice`); + } + seen.add(entryId); + if (dependency.versionRange !== undefined + && !isValidVersionRange(nonEmptyString(dependency.versionRange, `${depAt}.versionRange`))) { + fail(`'${depAt}.versionRange' is not a valid version range`); + } + return { entryId, ...(dependency.versionRange !== undefined ? { versionRange: dependency.versionRange as string } : {}) }; + }); + } + return { + when: { platform, ...(architecture !== undefined ? { architecture } : {}) }, + ...(dependencies !== undefined ? { dependencies } : {}), + sandboxPolicy: validateSandboxPolicy(item.sandboxPolicy, `${itemAt}.sandboxPolicy`, contract), + }; + }); +} + +function validateEntry(raw: unknown, at: string, contract: CatalogContract): CatalogEntry { + if (!isRecord(raw)) { + fail(`'${at}' must be an object`); + } + onlyFields(raw, ['entryId', 'entryRevision', 'displayName', 'identity', 'platformVariants', 'provenance'], at); + const entryId = nonEmptyString(raw.entryId, `${at}.entryId`); + if (!ENTRY_ID_PATTERN.test(entryId)) { + fail(`'${at}.entryId' '${entryId}' must be namespaced, e.g. 'tool:name'`); + } + if (typeof raw.entryRevision !== 'number' || !Number.isInteger(raw.entryRevision) || raw.entryRevision < 1) { + fail(`'${at}.entryRevision' must be a positive integer`); + } + if (!isRecord(raw.provenance)) { + fail(`'${at}.provenance' must be an object`); + } + onlyFields(raw.provenance, ['method', 'sourceRevision'], `${at}.provenance`); + return { + entryId, + entryRevision: raw.entryRevision, + displayName: nonEmptyString(raw.displayName, `${at}.displayName`), + identity: validateIdentity(raw.identity, `${at}.identity`), + platformVariants: validateVariants(raw.platformVariants, `${at}.platformVariants`, contract), + provenance: { + method: nonEmptyString(raw.provenance.method, `${at}.provenance.method`), + sourceRevision: nonEmptyString(raw.provenance.sourceRevision, `${at}.provenance.sourceRevision`), + }, + }; +} + +// --------------------------------------------------------------------------- +// Variant selection and dependency closure (shared by validation and resolver) +// --------------------------------------------------------------------------- + +export interface VariantSelection { + variant: PlatformVariant; + exact: boolean; +} + +/** Selects the variant for a host: exact architecture first, then neutral (spec §4.4). */ +export function selectVariant( + entry: CatalogEntry, + platform: CatalogPlatform, + architecture: CatalogArchitecture, +): VariantSelection | undefined { + const forPlatform = entry.platformVariants.filter(variant => variant.when.platform === platform); + const exact = forPlatform.find(variant => variant.when.architecture === architecture); + if (exact) { + return { variant: exact, exact: true }; + } + const neutral = forPlatform.find(variant => variant.when.architecture === undefined); + return neutral ? { variant: neutral, exact: false } : undefined; +} + +export interface ClosureNode { + entry: CatalogEntry; + variant: PlatformVariant; + /** Range from the first edge that reached this node, in traversal order. */ + requiredVersionRange?: string; +} + +export type ClosureResult = + | { ok: true; nodes: ClosureNode[] } + | { ok: false; reason: 'cycle' | 'missing-entry' | 'unsupported-dependency'; detail: string }; + +/** + * Deterministic depth-first dependency closure. The root is first; each + * dependency follows in declaration order and appears once. Cycles are + * reported, never silently broken. + */ +export function dependencyClosure( + root: CatalogEntry, + rootVariant: PlatformVariant, + byId: ReadonlyMap, + platform: CatalogPlatform, + architecture: CatalogArchitecture, +): ClosureResult { + const nodes: ClosureNode[] = []; + const done = new Set(); + const stack: string[] = []; + let failure: ClosureResult | undefined; + + const visit = (entry: CatalogEntry, variant: PlatformVariant, range: string | undefined): void => { + if (failure) { + return; + } + if (stack.includes(entry.entryId)) { + failure = { ok: false, reason: 'cycle', detail: [...stack, entry.entryId].join(' -> ') }; + return; + } + if (done.has(entry.entryId)) { + return; + } + stack.push(entry.entryId); + nodes.push({ entry, variant, ...(range !== undefined ? { requiredVersionRange: range } : {}) }); + for (const dependency of variant.dependencies ?? []) { + const target = byId.get(dependency.entryId); + if (!target) { + failure = { ok: false, reason: 'missing-entry', detail: `${entry.entryId} -> ${dependency.entryId}` }; + return; + } + const selected = selectVariant(target, platform, architecture); + if (!selected) { + failure = { + ok: false, + reason: 'unsupported-dependency', + detail: `${entry.entryId} -> ${dependency.entryId} has no ${platform}/${architecture} variant`, + }; + return; + } + visit(target, selected.variant, dependency.versionRange); + } + stack.pop(); + done.add(entry.entryId); + }; + + visit(root, rootVariant, undefined); + return failure ?? { ok: true, nodes }; +} + +/** + * Composition limits for the v1 vocabulary (spec §4.5): returns a violation + * description, or `undefined` when the closure can be composed. + */ +export function compositionViolation(nodes: readonly ClosureNode[]): string | undefined { + const versions = new Set(nodes.map(node => node.variant.sandboxPolicy.version)); + if (versions.size > 1) { + return `mixed sandboxPolicy.version values (${[...versions].sort().join(', ')})`; + } + if (nodes.length < 2) { + return undefined; + } + for (const node of nodes) { + const policy = node.variant.sandboxPolicy; + for (const key of Object.keys(policy)) { + if (key !== 'version' && key !== 'filesystem') { + return `'${node.entry.entryId}' uses '${key}', which has no v1 cross-entry composition rule`; + } + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Revision-level validation +// --------------------------------------------------------------------------- + +function identityKeys(entry: CatalogEntry): string[] { + const keys: string[] = []; + for (const predicate of entry.identity) { + if (predicate.kind === 'purl') { + keys.push(`purl:${parsePurl(predicate.value)!.key}`); + } else { + // Invocation names are unique case-insensitively so matching is + // unambiguous on every platform, including Windows. + keys.push(...predicate.names.map(name => `invocation-name:${name.toLowerCase()}`)); + } + } + return keys; +} + +/** + * Validates one catalog revision against the v1 contract: shape, identity + * uniqueness and ordering, platform selectors, symbols, unsafe paths, backend + * neutrality, dependency closure and cycle-freedom for every supported + * platform/architecture, and the v1 composition limits. + */ +export function validateCatalogRevision(raw: unknown, contract: CatalogContract): CatalogRevision { + if (!isRecord(raw)) { + fail('catalog root must be an object'); + } + onlyFields(raw, ['catalogSchemaVersion', 'catalogRevision', 'entries'], 'catalog'); + if (raw.catalogSchemaVersion !== contract.catalogSchemaVersion) { + fail(`catalog.catalogSchemaVersion must be '${contract.catalogSchemaVersion}'`); + } + const catalogRevision = nonEmptyString(raw.catalogRevision, 'catalog.catalogRevision'); + if (!isCatalogRevisionId(catalogRevision)) { + fail(`catalog.catalogRevision '${catalogRevision}' must match YYYY-MM-DD.N`); + } + if (!Array.isArray(raw.entries)) { + fail('catalog.entries must be an array'); + } + const entries = raw.entries.map((entry, index) => validateEntry(entry, `entries[${index}]`, contract)); + + const byId = new Map(); + const identityOwners = new Map(); + for (const entry of entries) { + if (byId.has(entry.entryId)) { + fail(`duplicate entryId '${entry.entryId}'`); + } + byId.set(entry.entryId, entry); + for (const key of identityKeys(entry)) { + const owner = identityOwners.get(key); + if (owner !== undefined && owner !== entry.entryId) { + fail(`identity '${key}' is claimed by both '${owner}' and '${entry.entryId}'`); + } + identityOwners.set(key, entry.entryId); + } + } + + for (const entry of entries) { + for (const variant of entry.platformVariants) { + for (const dependency of variant.dependencies ?? []) { + if (!byId.has(dependency.entryId)) { + fail(`'${entry.entryId}' depends on unknown entry '${dependency.entryId}' in this revision`); + } + if (dependency.entryId === entry.entryId) { + fail(`'${entry.entryId}' depends on itself`); + } + } + } + for (const platform of PLATFORMS) { + for (const architecture of ARCHITECTURES) { + const selected = selectVariant(entry, platform, architecture); + if (!selected) { + continue; + } + const closure = dependencyClosure(entry, selected.variant, byId, platform, architecture); + if (!closure.ok) { + fail(`'${entry.entryId}' on ${platform}/${architecture}: ${closure.reason} (${closure.detail})`); + } + const violation = compositionViolation(closure.nodes); + if (violation) { + fail(`'${entry.entryId}' on ${platform}/${architecture}: ${violation}`); + } + const classes: Partial> = {}; + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + classes[field] = closure.nodes.flatMap(node => node.variant.sandboxPolicy.filesystem?.[field] ?? []); + } + const overlap = findCrossClassOverlap(classes, platform); + if (overlap) { + fail(`'${entry.entryId}' on ${platform}/${architecture}: ${overlap}`); + } + } + } + } + + return { catalogSchemaVersion: contract.catalogSchemaVersion, catalogRevision, entries }; +} diff --git a/policy-catalog/typescript/src/errors.ts b/policy-catalog/typescript/src/errors.ts new file mode 100644 index 000000000..ba7021e41 --- /dev/null +++ b/policy-catalog/typescript/src/errors.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Language-neutral library failure categories. Shared conformance fixtures + * assert on these values, so every language binding must preserve them. + * A library failure is never a "no match" result (spec §5). + */ +export type PolicyCatalogErrorCategory = + /** Bundled data does not match its published integrity digest. */ + | 'integrity' + /** Catalog, manifest, or contract data violates the catalog contract. */ + | 'validation' + /** An explicitly requested catalog revision is not installed. */ + | 'revision-unavailable' + /** The caller supplied an invalid ResolveContext or ToolCandidate. */ + | 'invalid-context' + /** The host platform/architecture cannot be mapped to a catalog selector. */ + | 'unsupported-host' + /** Resolved paths would require choosing between access classes. */ + | 'composition-conflict'; + +export class PolicyCatalogError extends Error { + readonly category: PolicyCatalogErrorCategory; + + constructor(category: PolicyCatalogErrorCategory, message: string) { + super(`[${category}] ${message}`); + this.name = 'PolicyCatalogError'; + this.category = category; + } +} diff --git a/policy-catalog/typescript/src/history.ts b/policy-catalog/typescript/src/history.ts new file mode 100644 index 000000000..bc1380bc9 --- /dev/null +++ b/policy-catalog/typescript/src/history.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { canonicalJson } from './canonical-json.js'; +import { PolicyCatalogError } from './errors.js'; +import { compareCatalogRevisions, type CatalogEntry, type CatalogRevision } from './catalog.js'; +import type { CatalogStore } from './store.js'; + +function entrySemanticKey(entry: CatalogEntry): string { + const { entryRevision: _ignored, ...rest } = entry; + return canonicalJson(rest); +} + +/** + * Checks entry-revision monotonicity between two consecutive published + * catalog revisions (spec §4.1, §10): a semantically changed entry must bump + * `entryRevision`; an unchanged entry must keep it; it never decreases. + */ +export function checkEntryRevisions(previous: CatalogRevision, next: CatalogRevision): string[] { + const errors: string[] = []; + if (compareCatalogRevisions(previous.catalogRevision, next.catalogRevision) >= 0) { + errors.push(`catalog revision '${next.catalogRevision}' must be newer than '${previous.catalogRevision}'`); + } + const before = new Map(previous.entries.map(entry => [entry.entryId, entry])); + for (const entry of next.entries) { + const old = before.get(entry.entryId); + if (!old) { + continue; + } + const changed = entrySemanticKey(old) !== entrySemanticKey(entry); + if (changed && entry.entryRevision <= old.entryRevision) { + errors.push(`${next.catalogRevision}: '${entry.entryId}' changed but entryRevision did not increase (${old.entryRevision} -> ${entry.entryRevision})`); + } else if (!changed && entry.entryRevision !== old.entryRevision) { + errors.push(`${next.catalogRevision}: '${entry.entryId}' is unchanged but entryRevision moved (${old.entryRevision} -> ${entry.entryRevision})`); + } + } + return errors; +} + +/** + * Validates every installed revision (integrity + contract) and the + * entry-revision chain between consecutive revisions. + */ +export function checkStoreHistory(store: CatalogStore): string[] { + const errors: string[] = []; + let previous: CatalogRevision | undefined; + for (const id of store.availableRevisions) { + let current: CatalogRevision; + try { + current = store.revision(id); + } catch (error) { + errors.push(error instanceof PolicyCatalogError ? error.message : String(error)); + previous = undefined; + continue; + } + if (previous) { + errors.push(...checkEntryRevisions(previous, current)); + } + previous = current; + } + return errors; +} + +/** Raw published state used to compare a proposed change against its base. */ +export interface PublishedState { + manifest: { revisions: Array<{ catalogRevision: string; file: string; sha256: string }> }; + /** Raw file text keyed by manifest `file`. */ + files: ReadonlyMap; +} + +/** + * Enforces immutability of published revisions across a proposed change + * (spec §10): previously published revisions keep their manifest entry, + * digest, and file bytes, and new revisions are only appended. + */ +export function checkPublishedImmutability(base: PublishedState, proposed: PublishedState): string[] { + const errors: string[] = []; + base.manifest.revisions.forEach((published, index) => { + const now = proposed.manifest.revisions[index]; + if (!now || now.catalogRevision !== published.catalogRevision) { + errors.push(`published revision '${published.catalogRevision}' was removed or reordered in the manifest`); + return; + } + if (now.file !== published.file || now.sha256 !== published.sha256) { + errors.push(`published revision '${published.catalogRevision}' manifest entry was modified`); + } + const baseBytes = base.files.get(published.file); + const proposedBytes = proposed.files.get(published.file); + if (baseBytes !== undefined && proposedBytes !== baseBytes) { + errors.push(`published revision file '${published.file}' was modified; publish a new revision instead`); + } + }); + return errors; +} diff --git a/policy-catalog/typescript/src/index.ts b/policy-catalog/typescript/src/index.ts new file mode 100644 index 000000000..60865e6c4 --- /dev/null +++ b/policy-catalog/typescript/src/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Runtime lookup (spec §5.1) and setup/inspection (spec §5.2) are separate +// entry points. Neither launches a sandbox, contacts a network service, or +// writes consumer state. +export { + resolveCatalogEntry, + listCatalogEntries, + getCatalogInfo, + PolicyCatalog, + nodeHostEnvironment, + type HostEnvironment, +} from './resolver.js'; + +export { + CatalogStore, + loadCatalogDirectory, + bundledCatalogStore, + type CatalogSource, + type CatalogManifest, + type ManifestRevision, +} from './store.js'; + +export { PolicyCatalogError, type PolicyCatalogErrorCategory } from './errors.js'; + +export type { + CatalogArchitecture, + CatalogEntryMetadata, + CatalogIdentityMetadata, + CatalogInfo, + CatalogNetworkRule, + CatalogPlatform, + CatalogSandboxPolicy, + ResolveContext, + ResolvedToolEntry, + ToolCandidate, +} from './types.js'; diff --git a/policy-catalog/typescript/src/purl.ts b/policy-catalog/typescript/src/purl.ts new file mode 100644 index 000000000..70f56512c --- /dev/null +++ b/policy-catalog/typescript/src/purl.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Parsed package URL, reduced to the parts identity matching uses. */ +export interface ParsedPurl { + /** `type/[namespace/]name` with the type lower-cased. Version-free. */ + key: string; + version?: string; +} + +/** + * Parses a package URL (`pkg:type/namespace/name@version?qualifiers#subpath`). + * Qualifiers and subpath do not participate in v1 identity matching. + */ +export function parsePurl(value: string): ParsedPurl | undefined { + if (!value.startsWith('pkg:')) { + return undefined; + } + let rest = value.slice('pkg:'.length); + rest = rest.split('#', 1)[0].split('?', 1)[0]; + const lastSlash = rest.lastIndexOf('/'); + const at = rest.lastIndexOf('@'); + let version: string | undefined; + if (at > lastSlash) { + version = decodeURIComponent(rest.slice(at + 1)); + rest = rest.slice(0, at); + } + const segments = rest.split('/'); + if (segments.length < 2 || segments.some(segment => segment.length === 0)) { + return undefined; + } + const [type, ...path] = segments; + if (!/^[a-zA-Z][a-zA-Z0-9.+-]*$/.test(type)) { + return undefined; + } + return { + key: `${type.toLowerCase()}/${path.join('/')}`, + ...(version !== undefined && version.length > 0 ? { version } : {}), + }; +} diff --git a/policy-catalog/typescript/src/resolver.ts b/policy-catalog/typescript/src/resolver.ts new file mode 100644 index 000000000..d705c530a --- /dev/null +++ b/policy-catalog/typescript/src/resolver.ts @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PolicyCatalogError } from './errors.js'; +import { parsePurl } from './purl.js'; +import { satisfiesVersionRange } from './version-range.js'; +import { + ARCHITECTURES, + COMPOSABLE_FILESYSTEM_FIELDS, + IDENTITY_STRENGTH, + PLATFORMS, + compositionViolation, + dependencyClosure, + findCrossClassOverlap, + pathKeySegments, + policySymbols, + selectVariant, + type AccessClass, + type CatalogEntry, + type CatalogRevision, + type IdentityPredicate, +} from './catalog.js'; +import { bundledCatalogStore, type CatalogStore } from './store.js'; +import type { + CatalogArchitecture, + CatalogEntryMetadata, + CatalogInfo, + CatalogPlatform, + CatalogSandboxPolicy, + ResolveContext, + ResolvedToolEntry, + ToolCandidate, +} from './types.js'; + +/** Host facts the resolver may use when the caller omits them. */ +export interface HostEnvironment { + platform(): CatalogPlatform; + architecture(): CatalogArchitecture; + /** Approved host-known symbols (`source: "host"` in the contract) for the current host. */ + symbol(name: string): string | undefined; +} + +function hostPlatform(): CatalogPlatform { + switch (os.platform()) { + case 'win32': + return 'windows'; + case 'darwin': + return 'macos'; + case 'linux': + return 'linux'; + default: + throw new PolicyCatalogError('unsupported-host', `host platform '${os.platform()}' has no catalog selector`); + } +} + +function hostArchitecture(): CatalogArchitecture { + // Use the OS-reported machine type rather than the Node.js build + // architecture (process.arch). Callers that know the tool's architecture + // should pass ResolveContext.architecture explicitly. + const machine = os.machine().toLowerCase(); + if (machine === 'x86_64' || machine === 'amd64' || machine === 'x64') { + return 'x64'; + } + if (machine === 'arm64' || machine === 'aarch64') { + return 'arm64'; + } + throw new PolicyCatalogError('unsupported-host', `host architecture '${machine}' has no catalog selector`); +} + +/** Default host environment backed by Node.js `os`. */ +export const nodeHostEnvironment: HostEnvironment = { + platform: hostPlatform, + architecture: hostArchitecture, + symbol(name) { + switch (name) { + case 'user_home': + return os.homedir() || undefined; + case 'temp_dir': + return os.tmpdir() || undefined; + default: + return undefined; + } + }, +}; + +interface IdentityMatch { + entry: CatalogEntry; + predicate: IdentityPredicate; + warnings: string[]; +} + +function pathApi(platform: CatalogPlatform): path.PlatformPath { + return platform === 'windows' ? path.win32 : path.posix; +} + +function normalizePath(value: string, platform: CatalogPlatform): string { + const api = pathApi(platform); + const normalized = api.normalize(value); + const root = api.parse(normalized).root; + return normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized; +} + +function validateContext(ctx: ResolveContext): void { + if (ctx.platform !== undefined && !PLATFORMS.includes(ctx.platform)) { + throw new PolicyCatalogError('invalid-context', `ResolveContext.platform '${String(ctx.platform)}' is unsupported`); + } + if (ctx.architecture !== undefined && !ARCHITECTURES.includes(ctx.architecture)) { + throw new PolicyCatalogError('invalid-context', `ResolveContext.architecture '${String(ctx.architecture)}' is unsupported`); + } +} + +function validateTool(tool: ToolCandidate): void { + if (typeof tool?.invocationName !== 'string' || tool.invocationName.length === 0) { + throw new PolicyCatalogError('invalid-context', 'ToolCandidate.invocationName must be a non-empty string'); + } + if (/[\\/]/.test(tool.invocationName)) { + throw new PolicyCatalogError('invalid-context', 'ToolCandidate.invocationName must be a bare name, not a path'); + } +} + +/** + * Read-only view over one catalog store. Resolution and inspection are + * separate operations; neither mutates catalog data or any consumer state. + */ +export class PolicyCatalog { + private readonly store: CatalogStore; + private readonly host: HostEnvironment; + + constructor(store: CatalogStore, host: HostEnvironment = nodeHostEnvironment) { + this.store = store; + this.host = host; + } + + // ------------------------------------------------------------------------- + // Setup and inspection (spec §5.2) + // ------------------------------------------------------------------------- + + getCatalogInfo(): CatalogInfo { + const revision = this.store.revision(); + return { catalogSchemaVersion: revision.catalogSchemaVersion, catalogRevision: revision.catalogRevision }; + } + + /** Metadata for every entry in the installed revision, ordered by `entryId`. Never includes a policy body. */ + listCatalogEntries(): CatalogEntryMetadata[] { + const revision = this.store.revision(); + return [...revision.entries] + .sort((a, b) => (a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0)) + .map(entry => ({ + catalogRevision: revision.catalogRevision, + entryId: entry.entryId, + entryRevision: entry.entryRevision, + displayName: entry.displayName, + identity: entry.identity.map(predicate => (predicate.kind === 'purl' + ? { kind: 'purl' as const, value: predicate.value, ...(predicate.versionRange !== undefined ? { versionRange: predicate.versionRange } : {}) } + : { kind: 'invocation-name' as const, names: [...predicate.names] })), + platformVariants: entry.platformVariants.map(variant => ({ + platform: variant.when.platform, + ...(variant.when.architecture !== undefined ? { architecture: variant.when.architecture } : {}), + dependencyEntryIds: (variant.dependencies ?? []).map(dependency => dependency.entryId), + sandboxPolicyVersion: variant.sandboxPolicy.version, + })), + provenance: { ...entry.provenance }, + })); + } + + // ------------------------------------------------------------------------- + // Runtime lookup (spec §5.1) + // ------------------------------------------------------------------------- + + /** + * Resolves one tool's candidate minimum requirement. + * + * Returns `undefined` when there is no acceptable identity match, no + * applicable platform variant, or a required symbol is unresolved. Throws + * {@link PolicyCatalogError} for library failures (integrity, unavailable + * revision, invalid context, composition conflict), which are never + * reported as "no match". + * + * The result is a candidate lower bound, not authorization: consumers apply + * their own authorization, ceilings, persistence, composition, and audit. + */ + resolveCatalogEntry(tool: ToolCandidate, ctx: ResolveContext = {}): ResolvedToolEntry | undefined { + validateTool(tool); + validateContext(ctx); + this.validateContextSymbols(ctx); + const revision = this.store.revision(ctx.catalogRevision); + + const platform = ctx.platform ?? this.host.platform(); + const architecture = ctx.architecture ?? this.host.architecture(); + + const match = this.matchIdentity(revision, tool, ctx.allowWeakIdentityFallback === true, platform); + if (!match) { + return undefined; + } + + const selected = selectVariant(match.entry, platform, architecture); + if (!selected) { + return undefined; + } + + const byId = new Map(revision.entries.map(entry => [entry.entryId, entry])); + const closure = dependencyClosure(match.entry, selected.variant, byId, platform, architecture); + if (!closure.ok) { + // A validated revision cannot reach this; treat it as corrupt data. + throw new PolicyCatalogError('validation', `dependency resolution failed: ${closure.reason} (${closure.detail})`); + } + const violation = compositionViolation(closure.nodes); + if (violation) { + throw new PolicyCatalogError('validation', `composition rejected: ${violation}`); + } + + const symbols = this.resolveSymbols(closure.nodes.flatMap(node => policySymbols(node.variant.sandboxPolicy)), ctx, platform); + if (!symbols) { + return undefined; + } + + const policy = this.composePolicy(closure.nodes.map(node => node.variant.sandboxPolicy), symbols, platform); + + return { + entryId: match.entry.entryId, + entryRevision: match.entry.entryRevision, + catalogRevision: revision.catalogRevision, + matchedIdentity: { kind: match.predicate.kind, strength: IDENTITY_STRENGTH[match.predicate.kind] }, + resolvedDependencies: closure.nodes.slice(1).map(node => ({ + entryId: node.entry.entryId, + entryRevision: node.entry.entryRevision, + ...(node.requiredVersionRange !== undefined ? { requiredVersionRange: node.requiredVersionRange } : {}), + })), + policy, + warnings: match.warnings, + }; + } + + private matchIdentity( + revision: CatalogRevision, + tool: ToolCandidate, + allowWeak: boolean, + platform: CatalogPlatform, + ): IdentityMatch | undefined { + const warnings: string[] = []; + let strong: IdentityMatch | undefined; + + if (tool.packageUrl !== undefined) { + const parsed = parsePurl(tool.packageUrl); + if (!parsed) { + throw new PolicyCatalogError('invalid-context', `ToolCandidate.packageUrl '${tool.packageUrl}' is not a valid package URL`); + } + for (const entry of revision.entries) { + const predicate = entry.identity.find(p => p.kind === 'purl' && parsePurl(p.value)?.key === parsed.key); + if (predicate) { + strong = { entry, predicate, warnings }; + break; + } + } + if (strong && strong.predicate.kind === 'purl' && strong.predicate.versionRange !== undefined) { + const evidence = tool.detectedVersion ?? parsed.version; + if (evidence !== undefined) { + const satisfied = satisfiesVersionRange(evidence, strong.predicate.versionRange); + if (satisfied === false) { + warnings.push(`detected version '${evidence}' is outside the reviewed range '${strong.predicate.versionRange}' for ${strong.entry.entryId}`); + } else if (satisfied === undefined) { + warnings.push(`detected version '${evidence}' could not be compared with the reviewed range '${strong.predicate.versionRange}' for ${strong.entry.entryId}`); + } + } + } + } + + const weakEntry = this.matchInvocationName(revision, tool.invocationName, platform); + if (strong) { + if (weakEntry && weakEntry.entry.entryId !== strong.entry.entryId) { + warnings.push(`invocation name '${tool.invocationName}' is listed by ${weakEntry.entry.entryId}; the package URL match ${strong.entry.entryId} takes precedence`); + } + return strong; + } + if (!allowWeak || !weakEntry) { + return undefined; + } + if (tool.packageUrl !== undefined) { + warnings.push(`package URL '${tool.packageUrl}' matched no entry; fell back to invocation-name identity`); + } + warnings.push(`${weakEntry.entry.entryId} matched only by invocation name '${tool.invocationName}' (weak identity)`); + return { ...weakEntry, warnings }; + } + + private matchInvocationName( + revision: CatalogRevision, + name: string, + platform: CatalogPlatform, + ): Omit | undefined { + // Windows and default macOS volumes resolve executables case-insensitively; + // Linux does not. Validation guarantees case-insensitive uniqueness across + // entries, so either comparison selects at most one entry. + const fold = platform === 'linux' ? (value: string) => value : (value: string) => value.toLowerCase(); + const key = fold(name); + for (const entry of revision.entries) { + const predicate = entry.identity.find(p => p.kind === 'invocation-name' && p.names.some(n => fold(n) === key)); + if (predicate) { + return { entry, predicate }; + } + } + return undefined; + } + + /** Caller symbol names are checked before matching so an invalid context is never reported as no-match. */ + private validateContextSymbols(ctx: ResolveContext): void { + const contract = this.store.contract; + if (ctx.symbols !== undefined) { + for (const name of Object.keys(ctx.symbols)) { + const definition = contract.symbols[name]; + if (!definition) { + throw new PolicyCatalogError('invalid-context', `ResolveContext.symbols.${name} is not a catalog symbol`); + } + if (definition.source === 'context') { + throw new PolicyCatalogError('invalid-context', `symbol '${name}' is supplied through ResolveContext.projectRoot, not symbols`); + } + } + } + } + + private resolveSymbols(names: string[], ctx: ResolveContext, platform: CatalogPlatform): Map | undefined { + const contract = this.store.contract; + const api = pathApi(platform); + const values = new Map(); + for (const name of new Set(names)) { + const definition = contract.symbols[name]; + let value: string | undefined; + if (definition.source === 'context') { + value = ctx.projectRoot; + } else { + value = ctx.symbols?.[name]; + // Host-derived symbols are only meaningful for the current host; they + // are never derived for an explicitly different target platform. + if (value === undefined && definition.source === 'host' && platform === this.host.platform()) { + value = this.host.symbol(name); + } + } + if (value === undefined) { + return undefined; + } + if (value.includes('${') || !api.isAbsolute(value)) { + throw new PolicyCatalogError('invalid-context', `symbol '${name}' must resolve to an absolute ${platform} path`); + } + values.set(name, value); + } + return values; + } + + private composePolicy( + policies: CatalogSandboxPolicy[], + symbols: ReadonlyMap, + platform: CatalogPlatform, + ): CatalogSandboxPolicy { + const classes: Partial> = {}; + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + const seen = new Set(); + const out: string[] = []; + for (const policy of policies) { + for (const template of policy.filesystem?.[field] ?? []) { + const resolved = normalizePath( + template.replace(/\$\{([a-z][a-z0-9_]*)\}/g, (_m, name: string) => symbols.get(name)!), + platform, + ); + const key = pathKeySegments(resolved, platform).join('\u0000'); + if (!seen.has(key)) { + seen.add(key); + out.push(resolved); + } + } + } + if (out.length > 0) { + classes[field] = out; + } + } + const overlap = findCrossClassOverlap(classes, platform); + if (overlap) { + // Symbol values can make distinct templates collide. The resolver never + // chooses an access class implicitly. + throw new PolicyCatalogError('composition-conflict', `resolved paths overlap across access classes: ${overlap}`); + } + + const root = policies[0]; + const result: CatalogSandboxPolicy = { version: root.version }; + if (root.filesystem !== undefined || Object.keys(classes).length > 0) { + result.filesystem = {}; + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + if (classes[field]) { + result.filesystem[field] = classes[field]; + } + } + } + // Composition validation guarantees these fields only exist on a + // single-entry closure, so they are copied, never merged. + if (root.network !== undefined) { + result.network = structuredClone(root.network); + } + if (root.ui !== undefined) { + result.ui = structuredClone(root.ui); + } + if (root.timeoutMs !== undefined) { + result.timeoutMs = root.timeoutMs; + } + return result; + } +} + +let defaultCatalog: PolicyCatalog | undefined; + +function bundled(): PolicyCatalog { + defaultCatalog ??= new PolicyCatalog(bundledCatalogStore()); + return defaultCatalog; +} + +/** Resolves one tool against the bundled catalog. See {@link PolicyCatalog.resolveCatalogEntry}. */ +export function resolveCatalogEntry(tool: ToolCandidate, ctx?: ResolveContext): ResolvedToolEntry | undefined { + return bundled().resolveCatalogEntry(tool, ctx); +} + +/** Lists bundled catalog entry metadata. See {@link PolicyCatalog.listCatalogEntries}. */ +export function listCatalogEntries(): CatalogEntryMetadata[] { + return bundled().listCatalogEntries(); +} + +/** Reports the bundled catalog schema version and revision. */ +export function getCatalogInfo(): CatalogInfo { + return bundled().getCatalogInfo(); +} diff --git a/policy-catalog/typescript/src/store.ts b/policy-catalog/typescript/src/store.ts new file mode 100644 index 000000000..87fceb69d --- /dev/null +++ b/policy-catalog/typescript/src/store.ts @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; +import { canonicalSha256 } from './canonical-json.js'; +import { PolicyCatalogError } from './errors.js'; +import { + CATALOG_SCHEMA_VERSION, + compareCatalogRevisions, + isCatalogRevisionId, + validateCatalogRevision, + validateContract, + type CatalogContract, + type CatalogRevision, +} from './catalog.js'; + +/** One published revision as listed in `catalog/manifest.json`. */ +export interface ManifestRevision { + catalogRevision: string; + file: string; + sha256: string; +} + +export interface CatalogManifest { + catalogSchemaVersion: string; + defaultRevision: string; + revisions: ManifestRevision[]; +} + +/** In-memory source for a catalog store. Used by the file loader and by fixtures. */ +export interface CatalogSource { + contract: unknown; + manifest: unknown; + /** Reads the raw JSON of a manifest `file` entry. */ + readRevision(file: string): unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function invalid(message: string): never { + throw new PolicyCatalogError('validation', message); +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value as Record)) { + deepFreeze(child); + } + } + return value; +} + +export function validateManifest(raw: unknown): CatalogManifest { + if (!isRecord(raw)) { + invalid('manifest root must be an object'); + } + for (const key of Object.keys(raw)) { + if (!['catalogSchemaVersion', 'defaultRevision', 'revisions'].includes(key)) { + invalid(`unsupported field 'manifest.${key}'`); + } + } + if (raw.catalogSchemaVersion !== CATALOG_SCHEMA_VERSION) { + invalid(`manifest.catalogSchemaVersion must be '${CATALOG_SCHEMA_VERSION}'`); + } + if (!Array.isArray(raw.revisions) || raw.revisions.length === 0) { + invalid('manifest.revisions must be a non-empty array'); + } + const revisions = raw.revisions.map((item, index): ManifestRevision => { + const at = `manifest.revisions[${index}]`; + if (!isRecord(item)) { + invalid(`'${at}' must be an object`); + } + for (const key of Object.keys(item)) { + if (!['catalogRevision', 'file', 'sha256'].includes(key)) { + invalid(`unsupported field '${at}.${key}'`); + } + } + const { catalogRevision, file, sha256 } = item; + if (typeof catalogRevision !== 'string' || !isCatalogRevisionId(catalogRevision)) { + invalid(`'${at}.catalogRevision' must match YYYY-MM-DD.N`); + } + if (file !== `revisions/${catalogRevision}.json`) { + invalid(`'${at}.file' must be 'revisions/${catalogRevision}.json'`); + } + if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256)) { + invalid(`'${at}.sha256' must be a lower-case hex SHA-256 digest`); + } + return { catalogRevision, file, sha256 }; + }); + // Published revisions are append-only and strictly increasing. + for (let index = 1; index < revisions.length; index += 1) { + if (compareCatalogRevisions(revisions[index - 1].catalogRevision, revisions[index].catalogRevision) >= 0) { + invalid(`manifest.revisions must be strictly increasing ('${revisions[index].catalogRevision}')`); + } + } + const defaultRevision = raw.defaultRevision; + if (typeof defaultRevision !== 'string' || !revisions.some(r => r.catalogRevision === defaultRevision)) { + invalid('manifest.defaultRevision must name a listed revision'); + } + return { catalogSchemaVersion: CATALOG_SCHEMA_VERSION, defaultRevision, revisions }; +} + +/** + * Read-only access to the locally installed, integrity-validated catalog + * revisions. Revisions are loaded lazily, verified against the manifest + * digest, validated against the contract, and deep-frozen. + */ +export class CatalogStore { + readonly contract: CatalogContract; + readonly manifest: CatalogManifest; + private readonly source: CatalogSource; + private readonly loaded = new Map(); + + constructor(source: CatalogSource) { + this.source = source; + this.contract = deepFreeze(validateContract(source.contract)); + this.manifest = deepFreeze(validateManifest(source.manifest)); + } + + get defaultRevision(): string { + return this.manifest.defaultRevision; + } + + get availableRevisions(): string[] { + return this.manifest.revisions.map(revision => revision.catalogRevision); + } + + /** + * Returns the requested revision, or the installed default when omitted. + * An explicitly requested revision that is not installed is an error; it is + * never substituted with a different revision. + */ + revision(catalogRevision?: string): CatalogRevision { + const id = catalogRevision ?? this.manifest.defaultRevision; + const cached = this.loaded.get(id); + if (cached) { + return cached; + } + const listed = this.manifest.revisions.find(revision => revision.catalogRevision === id); + if (!listed) { + throw new PolicyCatalogError('revision-unavailable', `catalog revision '${id}' is not installed`); + } + let raw: unknown; + try { + raw = this.source.readRevision(listed.file); + } catch (error) { + throw new PolicyCatalogError('integrity', `catalog revision '${id}' could not be read: ${(error as Error).message}`); + } + const digest = canonicalSha256(raw); + if (digest !== listed.sha256) { + throw new PolicyCatalogError( + 'integrity', + `catalog revision '${id}' digest ${digest} does not match the published digest ${listed.sha256}`, + ); + } + const revision = validateCatalogRevision(raw, this.contract); + if (revision.catalogRevision !== id) { + throw new PolicyCatalogError('integrity', `file '${listed.file}' declares revision '${revision.catalogRevision}', expected '${id}'`); + } + deepFreeze(revision); + this.loaded.set(id, revision); + return revision; + } +} + +function readJson(url: URL): unknown { + return JSON.parse(readFileSync(url, 'utf8')); +} + +/** Loads a store from a catalog directory (`contract.v1.json`, `manifest.json`, `revisions/`). */ +export function loadCatalogDirectory(directory: URL): CatalogStore { + const base = directory.href.endsWith('/') ? directory : new URL(`${directory.href}/`); + let contract: unknown; + let manifest: unknown; + try { + contract = readJson(new URL('contract.v1.json', base)); + manifest = readJson(new URL('manifest.json', base)); + } catch (error) { + throw new PolicyCatalogError('integrity', `catalog data could not be read: ${(error as Error).message}`); + } + return new CatalogStore({ + contract, + manifest, + readRevision: file => readJson(new URL(file, base)), + }); +} + +let bundled: CatalogStore | undefined; + +/** + * The catalog bundled with this package. The build copies the repository's + * `catalog/` directory next to the compiled output (`/../catalog/`). + */ +export function bundledCatalogStore(): CatalogStore { + bundled ??= loadCatalogDirectory(new URL('../catalog/', import.meta.url)); + return bundled; +} diff --git a/policy-catalog/typescript/src/tooling.ts b/policy-catalog/typescript/src/tooling.ts new file mode 100644 index 000000000..7fd983e9f --- /dev/null +++ b/policy-catalog/typescript/src/tooling.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Contribution and CI tooling (spec §7). Not part of the runtime lookup API; +// exported under the `./tooling` subpath so validation scripts and tests can +// share the resolver's exact rules. +export { canonicalJson, canonicalSha256 } from './canonical-json.js'; +export { + validateCatalogRevision, + validateContract, + compareCatalogRevisions, + selectVariant, + PLATFORMS, + ARCHITECTURES, + type CatalogContract, + type CatalogEntry, + type CatalogRevision, + type IdentityPredicate, + type PlatformVariant, +} from './catalog.js'; +export { validateManifest } from './store.js'; +export { + checkEntryRevisions, + checkStoreHistory, + checkPublishedImmutability, + type PublishedState, +} from './history.js'; +export { isValidVersionRange, satisfiesVersionRange } from './version-range.js'; +export { parsePurl } from './purl.js'; diff --git a/policy-catalog/typescript/src/types.ts b/policy-catalog/typescript/src/types.ts new file mode 100644 index 000000000..558c234f3 --- /dev/null +++ b/policy-catalog/typescript/src/types.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Catalog platform selector values (spec §4.4). */ +export type CatalogPlatform = 'windows' | 'linux' | 'macos'; + +/** Catalog architecture selector values (spec §4.4). */ +export type CatalogArchitecture = 'x64' | 'arm64'; + +/** + * The catalog-supported subset of MXC's `SandboxPolicy` authoring contract. + * + * This library deliberately does not import the MXC SDK. The shape is + * structurally compatible with the MXC SDK `SandboxPolicy` type so a consumer + * can pass a (reviewed, authorized) result to `createConfigFromPolicy()`. + * Backend-specific keys are not part of the catalog vocabulary. + */ +export interface CatalogSandboxPolicy { + version: string; + filesystem?: { + deniedPaths?: string[]; + readonlyPaths?: string[]; + readwritePaths?: string[]; + }; + network?: { + egress?: { + default?: 'allow' | 'deny'; + allow?: CatalogNetworkRule[]; + deny?: CatalogNetworkRule[]; + }; + ingress?: { + default?: 'allow' | 'deny'; + hostLoopback?: 'allow' | 'deny'; + }; + }; + ui?: { + allowWindows?: boolean; + clipboard?: 'none' | 'read' | 'write' | 'all'; + allowInputInjection?: boolean; + }; + timeoutMs?: number; +} + +export interface CatalogNetworkRule { + to?: Array<{ cidr: string; except?: string[] }>; + ports?: Array<{ protocol?: 'tcp' | 'udp' | 'icmp' | 'any'; port?: number; endPort?: number }>; +} + +/** Runtime lookup input (spec §5.1). */ +export interface ToolCandidate { + invocationName: string; + packageUrl?: string; + detectedVersion?: string; +} + +/** Runtime lookup context (spec §5.1). */ +export interface ResolveContext { + projectRoot?: string; + symbols?: Record; + platform?: CatalogPlatform; + architecture?: CatalogArchitecture; + catalogRevision?: string; + allowWeakIdentityFallback?: boolean; +} + +/** Runtime lookup result (spec §5.1). */ +export interface ResolvedToolEntry { + entryId: string; + entryRevision: number; + catalogRevision: string; + matchedIdentity: { kind: string; strength: 'strong' | 'weak' }; + resolvedDependencies: Array<{ + entryId: string; + entryRevision: number; + requiredVersionRange?: string; + }>; + policy: CatalogSandboxPolicy; + warnings: string[]; +} + +/** Inspection metadata (spec §5.2). */ +export type CatalogIdentityMetadata = + | { kind: 'purl'; value: string; versionRange?: string } + | { kind: 'invocation-name'; names: string[] }; + +/** Inspection metadata (spec §5.2). No policy body is exposed. */ +export interface CatalogEntryMetadata { + catalogRevision: string; + entryId: string; + entryRevision: number; + displayName: string; + identity: CatalogIdentityMetadata[]; + platformVariants: Array<{ + platform: CatalogPlatform; + architecture?: CatalogArchitecture; + dependencyEntryIds: string[]; + sandboxPolicyVersion: string; + }>; + provenance: { + method: string; + sourceRevision: string; + }; +} + +/** Result of `getCatalogInfo()` (spec §5.2). */ +export interface CatalogInfo { + catalogSchemaVersion: string; + catalogRevision: string; +} diff --git a/policy-catalog/typescript/src/version-range.ts b/policy-catalog/typescript/src/version-range.ts new file mode 100644 index 000000000..4f00425b0 --- /dev/null +++ b/policy-catalog/typescript/src/version-range.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Minimal, dependency-free version-range support for catalog v1. + * + * Grammar (a deliberate subset of npm semver ranges, identical in every + * language binding): + * + * range := comparatorSet ( "||" comparatorSet )* + * comparatorSet := comparator ( " " comparator )* + * comparator := op? version + * op := ">=" | "<=" | ">" | "<" | "=" + * version := N ( "." N ( "." N )? )? + * + * Missing minor/patch components are zero-filled. A comparator with no + * operator is a prefix match (`22` means `>=22.0.0 <23.0.0`). Prerelease and + * build metadata on evidence versions are ignored. + */ + +type Triple = [number, number, number]; +type Op = '>=' | '<=' | '>' | '<' | '=' | 'prefix'; +interface Comparator { + op: Op; + version: Triple; + parts: number; +} + +const COMPARATOR = /^(>=|<=|>|<|=)?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/; + +function parseComparator(token: string): Comparator | undefined { + const match = COMPARATOR.exec(token); + if (!match) { + return undefined; + } + const parts = match[4] !== undefined ? 3 : match[3] !== undefined ? 2 : 1; + return { + op: (match[1] as Op | undefined) ?? 'prefix', + version: [Number(match[2]), Number(match[3] ?? 0), Number(match[4] ?? 0)], + parts, + }; +} + +function parseRange(range: string): Comparator[][] | undefined { + if (range.trim().length === 0) { + return undefined; + } + const sets: Comparator[][] = []; + for (const alternative of range.split('||')) { + const tokens = alternative.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + return undefined; + } + const comparators: Comparator[] = []; + for (const token of tokens) { + const comparator = parseComparator(token); + if (!comparator) { + return undefined; + } + comparators.push(comparator); + } + sets.push(comparators); + } + return sets; +} + +/** Returns true when `range` uses the supported v1 range grammar. */ +export function isValidVersionRange(range: string): boolean { + return parseRange(range) !== undefined; +} + +/** Parses tool version evidence such as `v22.3.1` or `10.9.0-rc.1`. */ +export function parseVersionEvidence(value: string): Triple | undefined { + const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/.exec(value.trim()); + if (!match) { + return undefined; + } + return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; +} + +function compare(left: Triple, right: Triple): number { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) { + return left[index] < right[index] ? -1 : 1; + } + } + return 0; +} + +function satisfiesComparator(version: Triple, comparator: Comparator): boolean { + const order = compare(version, comparator.version); + switch (comparator.op) { + case '>=': + return order >= 0; + case '<=': + return order <= 0; + case '>': + return order > 0; + case '<': + return order < 0; + case '=': + return order === 0; + case 'prefix': + return version.slice(0, comparator.parts).every((part, index) => part === comparator.version[index]); + } +} + +/** + * Evaluates `version` against `range`. Returns `undefined` when either value + * cannot be evaluated; callers treat that as "not verified", never a match. + */ +export function satisfiesVersionRange(version: string, range: string): boolean | undefined { + const parsedVersion = parseVersionEvidence(version); + const parsedRange = parseRange(range); + if (!parsedVersion || !parsedRange) { + return undefined; + } + return parsedRange.some(set => set.every(comparator => satisfiesComparator(parsedVersion, comparator))); +} diff --git a/policy-catalog/typescript/tsconfig.json b/policy-catalog/typescript/tsconfig.json new file mode 100644 index 000000000..890a3f63f --- /dev/null +++ b/policy-catalog/typescript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} From a3df6f61edd18dedf8fb81a0c37e6c4194ef98a1 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 22:06:38 -0700 Subject: [PATCH 03/50] test(policy-catalog): add unit tests and shared conformance fixtures --- .../conformance/fixtures/bundled-catalog.json | 110 +++++++++++ .../fixtures/synthetic-catalog.json | 187 ++++++++++++++++++ .../typescript/test/conformance.test.ts | 29 +++ policy-catalog/typescript/test/helpers.ts | 104 ++++++++++ .../typescript/test/resolver.test.ts | 155 +++++++++++++++ policy-catalog/typescript/test/store.test.ts | 103 ++++++++++ .../typescript/test/validation.test.ts | 136 +++++++++++++ .../typescript/test/version-range.test.ts | 37 ++++ policy-catalog/typescript/tsconfig.test.json | 10 + 9 files changed, 871 insertions(+) create mode 100644 policy-catalog/conformance/fixtures/bundled-catalog.json create mode 100644 policy-catalog/conformance/fixtures/synthetic-catalog.json create mode 100644 policy-catalog/typescript/test/conformance.test.ts create mode 100644 policy-catalog/typescript/test/helpers.ts create mode 100644 policy-catalog/typescript/test/resolver.test.ts create mode 100644 policy-catalog/typescript/test/store.test.ts create mode 100644 policy-catalog/typescript/test/validation.test.ts create mode 100644 policy-catalog/typescript/test/version-range.test.ts create mode 100644 policy-catalog/typescript/tsconfig.test.json diff --git a/policy-catalog/conformance/fixtures/bundled-catalog.json b/policy-catalog/conformance/fixtures/bundled-catalog.json new file mode 100644 index 000000000..9c1aebc33 --- /dev/null +++ b/policy-catalog/conformance/fixtures/bundled-catalog.json @@ -0,0 +1,110 @@ +{ + "$comment": "Language-neutral conformance cases against the bundled catalog. Every binding (TypeScript, Rust, C#) must produce the same `expect` for each case. `expect: null` is the language's no-match value (undefined / None / null). `expectError` names a PolicyCatalogError category.", + "catalog": "bundled", + "cases": [ + { + "name": "git by invocation name with weak fallback on windows", + "tool": { "invocationName": "git.exe" }, + "context": { + "platform": "windows", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "C:\\work\\repo", + "symbols": { "git_prefix": "C:\\Program Files\\Git\\cmd" } + }, + "expect": { + "entryId": "tool:git", + "entryRevision": 1, + "catalogRevision": "2026-09-29.1", + "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, + "resolvedDependencies": [], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["C:\\Program Files\\Git\\cmd"], + "readwritePaths": ["C:\\work\\repo"] + } + }, + "warnings": ["tool:git matched only by invocation name 'git.exe' (weak identity)"] + } + }, + { + "name": "node by invocation name on linux", + "tool": { "invocationName": "node" }, + "context": { + "platform": "linux", + "architecture": "arm64", + "allowWeakIdentityFallback": true, + "symbols": { "node_prefix": "/usr/local/bin" } + }, + "expect": { + "entryId": "tool:node", + "entryRevision": 1, + "catalogRevision": "2026-09-29.1", + "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, + "resolvedDependencies": [], + "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["/usr/local/bin"] } }, + "warnings": ["tool:node matched only by invocation name 'node' (weak identity)"] + } + }, + { + "name": "npm by package URL composes the node dependency on macos", + "tool": { "invocationName": "npx", "packageUrl": "pkg:npm/npm@10.9.0" }, + "context": { + "platform": "macos", + "architecture": "arm64", + "projectRoot": "/Users/dev/app/", + "symbols": { + "npm_prefix": "/opt/homebrew/bin", + "npm_cache": "/Users/dev/.npm", + "node_prefix": "/opt/homebrew/bin" + } + }, + "expect": { + "entryId": "tool:npm", + "entryRevision": 1, + "catalogRevision": "2026-09-29.1", + "matchedIdentity": { "kind": "purl", "strength": "strong" }, + "resolvedDependencies": [{ "entryId": "tool:node", "entryRevision": 1 }], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["/opt/homebrew/bin"], + "readwritePaths": ["/Users/dev/app", "/Users/dev/.npm"] + } + }, + "warnings": [] + } + }, + { + "name": "invocation name alone is not accepted without opt-in", + "tool": { "invocationName": "npm" }, + "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/w", "symbols": { "npm_prefix": "/p", "npm_cache": "/c", "node_prefix": "/n" } }, + "expect": null + }, + { + "name": "unknown tool is no match, never an empty policy", + "tool": { "invocationName": "cargo" }, + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true }, + "expect": null + }, + { + "name": "unresolved required symbol is no match, never a partial policy", + "tool": { "invocationName": "git" }, + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "/usr/bin" } }, + "expect": null + }, + { + "name": "unavailable explicit revision is an error, not a substitution", + "tool": { "invocationName": "git" }, + "context": { "platform": "linux", "architecture": "x64", "catalogRevision": "2099-01-01.1" }, + "expectError": "revision-unavailable" + }, + { + "name": "relative symbol value is rejected", + "tool": { "invocationName": "node" }, + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "node_prefix": "bin" } }, + "expectError": "invalid-context" + } + ] +} diff --git a/policy-catalog/conformance/fixtures/synthetic-catalog.json b/policy-catalog/conformance/fixtures/synthetic-catalog.json new file mode 100644 index 000000000..3ae9ee5e4 --- /dev/null +++ b/policy-catalog/conformance/fixtures/synthetic-catalog.json @@ -0,0 +1,187 @@ +{ + "$comment": "Language-neutral conformance cases against an inline synthetic catalog revision (not published). Covers identity strength, version-range warnings, architecture variants, dependency metadata, and composition. See bundled-catalog.json for the case format.", + "catalog": { + "catalogSchemaVersion": "1", + "catalogRevision": "2000-01-01.1", + "entries": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "displayName": "Base runtime", + "identity": [{ "kind": "invocation-name", "names": ["base"] }], + "platformVariants": [ + { + "when": { "platform": "linux" }, + "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/lib", "${git_prefix}/share"] } } + }, + { + "when": { "platform": "windows", "architecture": "arm64" }, + "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\arm64"] } } + }, + { + "when": { "platform": "windows", "architecture": "x64" }, + "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\x64"] } } + } + ], + "provenance": { "method": "fixture", "sourceRevision": "fixture" } + }, + { + "entryId": "tool:app", + "entryRevision": 5, + "displayName": "App", + "identity": [ + { "kind": "purl", "value": "pkg:npm/app", "versionRange": ">=2 <3" }, + { "kind": "invocation-name", "names": ["app", "app.cmd"] } + ], + "platformVariants": [ + { + "when": { "platform": "linux", "architecture": "arm64" }, + "dependencies": [{ "entryId": "tool:base", "versionRange": ">=22" }], + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { "readwritePaths": ["${project_root}"], "readonlyPaths": ["${git_prefix}/lib"] } + } + }, + { + "when": { "platform": "linux" }, + "dependencies": [{ "entryId": "tool:base" }], + "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"] } } + }, + { + "when": { "platform": "windows" }, + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { "readwritePaths": ["${project_root}"] }, + "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } + } + } + ], + "provenance": { "method": "fixture", "sourceRevision": "fixture" } + } + ] + }, + "cases": [ + { + "name": "exact architecture variant precedes the neutral variant; dependency range is unevaluated metadata", + "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app", "detectedVersion": "2.4.1" }, + "context": { "platform": "linux", "architecture": "arm64", "projectRoot": "/w/proj", "symbols": { "git_prefix": "/opt/g" } }, + "expect": { + "entryId": "tool:app", + "entryRevision": 5, + "catalogRevision": "2000-01-01.1", + "matchedIdentity": { "kind": "purl", "strength": "strong" }, + "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2, "requiredVersionRange": ">=22" }], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["/opt/g/lib", "/opt/g/share"], "readwritePaths": ["/w/proj"] } + }, + "warnings": [] + } + }, + { + "name": "neutral variant is the fallback when no exact architecture exists", + "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, + "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, + "expect": { + "entryId": "tool:app", + "entryRevision": 5, + "catalogRevision": "2000-01-01.1", + "matchedIdentity": { "kind": "purl", "strength": "strong" }, + "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["/g/lib", "/g/share"], "readwritePaths": ["/w"] } + }, + "warnings": [] + } + }, + { + "name": "version-range mismatch is a warning, not a refusal", + "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app@3.0.0" }, + "context": { "platform": "windows", "architecture": "x64", "projectRoot": "D:\\src" }, + "expect": { + "entryId": "tool:app", + "entryRevision": 5, + "catalogRevision": "2000-01-01.1", + "matchedIdentity": { "kind": "purl", "strength": "strong" }, + "resolvedDependencies": [], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { "readwritePaths": ["D:\\src"] }, + "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } + }, + "warnings": ["detected version '3.0.0' is outside the reviewed range '>=2 <3' for tool:app"] + } + }, + { + "name": "no platform variant is no match", + "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, + "context": { "platform": "macos", "architecture": "arm64", "projectRoot": "/w" }, + "expect": null + }, + { + "name": "explicit x64 selects the x64 variant when an arm64 variant also exists", + "tool": { "invocationName": "base" }, + "context": { "platform": "windows", "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "C:\\g" } }, + "expect": { + "entryId": "tool:base", + "entryRevision": 2, + "catalogRevision": "2000-01-01.1", + "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, + "resolvedDependencies": [], + "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["C:\\g\\x64"] } }, + "warnings": ["tool:base matched only by invocation name 'base' (weak identity)"] + } + }, + { + "name": "invocation names are case-insensitive on windows", + "tool": { "invocationName": "APP.CMD" }, + "context": { "platform": "windows", "architecture": "arm64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\p" }, + "expect": { + "entryId": "tool:app", + "entryRevision": 5, + "catalogRevision": "2000-01-01.1", + "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, + "resolvedDependencies": [], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { "readwritePaths": ["C:\\p"] }, + "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } + }, + "warnings": ["tool:app matched only by invocation name 'APP.CMD' (weak identity)"] + } + }, + { + "name": "invocation names are case-sensitive on linux", + "tool": { "invocationName": "APP" }, + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/p", "symbols": { "git_prefix": "/g" } }, + "expect": null + }, + { + "name": "unmatched package URL falls back to weak identity only with opt-in", + "tool": { "invocationName": "app", "packageUrl": "pkg:npm/other" }, + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, + "expect": { + "entryId": "tool:app", + "entryRevision": 5, + "catalogRevision": "2000-01-01.1", + "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, + "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], + "policy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["/g/lib", "/g/share"], "readwritePaths": ["/w"] } + }, + "warnings": [ + "package URL 'pkg:npm/other' matched no entry; fell back to invocation-name identity", + "tool:app matched only by invocation name 'app' (weak identity)" + ] + } + }, + { + "name": "symbol values that make access classes overlap are a composition conflict", + "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, + "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/g", "symbols": { "git_prefix": "/g" } }, + "expectError": "composition-conflict" + } + ] +} diff --git a/policy-catalog/typescript/test/conformance.test.ts b/policy-catalog/typescript/test/conformance.test.ts new file mode 100644 index 000000000..fccf70c60 --- /dev/null +++ b/policy-catalog/typescript/test/conformance.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Runs the language-neutral conformance fixtures (conformance/fixtures/*.json). +// Other language bindings run the same files with the same expectations. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { bundledCatalog, catalogFor, errorCategory, fixtureFiles, fixturesDir, readJson } from './helpers.js'; + +for (const name of fixtureFiles()) { + const fixture = readJson(new URL(name, fixturesDir)); + describe(`conformance: ${name}`, () => { + const catalog = fixture.catalog === 'bundled' ? bundledCatalog() : catalogFor(fixture.catalog); + for (const testCase of fixture.cases) { + it(testCase.name, () => { + if (testCase.expectError !== undefined) { + assert.equal(errorCategory(() => catalog.resolveCatalogEntry(testCase.tool, testCase.context)), testCase.expectError); + return; + } + const actual = catalog.resolveCatalogEntry(testCase.tool, testCase.context); + if (testCase.expect === null) { + assert.equal(actual, undefined); + } else { + assert.deepEqual(actual, testCase.expect); + } + }); + } + }); +} diff --git a/policy-catalog/typescript/test/helpers.ts b/policy-catalog/typescript/test/helpers.ts new file mode 100644 index 000000000..252b2127f --- /dev/null +++ b/policy-catalog/typescript/test/helpers.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync, readdirSync } from 'node:fs'; +import { + CatalogStore, + PolicyCatalog, + PolicyCatalogError, + bundledCatalogStore, + type HostEnvironment, +} from '../dist/index.js'; +import { canonicalSha256 } from '../dist/tooling.js'; + +export const fixturesDir = new URL('../../conformance/fixtures/', import.meta.url); +export const catalogDir = new URL('../../catalog/', import.meta.url); + +export function readJson(url: URL): any { + return JSON.parse(readFileSync(url, 'utf8')); +} + +export const contract = readJson(new URL('contract.v1.json', catalogDir)); + +/** Host with fixed facts, so tests never depend on the machine running them. */ +export function fixedHost( + platform: 'windows' | 'linux' | 'macos' = 'linux', + architecture: 'x64' | 'arm64' = 'x64', + symbols: Record = {}, +): HostEnvironment { + return { + platform: () => platform, + architecture: () => architecture, + symbol: name => symbols[name], + }; +} + +/** Builds an in-memory store whose manifest publishes the given revisions. */ +export function storeFor(revisions: any[], options: { defaultRevision?: string; digests?: Record } = {}): CatalogStore { + const files = new Map(); + const manifest = { + catalogSchemaVersion: '1', + defaultRevision: options.defaultRevision ?? revisions[revisions.length - 1].catalogRevision, + revisions: revisions.map(revision => { + const file = `revisions/${revision.catalogRevision}.json`; + files.set(file, revision); + return { + catalogRevision: revision.catalogRevision, + file, + sha256: options.digests?.[revision.catalogRevision] ?? canonicalSha256(revision), + }; + }), + }; + return new CatalogStore({ + contract, + manifest, + readRevision: file => structuredClone(files.get(file)), + }); +} + +export function catalogFor(revision: any, host: HostEnvironment = fixedHost()): PolicyCatalog { + return new PolicyCatalog(storeFor([revision]), host); +} + +export function bundledCatalog(host: HostEnvironment = fixedHost()): PolicyCatalog { + return new PolicyCatalog(bundledCatalogStore(), host); +} + +export function fixtureFiles(): string[] { + return readdirSync(fixturesDir).filter(name => name.endsWith('.json')).sort(); +} + +export function errorCategory(fn: () => unknown): string | undefined { + try { + fn(); + } catch (error) { + if (error instanceof PolicyCatalogError) { + return error.category; + } + throw error; + } + return undefined; +} + +/** Minimal valid revision with one entry, for mutation in validation tests. */ +export function revisionWith(entries: any[], catalogRevision = '2000-01-01.1'): any { + return { catalogSchemaVersion: '1', catalogRevision, entries }; +} + +export function entry(entryId: string, overrides: Record = {}): any { + const name = entryId.split(':')[1]; + return { + entryId, + entryRevision: 1, + displayName: name, + identity: [{ kind: 'invocation-name', names: [name] }], + platformVariants: [ + { + when: { platform: 'linux' }, + sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readwritePaths: ['${project_root}'] } }, + }, + ], + provenance: { method: 'test', sourceRevision: 'test' }, + ...overrides, + }; +} diff --git a/policy-catalog/typescript/test/resolver.test.ts b/policy-catalog/typescript/test/resolver.test.ts new file mode 100644 index 000000000..a8eea0fb2 --- /dev/null +++ b/policy-catalog/typescript/test/resolver.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + PolicyCatalog, + getCatalogInfo, + listCatalogEntries, + resolveCatalogEntry, +} from '../dist/index.js'; +import { bundledCatalog, catalogFor, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; + +describe('runtime lookup', () => { + it('omitted context uses host platform/architecture, installed revision, and no weak fallback', () => { + const host = fixedHost('windows', 'arm64'); + const catalog = catalogFor(revisionWith([ + entry('tool:a', { + identity: [{ kind: 'purl', value: 'pkg:npm/a' }, { kind: 'invocation-name', names: ['a'] }], + platformVariants: [ + { when: { platform: 'windows', architecture: 'x64' }, sandboxPolicy: { version: '0.9.0-alpha', timeoutMs: 1 } }, + { when: { platform: 'windows', architecture: 'arm64' }, sandboxPolicy: { version: '0.9.0-alpha', timeoutMs: 2 } }, + ], + }), + ]), host); + assert.equal(catalog.resolveCatalogEntry({ invocationName: 'a' }), undefined, 'weak identity requires opt-in'); + const result = catalog.resolveCatalogEntry({ invocationName: 'a', packageUrl: 'pkg:npm/a' }); + assert.equal(result?.policy.timeoutMs, 2, 'host arm64 selected'); + assert.equal(result?.catalogRevision, '2000-01-01.1'); + const explicit = catalog.resolveCatalogEntry({ invocationName: 'a', packageUrl: 'pkg:npm/a' }, { architecture: 'x64' }); + assert.equal(explicit?.policy.timeoutMs, 1, 'explicit architecture wins'); + }); + + it('never fabricates projectRoot or caller symbols', () => { + const catalog = bundledCatalog(); + assert.equal(catalog.resolveCatalogEntry({ invocationName: 'git' }, { allowWeakIdentityFallback: true }), undefined); + }); + + it('derives host symbols only for the current host platform, and caller values override them', () => { + const revision = revisionWith([ + entry('tool:t', { + platformVariants: [ + { when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, + { when: { platform: 'macos' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, + ], + }), + ]); + const catalog = catalogFor(revision, fixedHost('linux', 'x64', { user_home: '/home/me' })); + const weak = { allowWeakIdentityFallback: true }; + assert.deepEqual(catalog.resolveCatalogEntry({ invocationName: 't' }, weak)?.policy.filesystem, { readonlyPaths: ['/home/me/.cfg'] }); + assert.equal(catalog.resolveCatalogEntry({ invocationName: 't' }, { ...weak, platform: 'macos' }), undefined); + assert.deepEqual( + catalog.resolveCatalogEntry({ invocationName: 't' }, { ...weak, symbols: { user_home: '/srv/u' } })?.policy.filesystem, + { readonlyPaths: ['/srv/u/.cfg'] }, + ); + }); + + it('rejects invalid context and inputs as library failures, not no-match', () => { + const catalog = bundledCatalog(); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: '' })), 'invalid-context'); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: '/usr/bin/git' })), 'invalid-context'); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'git' }, { platform: 'plan9' as any })), 'invalid-context'); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'git' }, { symbols: { nope: '/x' } })), 'invalid-context'); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'git' }, { symbols: { project_root: '/x' } })), 'invalid-context'); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'npm', packageUrl: 'npm' })), 'invalid-context'); + }); + + it('host detection failure is a library error, not a guessed selection', () => { + const host = { ...fixedHost(), architecture: (): never => { throw new Error('unknown machine'); } }; + const catalog = new PolicyCatalog(storeFor([revisionWith([entry('tool:t')])]), host); + assert.throws(() => catalog.resolveCatalogEntry({ invocationName: 't' }, { allowWeakIdentityFallback: true }), /unknown machine/); + }); + + it('is deterministic and returns caller-owned copies', () => { + const catalog = bundledCatalog(); + const ctx = { + platform: 'linux' as const, + architecture: 'x64' as const, + projectRoot: '/p', + symbols: { npm_prefix: '/n', npm_cache: '/c', node_prefix: '/n' }, + }; + const tool = { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }; + const first = catalog.resolveCatalogEntry(tool, ctx)!; + first.policy.filesystem!.readwritePaths!.push('/mutated'); + first.warnings.push('mutated'); + const second = catalog.resolveCatalogEntry(tool, ctx)!; + assert.deepEqual(second.policy.filesystem, { readonlyPaths: ['/n'], readwritePaths: ['/p', '/c'] }); + assert.deepEqual(second.warnings, []); + }); + + it('de-duplicates normalized paths within an access class using platform path rules', () => { + const catalog = catalogFor(revisionWith([ + entry('tool:w', { + platformVariants: [{ + when: { platform: 'windows' }, + sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}\\'] } }, + }], + }), + ])); + const result = catalog.resolveCatalogEntry( + { invocationName: 'w' }, + { platform: 'windows', architecture: 'x64', allowWeakIdentityFallback: true, symbols: { git_prefix: 'C:\\Tools', node_prefix: 'c:\\tools' } }, + ); + assert.deepEqual(result?.policy.filesystem, { readonlyPaths: ['C:\\Tools'] }); + }); + + it('dependency chain resolution composes each entry once, in declaration order', () => { + const variant = (deps: string[], path: string) => ({ + when: { platform: 'linux' }, + ...(deps.length ? { dependencies: deps.map(entryId => ({ entryId })) } : {}), + sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${project_root}/${path}`] } }, + }); + const catalog = catalogFor(revisionWith([ + entry('tool:top', { platformVariants: [variant(['tool:left', 'tool:right'], 'top')] }), + entry('tool:left', { platformVariants: [variant(['tool:leaf'], 'left')] }), + entry('tool:right', { platformVariants: [variant(['tool:leaf'], 'right')] }), + entry('tool:leaf', { platformVariants: [variant([], 'leaf')] }), + ])); + const result = catalog.resolveCatalogEntry({ invocationName: 'top' }, { allowWeakIdentityFallback: true, projectRoot: '/r' })!; + assert.deepEqual(result.resolvedDependencies.map(d => d.entryId), ['tool:left', 'tool:leaf', 'tool:right']); + assert.deepEqual(result.policy.filesystem?.readonlyPaths, ['/r/top', '/r/left', '/r/leaf', '/r/right']); + }); + + it('dependency without a variant for the host makes the entry invalid at validation time', () => { + const store = storeFor([revisionWith([ + entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:b' }], sandboxPolicy: { version: '0.9.0-alpha' } }] }), + entry('tool:b', { platformVariants: [{ when: { platform: 'windows' }, sandboxPolicy: { version: '0.9.0-alpha' } }] }), + ])]); + assert.throws(() => store.revision(), /unsupported-dependency/); + assert.equal(errorCategory(() => store.revision()), 'validation'); + }); +}); + +describe('setup and inspection', () => { + it('reports the bundled revision and matches the manifest default', () => { + assert.deepEqual(getCatalogInfo(), { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' }); + }); + + it('lists metadata ordered by entryId without any policy body', () => { + const entries = listCatalogEntries(); + assert.deepEqual(entries.map(e => e.entryId), ['tool:git', 'tool:node', 'tool:npm']); + const npm = entries.find(e => e.entryId === 'tool:npm')!; + assert.deepEqual(npm.platformVariants[0], { + platform: 'windows', + dependencyEntryIds: ['tool:node'], + sandboxPolicyVersion: '0.9.0-alpha', + }); + assert.ok(!JSON.stringify(entries).includes('readwritePaths')); + assert.ok(!JSON.stringify(entries).includes('${')); + }); + + it('module-level resolveCatalogEntry uses the bundled catalog', () => { + assert.equal(resolveCatalogEntry({ invocationName: 'definitely-unknown-tool' }, { allowWeakIdentityFallback: true }), undefined); + }); +}); diff --git a/policy-catalog/typescript/test/store.test.ts b/policy-catalog/typescript/test/store.test.ts new file mode 100644 index 000000000..086ec25ea --- /dev/null +++ b/policy-catalog/typescript/test/store.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { PolicyCatalog, bundledCatalogStore } from '../dist/index.js'; +import { + canonicalJson, + canonicalSha256, + checkEntryRevisions, + checkPublishedImmutability, + checkStoreHistory, + validateContract, + validateCatalogRevision, +} from '../dist/tooling.js'; +import { contract, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; + +const parsedContract = validateContract(contract); + +describe('integrity', () => { + it('bundled catalog verifies against its published digest', () => { + const store = bundledCatalogStore(); + assert.deepEqual(checkStoreHistory(store), []); + assert.equal(store.revision().catalogRevision, store.defaultRevision); + }); + + it('canonical digest ignores formatting and key order but not content', () => { + assert.equal(canonicalJson({ b: 1, a: [2, { d: 3, c: 4 }] }), '{"a":[2,{"c":4,"d":3}],"b":1}'); + assert.equal(canonicalSha256({ a: 1, b: 2 }), canonicalSha256({ b: 2, a: 1 })); + assert.notEqual(canonicalSha256({ a: 1 }), canonicalSha256({ a: 2 })); + }); + + it('a tampered revision fails with an integrity error, never a no-match', () => { + const revision = revisionWith([entry('tool:a')]); + const store = storeFor([revision], { digests: { '2000-01-01.1': '0'.repeat(64) } }); + assert.equal(errorCategory(() => store.revision()), 'integrity'); + const catalog = new PolicyCatalog(store, fixedHost()); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'a' }, { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorCategory(() => catalog.getCatalogInfo()), 'integrity'); + }); + + it('a revision file that declares a different revision id fails integrity', () => { + const revision = revisionWith([entry('tool:a')], '2000-01-02.1'); + const digest = canonicalSha256(revision); + const store = storeFor([{ ...revision, catalogRevision: '2000-01-01.1' }], { digests: { '2000-01-01.1': digest } }); + // The digest was computed for other content, so this is caught as tampering first. + assert.equal(errorCategory(() => store.revision()), 'integrity'); + }); + + it('invalid manifest data is a validation error', () => { + const revision = revisionWith([entry('tool:a')]); + assert.equal(errorCategory(() => storeFor([revision], { defaultRevision: '2001-01-01.1' })), 'validation'); + assert.equal(errorCategory(() => storeFor([revisionWith([entry('tool:a')], '2000-01-02.1'), revision])), 'validation'); + }); +}); + +describe('versioning and immutable revisions', () => { + const r1 = revisionWith([entry('tool:a'), entry('tool:b')], '2000-01-01.1'); + + it('selects an explicit installed revision and never substitutes an unavailable one', () => { + const r2 = revisionWith([entry('tool:a', { entryRevision: 2, displayName: 'renamed' }), entry('tool:b')], '2000-01-02.1'); + const catalog = new PolicyCatalog(storeFor([r1, r2]), fixedHost()); + const ctx = { allowWeakIdentityFallback: true, projectRoot: '/p' }; + assert.equal(catalog.resolveCatalogEntry({ invocationName: 'a' }, ctx)?.entryRevision, 2); + assert.equal(catalog.resolveCatalogEntry({ invocationName: 'a' }, { ...ctx, catalogRevision: '2000-01-01.1' })?.entryRevision, 1); + assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'a' }, { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision-unavailable'); + }); + + it('loaded revisions are deeply frozen (read-only)', () => { + const store = storeFor([r1]); + const loaded = store.revision(); + assert.ok(Object.isFrozen(loaded.entries[0].platformVariants[0].sandboxPolicy)); + assert.throws(() => { (loaded.entries as any[]).push({}); }, TypeError); + }); + + it('requires entryRevision to increase exactly when an entry changes', () => { + const changedNoBump = revisionWith([entry('tool:a', { displayName: 'x' }), entry('tool:b')], '2000-01-02.1'); + const bumpedNoChange = revisionWith([entry('tool:a'), entry('tool:b', { entryRevision: 2 })], '2000-01-02.1'); + const ok = revisionWith([entry('tool:a', { displayName: 'x', entryRevision: 2 }), entry('tool:b')], '2000-01-02.1'); + const [a, b, c] = [changedNoBump, bumpedNoChange, ok].map(r => validateCatalogRevision(r, parsedContract)); + const base = validateCatalogRevision(r1, parsedContract); + assert.match(checkEntryRevisions(base, a).join('\n'), /'tool:a' changed but entryRevision did not increase/); + assert.match(checkEntryRevisions(base, b).join('\n'), /'tool:b' is unchanged but entryRevision moved/); + assert.deepEqual(checkEntryRevisions(base, c), []); + assert.match(checkEntryRevisions(c, base).join('\n'), /must be newer/); + }); + + it('rejects edits to, or removal of, an already-published revision', () => { + const published = { catalogRevision: '2000-01-01.1', file: 'revisions/2000-01-01.1.json', sha256: 'a'.repeat(64) }; + const base = { manifest: { revisions: [published] }, files: new Map([[published.file, '{"x":1}\n']]) }; + const appended = { + manifest: { revisions: [published, { catalogRevision: '2000-01-02.1', file: 'revisions/2000-01-02.1.json', sha256: 'b'.repeat(64) }] }, + files: new Map([[published.file, '{"x":1}\n']]), + }; + assert.deepEqual(checkPublishedImmutability(base, appended), []); + const edited = { manifest: { revisions: [published] }, files: new Map([[published.file, '{"x":2}\n']]) }; + assert.match(checkPublishedImmutability(base, edited).join('\n'), /was modified; publish a new revision/); + const redigested = { manifest: { revisions: [{ ...published, sha256: 'c'.repeat(64) }] }, files: base.files }; + assert.match(checkPublishedImmutability(base, redigested).join('\n'), /manifest entry was modified/); + const removed = { manifest: { revisions: [] }, files: new Map() }; + assert.match(checkPublishedImmutability(base, removed).join('\n'), /removed or reordered/); + }); +}); diff --git a/policy-catalog/typescript/test/validation.test.ts b/policy-catalog/typescript/test/validation.test.ts new file mode 100644 index 000000000..b317740f4 --- /dev/null +++ b/policy-catalog/typescript/test/validation.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateCatalogRevision, validateContract } from '../dist/tooling.js'; +import { contract, entry, revisionWith } from './helpers.js'; + +const parsedContract = validateContract(contract); +const validate = (entries: any[]) => () => validateCatalogRevision(revisionWith(entries), parsedContract); +const linux = (sandboxPolicy: any, extra: Record = {}) => ({ when: { platform: 'linux' }, ...extra, sandboxPolicy }); +const v = '0.9.0-alpha'; + +describe('catalog validation (contribution/CI rules)', () => { + it('accepts a minimal valid revision', () => { + assert.doesNotThrow(validate([entry('tool:a')])); + }); + + it('rejects dependency cycles, including indirect ones', () => { + const dep = (id: string) => [linux({ version: v }, { dependencies: [{ entryId: id }] })]; + assert.throws(validate([ + entry('tool:a', { platformVariants: dep('tool:b') }), + entry('tool:b', { platformVariants: dep('tool:c') }), + entry('tool:c', { platformVariants: dep('tool:a') }), + ]), /cycle \(tool:a -> tool:b -> tool:c -> tool:a\)/); + assert.throws(validate([entry('tool:a', { platformVariants: dep('tool:a') })]), /depends on itself/); + }); + + it('rejects dependencies outside the same catalog revision', () => { + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v }, { dependencies: [{ entryId: 'tool:missing' }] })] })]), /unknown entry 'tool:missing'/); + }); + + it('rejects syntactically invalid dependency version ranges but never evaluates them', () => { + assert.throws(validate([ + entry('tool:a', { platformVariants: [linux({ version: v }, { dependencies: [{ entryId: 'tool:b', versionRange: '^1.x' }] })] }), + entry('tool:b'), + ]), /versionRange/); + }); + + it('rejects duplicate exact selectors and a second architecture-neutral variant', () => { + const variant = (architecture?: string) => ({ when: { platform: 'linux', ...(architecture ? { architecture } : {}) }, sandboxPolicy: { version: v } }); + assert.throws(validate([entry('tool:a', { platformVariants: [variant('x64'), variant('x64')] })]), /duplicates selector/); + assert.throws(validate([entry('tool:a', { platformVariants: [variant(), variant()] })]), /second architecture-neutral/); + assert.doesNotThrow(validate([entry('tool:a', { platformVariants: [variant(), variant('x64'), variant('arm64')] })])); + assert.throws(validate([entry('tool:a', { platformVariants: [{ when: { platform: 'linux', architecture: 'riscv' }, sandboxPolicy: { version: v } }] })]), /architecture/); + }); + + it('rejects variants that name a containment backend', () => { + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, processContainer: {} })] })]), /containment backend/); + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, containment: 'lxc' })] })]), /containment backend/); + }); + + it('requires exact registered SandboxPolicy versions', () => { + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: '0.9.0' })] })]), /not a SandboxPolicy version registered/); + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: '0.10.0-alpha' })] })]), /not a SandboxPolicy version registered/); + }); + + it('rejects unsupported fields at every level', () => { + assert.throws(validate([entry('tool:a', { extra: 1 })]), /unsupported field/); + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, filesystem: { clearPolicyOnExit: true } })] })]), /unsupported field/); + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, network: { allowOutbound: true } })] })]), /unsupported field/); + assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, telemetry: {} })] })]), /unsupported field/); + }); + + it('rejects literal, user-specific, wildcard, traversal, and unknown-symbol paths', () => { + const path = (p: string) => validate([entry('tool:a', { platformVariants: [linux({ version: v, filesystem: { readonlyPaths: [p] } })] })]); + assert.throws(path('/home/alice/.npm'), /literal paths are not allowed/); + assert.throws(path('C:\\Users\\alice'), /literal paths are not allowed/); + assert.throws(path('/usr/lib'), /literal paths are not allowed/); + assert.throws(path('${project_root}/*'), /wildcard/); + assert.throws(path('${project_root}/../x'), /'\.\.'/); + assert.throws(path('${unknown_thing}'), /unknown symbol/); + assert.throws(path('${project_root'), /malformed symbol/); + assert.doesNotThrow(path('${project_root}/node_modules')); + }); + + it('rejects wildcard network grants', () => { + const net = (network: any) => validate([entry('tool:a', { platformVariants: [linux({ version: v, network })] })]); + assert.throws(net({ egress: { default: 'allow' } }), /default-allow/); + assert.throws(net({ ingress: { default: 'allow' } }), /default-allow/); + assert.throws(net({ egress: { allow: [{ ports: [{ port: 443 }] }] } }), /wildcard network grant/); + assert.throws(net({ egress: { allow: [{ to: [{ cidr: '0.0.0.0/0' }] }] } }), /wildcard network grant/); + assert.doesNotThrow(net({ egress: { default: 'deny', allow: [{ to: [{ cidr: '192.0.2.0/24' }], ports: [{ protocol: 'tcp', port: 443 }] }] } })); + }); + + it('enforces identity uniqueness across entries and strongest-first ordering', () => { + assert.throws(validate([entry('tool:a'), entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['A'] }] })]), /claimed by both/); + assert.throws(validate([ + entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/x' }] }), + entry('tool:b', { identity: [{ kind: 'purl', value: 'pkg:NPM/x' }] }), + ]), /claimed by both/); + assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['a'] }, { kind: 'purl', value: 'pkg:npm/a' }] })]), /ordered strongest first/); + assert.throws(validate([entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/a@1.0.0' }] })]), /must not pin a version/); + assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['bin/a'] }] })]), /bare invocation name/); + assert.throws(validate([entry('tool:a', { identity: [{ kind: 'sha256', value: 'x' }] })]), /not a supported identity kind/); + assert.throws(validate([entry('tool:a'), entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['z'] }] })]), /duplicate entryId/); + assert.throws(validate([entry('tool:a', { entryId: 'noNamespace' })]), /namespaced/); + }); + + describe('v1 composition vocabulary', () => { + const withDep = (policy: any, depPolicy: any) => validate([ + entry('tool:a', { platformVariants: [linux(policy, { dependencies: [{ entryId: 'tool:b' }] })] }), + entry('tool:b', { platformVariants: [linux(depPolicy)] }), + ]); + + it('composes filesystem path classes and same-class duplicates', () => { + assert.doesNotThrow(withDep( + { version: v, filesystem: { readwritePaths: ['${project_root}'], readonlyPaths: ['${node_prefix}'] } }, + { version: v, filesystem: { readonlyPaths: ['${node_prefix}', '${git_prefix}'], deniedPaths: ['${user_home}/.ssh'] } }, + )); + }); + + it('rejects cross-class equal or ancestor/descendant paths', () => { + assert.throws(withDep( + { version: v, filesystem: { readwritePaths: ['${project_root}'] } }, + { version: v, filesystem: { readonlyPaths: ['${project_root}'] } }, + ), /overlaps/); + assert.throws(withDep( + { version: v, filesystem: { readwritePaths: ['${project_root}'] } }, + { version: v, filesystem: { deniedPaths: ['${project_root}/secrets'] } }, + ), /overlaps/); + }); + + it('rejects mixed sandboxPolicy versions', () => { + assert.throws(withDep({ version: v }, { version: '0.8.0-alpha' }), /mixed sandboxPolicy\.version/); + }); + + it('rejects network and other fields without a cross-entry rule, but allows them on standalone entries', () => { + const network = { egress: { default: 'deny' } }; + assert.throws(withDep({ version: v, network }, { version: v }), /'network'/); + assert.throws(withDep({ version: v }, { version: v, timeoutMs: 5 }), /'timeoutMs'/); + assert.throws(withDep({ version: v }, { version: v, ui: { clipboard: 'read' } }), /'ui'/); + assert.doesNotThrow(validate([entry('tool:a', { platformVariants: [linux({ version: v, network, ui: { clipboard: 'read' }, timeoutMs: 5 })] })])); + }); + }); +}); diff --git a/policy-catalog/typescript/test/version-range.test.ts b/policy-catalog/typescript/test/version-range.test.ts new file mode 100644 index 000000000..d2dfd6ce0 --- /dev/null +++ b/policy-catalog/typescript/test/version-range.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isValidVersionRange, parsePurl, satisfiesVersionRange } from '../dist/tooling.js'; + +describe('version ranges', () => { + it('accepts the v1 grammar and rejects everything else', () => { + for (const range of ['>=10 <12', '22', '>=1.2.3', '=1.0.0', '<2 || >=4']) { + assert.ok(isValidVersionRange(range), range); + } + for (const range of ['', '^1.2.3', '~1', '1.x', '>= 1', '1 ||', 'latest']) { + assert.ok(!isValidVersionRange(range), range); + } + }); + + it('evaluates evidence versions and reports incomparable evidence', () => { + assert.equal(satisfiesVersionRange('10.9.0', '>=10 <12'), true); + assert.equal(satisfiesVersionRange('v12.0.0', '>=10 <12'), false); + assert.equal(satisfiesVersionRange('22.3.1', '22'), true); + assert.equal(satisfiesVersionRange('23.0.0', '22'), false); + assert.equal(satisfiesVersionRange('3.0.0', '<2 || >=3'), true); + assert.equal(satisfiesVersionRange('10.0.0-rc.1', '>=10'), true); + assert.equal(satisfiesVersionRange('nightly', '>=10'), undefined); + }); +}); + +describe('package URLs', () => { + it('extracts a version-free key and optional version', () => { + assert.deepEqual(parsePurl('pkg:npm/npm@10.9.0'), { key: 'npm/npm', version: '10.9.0' }); + assert.deepEqual(parsePurl('pkg:NPM/%40scope/pkg@1.0.0?x=y#sub'), { key: 'npm/%40scope/pkg', version: '1.0.0' }); + assert.deepEqual(parsePurl('pkg:npm/npm'), { key: 'npm/npm' }); + assert.equal(parsePurl('npm/npm'), undefined); + assert.equal(parsePurl('pkg:npm'), undefined); + }); +}); diff --git a/policy-catalog/typescript/tsconfig.test.json b/policy-catalog/typescript/tsconfig.test.json new file mode 100644 index 000000000..f9eff10b4 --- /dev/null +++ b/policy-catalog/typescript/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "rootDir": "./test", + "declaration": false, + "sourceMap": false + }, + "include": ["test/**/*.ts"] +} From fa92d7c43f69c28dc96197ee5d45084026d3d6f2 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 22:06:38 -0700 Subject: [PATCH 04/50] ci(policy-catalog): add contribution validation pipeline and package smoke test validate-catalog runs schema, integrity, semantic contract, entry-revision monotonicity, published-revision immutability against a base ref, deterministic resolution, package inclusion, and fixture coverage checks. package-smoke packs and installs the library offline. A path-filtered workflow runs them on Windows/Linux/macOS while nested; policy-catalog/.github carries the post-extraction workflow. --- .github/workflows/PolicyCatalog.Prototype.yml | 43 +++++ policy-catalog/.github/workflows/ci.yml | 41 ++++ .../typescript/scripts/catalog-digest.mjs | 17 ++ .../typescript/scripts/package-smoke.mjs | 53 +++++ .../typescript/scripts/validate-catalog.mjs | 181 ++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 .github/workflows/PolicyCatalog.Prototype.yml create mode 100644 policy-catalog/.github/workflows/ci.yml create mode 100644 policy-catalog/typescript/scripts/catalog-digest.mjs create mode 100644 policy-catalog/typescript/scripts/package-smoke.mjs create mode 100644 policy-catalog/typescript/scripts/validate-catalog.mjs diff --git a/.github/workflows/PolicyCatalog.Prototype.yml b/.github/workflows/PolicyCatalog.Prototype.yml new file mode 100644 index 000000000..fde9bbd97 --- /dev/null +++ b/.github/workflows/PolicyCatalog.Prototype.yml @@ -0,0 +1,43 @@ +# Runs the self-contained policy-catalog prototype checks while it is nested +# in this repository. The directory carries its own workflow +# (policy-catalog/.github/workflows/ci.yml) for after extraction. +name: Policy Catalog Prototype + +on: + pull_request: + paths: + - 'policy-catalog/**' + - '.github/workflows/PolicyCatalog.Prototype.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + policy-catalog: + name: Policy catalog (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: policy-catalog/typescript + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: npm ci + - run: npm test + - name: Validate catalog (including published-revision immutability) + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + export POLICY_CATALOG_BASE_REF="origin/${{ github.base_ref }}" + fi + npm run validate + - run: npm run smoke:package diff --git a/policy-catalog/.github/workflows/ci.yml b/policy-catalog/.github/workflows/ci.yml new file mode 100644 index 000000000..596e86335 --- /dev/null +++ b/policy-catalog/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# CI for the standalone policy catalog repository. Inactive while this +# directory is nested inside microsoft/mxc-derived trees; it becomes the +# repository's workflow after `git filter-repo --subdirectory-filter`. +name: Policy Catalog CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: typescript + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: npm ci + - run: npm test + - name: Validate catalog (including published-revision immutability) + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + export POLICY_CATALOG_BASE_REF="origin/${{ github.base_ref }}" + fi + npm run validate + - run: npm run smoke:package diff --git a/policy-catalog/typescript/scripts/catalog-digest.mjs b/policy-catalog/typescript/scripts/catalog-digest.mjs new file mode 100644 index 000000000..39beac7db --- /dev/null +++ b/policy-catalog/typescript/scripts/catalog-digest.mjs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Prints the canonical SHA-256 digest of a catalog revision file, for use in +// catalog/manifest.json when publishing a NEW revision. Never recompute the +// digest of an already-published revision; publish a new revision instead. +// +// Usage: node scripts/catalog-digest.mjs ../catalog/revisions/.json +import { readFileSync } from 'node:fs'; +import { canonicalSha256 } from '../dist/tooling.js'; + +const file = process.argv[2]; +if (!file) { + console.error('usage: node scripts/catalog-digest.mjs '); + process.exit(2); +} +console.log(canonicalSha256(JSON.parse(readFileSync(file, 'utf8')))); diff --git a/policy-catalog/typescript/scripts/package-smoke.mjs b/policy-catalog/typescript/scripts/package-smoke.mjs new file mode 100644 index 000000000..070adf088 --- /dev/null +++ b/policy-catalog/typescript/scripts/package-smoke.mjs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package-level integration smoke test (spec §12 "Integration"): packs the +// library, installs the tarball into an empty temporary project with no +// network access and no MXC SDK or executor, and exercises the public API. +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageDir = fileURLToPath(new URL('..', import.meta.url)); +// Run npm through its JS entry point so no shell is involved on any platform. +const npmCli = process.env.npm_execpath; +if (!npmCli) { + throw new Error('run this script through `npm run smoke:package`'); +} +const run = (args, cwd) => execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8' }); +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-smoke-')); +try { + const packed = JSON.parse(run(['pack', '--json', '--pack-destination', work], packageDir)); + const tarball = join(work, packed[0].filename); + const files = packed[0].files.map(f => f.path); + for (const required of ['catalog/manifest.json', 'catalog/contract.v1.json', 'dist/index.js', 'dist/index.d.ts']) { + if (!files.includes(required)) throw new Error(`package is missing ${required}`); + } + if (files.some(f => f.startsWith('src/') || f.startsWith('test/'))) { + throw new Error('package unexpectedly includes sources or tests'); + } + const consumer = join(work, 'consumer'); + mkdirSync(consumer); + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: 'consumer', private: true, type: 'module' })); + run(['install', '--offline', '--no-audit', '--no-fund', tarball], consumer); + const manifest = JSON.parse(readFileSync(join(packageDir, '..', 'catalog', 'manifest.json'), 'utf8')); + writeFileSync(join(consumer, 'smoke.mjs'), ` + import { getCatalogInfo, listCatalogEntries, resolveCatalogEntry, PolicyCatalogError } from '@mxc-prototype/policy-catalog'; + import assert from 'node:assert/strict'; + assert.equal(getCatalogInfo().catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); + assert.ok(listCatalogEntries().length > 0); + assert.equal(resolveCatalogEntry({ invocationName: 'git' }), undefined); + const r = resolveCatalogEntry({ invocationName: 'git' }, { + platform: 'linux', architecture: 'x64', allowWeakIdentityFallback: true, + projectRoot: '/p', symbols: { git_prefix: '/usr/bin' }, + }); + assert.equal(r.entryId, 'tool:git'); + assert.throws(() => resolveCatalogEntry({ invocationName: 'git' }, { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); + console.log('package smoke ok'); + `); + process.stdout.write(execFileSync(process.execPath, ['smoke.mjs'], { cwd: consumer, encoding: 'utf8' })); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/policy-catalog/typescript/scripts/validate-catalog.mjs b/policy-catalog/typescript/scripts/validate-catalog.mjs new file mode 100644 index 000000000..8b3d01aa0 --- /dev/null +++ b/policy-catalog/typescript/scripts/validate-catalog.mjs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Contribution/CI validation pipeline for the catalog (spec §7, §12 "Data"). +// +// 1. JSON Schema conformance of contract, manifest, and every revision. +// 2. Integrity: every revision matches its published canonical SHA-256. +// 3. Semantic validation shared with the resolver: registered +// SandboxPolicy versions, identity uniqueness/ordering, selectors, +// dependency closure and cycle freedom, symbol validity, no literal or +// user-specific paths, no wildcard grants, unsupported-field rejection, +// backend neutrality, and v1 composition limits. +// 4. Entry-revision monotonicity across consecutive published revisions. +// 5. Immutability of already-published revisions against a base git ref +// (POLICY_CATALOG_BASE_REF, e.g. origin/main), when provided. +// 6. Deterministic resolution: every entry resolves identically twice for +// every platform/architecture selector. +// 7. Package inclusion: the bundled default revision equals the repository +// manifest default. +// 8. Every entry has a conformance fixture case. +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { PolicyCatalog, loadCatalogDirectory, bundledCatalogStore } from '../dist/index.js'; +import { + ARCHITECTURES, + PLATFORMS, + checkPublishedImmutability, + checkStoreHistory, + selectVariant, +} from '../dist/tooling.js'; + +const require = createRequire(import.meta.url); +const Ajv2020 = require('ajv/dist/2020'); + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const catalogDir = join(root, 'catalog'); +const schemaDir = join(root, 'schema'); +const readJson = file => JSON.parse(readFileSync(file, 'utf8')); +const errors = []; +const step = (name, fn) => { + try { + const found = fn() ?? []; + errors.push(...found.map(message => `${name}: ${message}`)); + console.log(`${found.length === 0 ? 'ok ' : 'FAIL'} ${name}`); + } catch (error) { + errors.push(`${name}: ${error.message}`); + console.log(`FAIL ${name}`); + } +}; + +const ajv = new Ajv2020({ allErrors: true, strict: true }); +const catalogSchema = ajv.compile(readJson(join(schemaDir, 'catalog.v1.schema.json'))); +const manifestSchema = ajv.compile(readJson(join(schemaDir, 'manifest.v1.schema.json'))); +const describe = validate => (validate.errors ?? []).map(e => `${e.instancePath || '/'} ${e.message}`); + +const manifest = readJson(join(catalogDir, 'manifest.json')); + +step('manifest schema', () => (manifestSchema(manifest) ? [] : describe(manifestSchema))); + +step('revision schema', () => { + const found = []; + const listed = new Set(manifest.revisions.map(r => r.file)); + for (const name of readdirSync(join(catalogDir, 'revisions'))) { + const file = `revisions/${name}`; + if (!listed.has(file)) { + found.push(`${file} is not listed in manifest.json`); + continue; + } + if (!catalogSchema(readJson(join(catalogDir, file)))) { + found.push(...describe(catalogSchema).map(m => `${file} ${m}`)); + } + } + return found; +}); + +const store = loadCatalogDirectory(pathToFileURL(`${catalogDir}/`)); + +step('integrity, contract, and entry-revision history', () => checkStoreHistory(store)); + +step('published revision immutability', () => { + const baseRef = process.env.POLICY_CATALOG_BASE_REF; + if (!baseRef) { + console.log(' (skipped: set POLICY_CATALOG_BASE_REF to compare against a base ref)'); + return []; + } + const repoRoot = execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + const prefix = relative(repoRoot, catalogDir).replaceAll('\\', '/'); + const show = path => { + try { + return execFileSync('git', ['-C', repoRoot, 'show', `${baseRef}:${prefix}/${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + return undefined; + } + }; + const baseManifestText = show('manifest.json'); + if (baseManifestText === undefined) { + console.log(` (no catalog at ${baseRef}; nothing published yet)`); + return []; + } + const baseManifest = JSON.parse(baseManifestText); + const baseFiles = new Map(); + for (const r of baseManifest.revisions) { + const text = show(r.file); + if (text !== undefined) baseFiles.set(r.file, text); + } + const proposedFiles = new Map(manifest.revisions.map(r => [r.file, readFileSync(join(catalogDir, r.file), 'utf8')])); + return checkPublishedImmutability({ manifest: baseManifest, files: baseFiles }, { manifest, files: proposedFiles }); +}); + +step('deterministic resolution', () => { + const found = []; + const catalog = new PolicyCatalog(store); + const symbols = Object.fromEntries( + Object.entries(store.contract.symbols).filter(([, d]) => d.source !== 'context').map(([name]) => [name, name]), + ); + for (const revisionId of store.availableRevisions) { + for (const entry of store.revision(revisionId).entries) { + const name = entry.identity.find(p => p.kind === 'invocation-name')?.names[0]; + const purl = entry.identity.find(p => p.kind === 'purl')?.value; + for (const platform of PLATFORMS) { + for (const architecture of ARCHITECTURES) { + if (!selectVariant(entry, platform, architecture)) continue; + const base = platform === 'windows' ? 'C:\\ci' : '/ci'; + const sep = platform === 'windows' ? '\\' : '/'; + const ctx = { + platform, + architecture, + catalogRevision: revisionId, + allowWeakIdentityFallback: true, + projectRoot: `${base}${sep}project`, + symbols: Object.fromEntries(Object.keys(symbols).map(s => [s, `${base}${sep}${s}`])), + }; + const tool = { invocationName: name ?? 'unused', ...(purl ? { packageUrl: purl } : {}) }; + const first = JSON.stringify(catalog.resolveCatalogEntry(tool, ctx)); + const second = JSON.stringify(catalog.resolveCatalogEntry(tool, ctx)); + if (first === undefined || first !== second) { + found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} did not resolve deterministically`); + } + } + } + } + } + return found; +}); + +step('package inclusion', () => { + const bundled = bundledCatalogStore(); + return bundled.defaultRevision === store.defaultRevision + && JSON.stringify(bundled.availableRevisions) === JSON.stringify(store.availableRevisions) + ? [] + : ['bundled package catalog does not match the repository catalog; rebuild the package']; +}); + +step('entry fixtures', () => { + const fixtureDir = join(root, 'conformance', 'fixtures'); + const covered = new Set(); + for (const name of readdirSync(fixtureDir)) { + const fixture = readJson(join(fixtureDir, name)); + if (fixture.catalog !== 'bundled') continue; + for (const c of fixture.cases) { + if (c.expect?.entryId) covered.add(c.expect.entryId); + } + } + const found = []; + for (const entry of store.revision().entries) { + if (!covered.has(entry.entryId)) { + found.push(`${entry.entryId} has no bundled-catalog conformance case in conformance/fixtures/`); + } + } + return found; +}); + +if (errors.length > 0) { + console.error('\nPolicy catalog validation FAILED:'); + for (const message of errors) console.error(` - ${message}`); + process.exit(1); +} +console.log(`\nPolicy catalog validation passed (${relative(process.cwd(), catalogDir) || '.'}, default revision ${store.defaultRevision}).`); From e918cb1942feea6c00775f28ab53e66be73d563c Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 22:07:18 -0700 Subject: [PATCH 05/50] fix(policy-catalog): compare published revisions by canonical content Line-ending conversion on checkout must not be reported as a published-revision edit. --- policy-catalog/typescript/src/history.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/policy-catalog/typescript/src/history.ts b/policy-catalog/typescript/src/history.ts index bc1380bc9..c93bdf8b7 100644 --- a/policy-catalog/typescript/src/history.ts +++ b/policy-catalog/typescript/src/history.ts @@ -84,9 +84,21 @@ export function checkPublishedImmutability(base: PublishedState, proposed: Publi if (now.file !== published.file || now.sha256 !== published.sha256) { errors.push(`published revision '${published.catalogRevision}' manifest entry was modified`); } - const baseBytes = base.files.get(published.file); - const proposedBytes = proposed.files.get(published.file); - if (baseBytes !== undefined && proposedBytes !== baseBytes) { + // Compare canonical content, not raw bytes, so checkout line-ending + // conversion cannot produce a false positive. + const canonical = (text: string | undefined): string | undefined => { + if (text === undefined) { + return undefined; + } + try { + return canonicalJson(JSON.parse(text)); + } catch { + return `invalid:${text}`; + } + }; + const baseContent = canonical(base.files.get(published.file)); + const proposedContent = canonical(proposed.files.get(published.file)); + if (baseContent !== undefined && proposedContent !== baseContent) { errors.push(`published revision file '${published.file}' was modified; publish a new revision instead`); } }); From 6b1755cd9e57b74214b37298e0b7f4ec6de7d28f Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 22:50:27 -0700 Subject: [PATCH 06/50] feat(policy-catalog): adopt additive multi-tool resolver API from the latest design Follow the current design proposal (docs/design.md): getSandboxConfig and getSandboxConfigWithDiagnostics accept one tool or an array and compose every eligible matching entry and dependency once; identity matching is additive rather than strongest-wins; omitted architecture uses the device's native system architecture with diagnostics; composition limits apply across all selected entries; entry-ID (not identity) uniqueness is validated. --- .github/workflows/PolicyCatalog.Prototype.yml | 42 +- policy-catalog/.github/workflows/ci.yml | 47 +- policy-catalog/{typescript => }/.gitignore | 3 +- policy-catalog/README.md | 265 +++--- .../conformance/fixtures/bundled-catalog.json | 201 ++++- .../fixtures/synthetic-catalog.json | 228 +++-- policy-catalog/docs/architecture.md | 132 +++ policy-catalog/docs/contributing.md | 105 +++ policy-catalog/docs/design.md | 809 ++++++++++++++++++ .../{typescript => }/package-lock.json | 0 policy-catalog/package.json | 55 ++ .../scripts/catalog-digest.mjs | 0 policy-catalog/scripts/check-extractable.mjs | 85 ++ policy-catalog/scripts/check-pack.mjs | 49 ++ .../scripts/check-tests-present.mjs | 24 + policy-catalog/scripts/clean.mjs | 11 + policy-catalog/scripts/package-smoke.mjs | 59 ++ .../scripts/validate-catalog.mjs | 75 +- .../{typescript => }/src/canonical-json.ts | 0 .../{typescript => }/src/catalog.ts | 99 +-- policy-catalog/src/cli.ts | 177 ++++ policy-catalog/{typescript => }/src/errors.ts | 2 +- .../{typescript => }/src/history.ts | 4 +- policy-catalog/src/host.ts | 121 +++ policy-catalog/{typescript => }/src/index.ts | 17 +- policy-catalog/{typescript => }/src/purl.ts | 0 policy-catalog/src/resolver.ts | 502 +++++++++++ policy-catalog/{typescript => }/src/store.ts | 5 +- .../{typescript => }/src/tooling.ts | 3 +- policy-catalog/{typescript => }/src/types.ts | 60 +- .../{typescript => }/src/version-range.ts | 0 policy-catalog/tests/functional/cli.test.ts | 229 +++++ policy-catalog/tests/functional/tsconfig.json | 17 + policy-catalog/tests/unit/conformance.test.ts | 37 + .../test => tests/unit}/helpers.ts | 10 +- policy-catalog/tests/unit/resolver.test.ts | 237 +++++ .../test => tests/unit}/store.test.ts | 18 +- policy-catalog/tests/unit/tsconfig.json | 17 + .../test => tests/unit}/validation.test.ts | 16 +- .../test => tests/unit}/version-range.test.ts | 2 +- policy-catalog/{typescript => }/tsconfig.json | 0 policy-catalog/typescript/package.json | 41 - .../typescript/scripts/copy-catalog.mjs | 12 - .../typescript/scripts/package-smoke.mjs | 53 -- policy-catalog/typescript/src/resolver.ts | 428 --------- .../typescript/test/conformance.test.ts | 29 - .../typescript/test/resolver.test.ts | 155 ---- policy-catalog/typescript/tsconfig.test.json | 10 - 48 files changed, 3371 insertions(+), 1120 deletions(-) rename policy-catalog/{typescript => }/.gitignore (56%) create mode 100644 policy-catalog/docs/architecture.md create mode 100644 policy-catalog/docs/contributing.md create mode 100644 policy-catalog/docs/design.md rename policy-catalog/{typescript => }/package-lock.json (100%) create mode 100644 policy-catalog/package.json rename policy-catalog/{typescript => }/scripts/catalog-digest.mjs (100%) create mode 100644 policy-catalog/scripts/check-extractable.mjs create mode 100644 policy-catalog/scripts/check-pack.mjs create mode 100644 policy-catalog/scripts/check-tests-present.mjs create mode 100644 policy-catalog/scripts/clean.mjs create mode 100644 policy-catalog/scripts/package-smoke.mjs rename policy-catalog/{typescript => }/scripts/validate-catalog.mjs (64%) rename policy-catalog/{typescript => }/src/canonical-json.ts (100%) rename policy-catalog/{typescript => }/src/catalog.ts (91%) create mode 100644 policy-catalog/src/cli.ts rename policy-catalog/{typescript => }/src/errors.ts (94%) rename policy-catalog/{typescript => }/src/history.ts (96%) create mode 100644 policy-catalog/src/host.ts rename policy-catalog/{typescript => }/src/index.ts (62%) rename policy-catalog/{typescript => }/src/purl.ts (100%) create mode 100644 policy-catalog/src/resolver.ts rename policy-catalog/{typescript => }/src/store.ts (97%) rename policy-catalog/{typescript => }/src/tooling.ts (86%) rename policy-catalog/{typescript => }/src/types.ts (62%) rename policy-catalog/{typescript => }/src/version-range.ts (100%) create mode 100644 policy-catalog/tests/functional/cli.test.ts create mode 100644 policy-catalog/tests/functional/tsconfig.json create mode 100644 policy-catalog/tests/unit/conformance.test.ts rename policy-catalog/{typescript/test => tests/unit}/helpers.ts (90%) create mode 100644 policy-catalog/tests/unit/resolver.test.ts rename policy-catalog/{typescript/test => tests/unit}/store.test.ts (84%) create mode 100644 policy-catalog/tests/unit/tsconfig.json rename policy-catalog/{typescript/test => tests/unit}/validation.test.ts (90%) rename policy-catalog/{typescript/test => tests/unit}/version-range.test.ts (97%) rename policy-catalog/{typescript => }/tsconfig.json (100%) delete mode 100644 policy-catalog/typescript/package.json delete mode 100644 policy-catalog/typescript/scripts/copy-catalog.mjs delete mode 100644 policy-catalog/typescript/scripts/package-smoke.mjs delete mode 100644 policy-catalog/typescript/src/resolver.ts delete mode 100644 policy-catalog/typescript/test/conformance.test.ts delete mode 100644 policy-catalog/typescript/test/resolver.test.ts delete mode 100644 policy-catalog/typescript/tsconfig.test.json diff --git a/.github/workflows/PolicyCatalog.Prototype.yml b/.github/workflows/PolicyCatalog.Prototype.yml index fde9bbd97..178cbeb72 100644 --- a/.github/workflows/PolicyCatalog.Prototype.yml +++ b/.github/workflows/PolicyCatalog.Prototype.yml @@ -1,6 +1,6 @@ # Runs the self-contained policy-catalog prototype checks while it is nested -# in this repository. The directory carries its own workflow -# (policy-catalog/.github/workflows/ci.yml) for after extraction. +# in this repository. After extraction, the directory's own workflow +# (policy-catalog/.github/workflows/ci.yml) takes over. name: Policy Catalog Prototype on: @@ -15,29 +15,43 @@ permissions: jobs: policy-catalog: - name: Policy catalog (${{ matrix.os }}) + name: Policy catalog (${{ matrix.os_label }}) strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} + include: + - os_label: windows + runner: windows-latest + - os_label: linux + runner: ubuntu-latest + - os_label: macos + runner: macos-latest + runs-on: ${{ matrix.runner }} defaults: run: - working-directory: policy-catalog/typescript + working-directory: policy-catalog steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 + cache: npm + cache-dependency-path: policy-catalog/package-lock.json + - run: npm ci - - run: npm test - - name: Validate catalog (including published-revision immutability) + + - run: npm run check + + - name: Published-revision immutability + if: github.event_name == 'pull_request' shell: bash - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - export POLICY_CATALOG_BASE_REF="origin/${{ github.base_ref }}" - fi - npm run validate - - run: npm run smoke:package + env: + POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} + run: npm run validate + + - name: Extractability (policy-catalog/ as a standalone repository) + if: matrix.os_label == 'linux' + run: npm run check:extract diff --git a/policy-catalog/.github/workflows/ci.yml b/policy-catalog/.github/workflows/ci.yml index 596e86335..a64485cbe 100644 --- a/policy-catalog/.github/workflows/ci.yml +++ b/policy-catalog/.github/workflows/ci.yml @@ -1,6 +1,5 @@ -# CI for the standalone policy catalog repository. Inactive while this -# directory is nested inside microsoft/mxc-derived trees; it becomes the -# repository's workflow after `git filter-repo --subdirectory-filter`. +# CI for the standalone policy catalog repository. Mirrors the MXC SDK unit +# and integration test jobs: Node 24, npm ci, and a per-OS matrix. name: Policy Catalog CI on: @@ -12,30 +11,42 @@ permissions: contents: read jobs: - test: - name: Test (${{ matrix.os }}) + check: + name: Check (${{ matrix.os_label }}) strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - defaults: - run: - working-directory: typescript + include: + - os_label: windows + runner: windows-latest + - os_label: linux + runner: ubuntu-latest + - os_label: macos + runner: macos-latest + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci - - run: npm test - - name: Validate catalog (including published-revision immutability) + + # typecheck, unit, functional, catalog validation, pack contents, install smoke + - run: npm run check + + - name: Published-revision immutability + if: github.event_name == 'pull_request' shell: bash - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - export POLICY_CATALOG_BASE_REF="origin/${{ github.base_ref }}" - fi - npm run validate - - run: npm run smoke:package + env: + POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} + run: npm run validate + + - name: Extractability (HEAD tree as a standalone repository) + if: matrix.os_label == 'linux' + run: npm run check:extract diff --git a/policy-catalog/typescript/.gitignore b/policy-catalog/.gitignore similarity index 56% rename from policy-catalog/typescript/.gitignore rename to policy-catalog/.gitignore index 6aceb3e4e..e428b13a7 100644 --- a/policy-catalog/typescript/.gitignore +++ b/policy-catalog/.gitignore @@ -1,5 +1,4 @@ node_modules/ dist/ -dist-test/ -catalog/ +dist-tests/ *.tgz diff --git a/policy-catalog/README.md b/policy-catalog/README.md index a66c0ab02..fc9c31046 100644 --- a/policy-catalog/README.md +++ b/policy-catalog/README.md @@ -1,163 +1,162 @@ # Known-tool policy catalog (prototype) > **Prototype.** This directory prototypes the standalone catalog and library -> proposed in the MXC Policy Store feature spec -> ([microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309), -> `docs/mxc-policy-store.md`). It is not an approved, shipped, or supported -> catalog. The spec describes the catalog as a time-limited bridge that is -> expected to be retired when Learning Mode provides the replacement workflow. +> described in the [Known-tool Policy Floors design proposal](docs/design.md), +> which is proposed in MXC as `docs/mxc-policy-store.md` +> ([microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309)). It is not +> an approved, shipped, or supported catalog. The design calls the catalog a +> time-limited bridge that is expected to be retired when Learning Mode +> provides the replacement workflow. -The directory is self-contained so it can be moved to its own repository -(spec §6) without changes: +This directory is laid out as its own repository. It has no imports from, and +no build or test dependency on, the enclosing MXC tree. You can extract it +with: ```text git filter-repo --subdirectory-filter policy-catalog ``` -Nothing here imports from the rest of the MXC tree, and no MXC SDK imports -from here. The spec states that the proposal makes no MXC SDK changes. +`npm run check:extract` proves this by building and testing a fresh copy. ## Layout | Path | Contents | |---|---| -| `catalog/contract.v1.json` | Catalog contract v1: the exact registered `SandboxPolicy` versions entries may embed, plus the symbol vocabulary. | -| `catalog/manifest.json` | Append-only list of published revisions, each with its canonical SHA-256 digest, plus the default revision. | +| `docs/design.md` | Copy of the design proposal (authoritative). | +| `docs/architecture.md` | How this prototype implements the design. | +| `docs/contributing.md` | Catalog and library contribution flow, validation rules, and test layout. | +| `catalog/contract.v1.json` | Catalog contract v1: registered `SandboxPolicy` versions and the symbol vocabulary. | +| `catalog/manifest.json` | Append-only list of published revisions, each with its canonical SHA-256, plus the default revision. | | `catalog/revisions/.json` | Immutable published catalog revisions. | | `schema/` | JSON Schemas for revisions and the manifest. | | `conformance/fixtures/` | Language-neutral conformance cases that every language binding must pass. | -| `typescript/` | TypeScript/JavaScript library, tests, and CI/contribution tooling. | -| `.github/workflows/ci.yml` | CI for the future standalone repository. It is inactive while this directory is nested. | +| `src/` | TypeScript/JavaScript library and the `policy-catalog` CLI. | +| `tests/unit/` | Unit and conformance tests (`node:test`). | +| `tests/functional/` | End-to-end tests that drive the compiled CLI against the real catalog and tampered copies. | +| `scripts/` | Validation, digest, package, and extractability checks. | +| `.github/workflows/ci.yml` | CI for the standalone repository. | -## Library API (TypeScript) +## Library API -Runtime lookup and inspection are separate (spec §5): +Runtime lookup is kept separate from inspection +([design §5](docs/design.md#5-api-surface)): ```ts -import { resolveCatalogEntry, listCatalogEntries, getCatalogInfo } from '@mxc-prototype/policy-catalog'; +import { + getSandboxConfig, + getSandboxConfigWithDiagnostics, + getCatalogInfo, + listCatalogEntries, +} from '@mxc-prototype/policy-catalog'; + +const ctx = { + allowWeakIdentityFallback: true, + projectRoot: '/work/app', + symbols: { git_prefix: '/usr/bin', npm_prefix: '/usr/local/bin', npm_cache: '/home/me/.npm', node_prefix: '/usr/local/bin' }, +}; + +getSandboxConfig('npm', ctx); // one tool -> SandboxPolicy | undefined +getSandboxConfig(['git', 'npm'], ctx); // several tools -> one composed policy +getSandboxConfigWithDiagnostics(['git', 'npm'], ctx); // same policy plus attribution and warnings +getSandboxConfig({ invocationName: 'npx', packageUrl: 'pkg:npm/npm@10.9.0' }, ctx); + +getCatalogInfo(); // { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' } +listCatalogEntries(); // metadata only, never policy bodies +``` -getCatalogInfo(); // { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' } -listCatalogEntries(); // metadata only; no policy bodies +The same operations are available from the command line: -const result = resolveCatalogEntry( - { invocationName: 'npm', packageUrl: 'pkg:npm/npm@10.9.0' }, - { projectRoot: '/work/app', symbols: { npm_prefix: '/usr/local/bin', npm_cache: '/home/me/.npm', node_prefix: '/usr/local/bin' } }, -); -// result?.policy is a candidate minimum SandboxPolicy (a lower bound, not authorization). +```text +npx policy-catalog resolve --diagnostics --allow-weak --project-root /work/app \ + --symbol git_prefix=/usr/bin git ``` -- A no-match result is `undefined`, not an empty policy. Library failures - throw `PolicyCatalogError` with a stable `category`: `integrity`, - `validation`, `revision-unavailable`, `invalid-context`, `unsupported-host`, - or `composition-conflict`. -- Omitted `platform` and `architecture` values use the host. Architecture - comes from the OS machine type, not `process.arch`. An omitted - `catalogRevision` uses the installed default. An explicitly requested - revision that is missing is an error, never a substitution. -- Weak (invocation-name-only) identity matches only with - `allowWeakIdentityFallback: true`. A package-URL (`purl`) match is strong. A - version-range mismatch returns a warning, not a refusal. -- `projectRoot` and caller symbols are never fabricated. A required symbol - that is unresolved produces `undefined`. The resolver can derive only - `host` symbols (`user_home`, `temp_dir`), and only for the current host - platform. -- Lookup is local and synchronous. It makes no network calls and does not - run the tool. - -### Consumer boundary - -The library does not grant access, create a sandbox, write consumer state, or -compose with consumer policy. Consumers own the following decisions (spec -§2, §5.3): - -- whether lookup is enabled -- access-profile mapping, authorization, and elevation -- persistence, keyed by `entryId`, `entryRevision`, and `catalogRevision` -- composition with their own layers and ceilings -- approval UX and audit -- the final call to `createConfigFromPolicy()` or sandbox creation - -## Data model and rules implemented - -- **Versions (spec §4.1):** The four dimensions are `catalogSchemaVersion`, - `catalogRevision`, per-entry `entryRevision`, and per-variant - `sandboxPolicy.version`. The tool `versionRange` is advisory evidence only. -- **Identity (spec §4.3):** Predicates are ordered strongest first and - validated. `purl` is strong and `invocation-name` is weak. Identity keys - must be unique across entries; invocation names are compared - case-insensitively for this check. -- **Platform variants (spec §4.4):** Each variant is a complete policy per - `windows`/`linux`/`macos`, with an optional `x64`/`arm64` selector. Variants - are never merged. An exact architecture wins over the neutral variant. - Duplicate exact selectors and more than one neutral variant are rejected. - Backend-specific keys are rejected. -- **Dependencies and composition (spec §4.5):** Dependency closure is - depth-first, deterministic, and rejects cycles. Only - `filesystem.deniedPaths`, `readonlyPaths`, and `readwritePaths` compose - across entries: paths are normalized with platform rules and de-duplicated - per class. Cross-class equal or ancestor/descendant paths are rejected. - Mixed policy versions are rejected. `network`, `ui`, `timeoutMs`, and every - other field are rejected in a multi-entry closure. A dependency - `versionRange` is checked only for syntax and is returned as unevaluated - metadata. -- **Integrity and immutability (spec §10):** Each revision must match its - manifest digest, computed over canonical JSON. Loaded data is deep-frozen. - CI rejects edits to, or removal of, published revisions. A change requires - a new revision. - -## Contribution / CI pipeline (spec §7, §12 "Data") +### Results + +- The result is a candidate **lower bound**, not authorization. Consumers + decide whether lookup is enabled, and own authorization, elevation, + persistence, composition with their own layers and ceilings, approval, + audit, and sandbox creation + ([design §2](docs/design.md#2-ownership-boundary), + [design §5.3](docs/design.md#53-consumer-obligations)). +- Absence is not an empty policy. `getSandboxConfig` returns `undefined` for + an empty input, when no input matched, or when a selected entry has an + unresolved required symbol. `getSandboxConfigWithDiagnostics` returns + `policy: undefined` with the diagnostics. +- Library failures throw `PolicyCatalogError` with a stable `category`. See + [architecture](docs/architecture.md#failure-categories). + +### Matching and selection + +- Matching is additive: every eligible matching entry contributes, and each + contributes once. +- `purl` identity is strong. Invocation-name-only identity is weak and + participates only with `allowWeakIdentityFallback: true`. +- An omitted `platform` uses the host. An omitted `architecture` uses the + device's native system architecture, not the process architecture, and + diagnostics warn that the tool's architecture was not verified. An omitted + `catalogRevision` uses the installed default. An unavailable explicit + revision is an error. +- Lookup is local and synchronous. It makes no network calls and does not run + the candidate tool. + +## Development + +Requires Node.js 24 or later. ```text -cd typescript npm ci -npm test # unit + conformance tests -npm run validate # full catalog validation pipeline -POLICY_CATALOG_BASE_REF=origin/main npm run validate # + immutability vs base -npm run smoke:package # pack, offline install, public-API smoke test +npm run check # typecheck, unit, functional, catalog validation, pack contents, install smoke +npm run test:unit +npm run test:functional +npm run validate # add POLICY_CATALOG_BASE_REF= to enforce published-revision immutability +npm run check:pack +npm run check:extract # build and test HEAD's copy of this directory as a standalone repository ``` -`validate` checks the following: - -- JSON Schema conformance -- manifest integrity digests -- the semantic contract (registered `SandboxPolicy` versions, identity - uniqueness and ordering, selectors, dependency closure and cycle freedom, - symbol validity, no literal, user-specific, or `..` paths, no wildcard - filesystem or network grants, and unsupported-field rejection) -- `entryRevision` monotonicity across revisions -- published-revision immutability against a base ref -- deterministic resolution for every selector -- package inclusion -- a conformance fixture for every entry - -### Publishing a new revision - -1. Copy the latest revision to `catalog/revisions/.json` and edit it. - Bump `entryRevision` for every entry that changed semantically. -2. Run `npm run build && npm run digest -- ../catalog/revisions/.json`. - Append the result to `manifest.json` and update `defaultRevision`. -3. Add or update conformance fixtures, then run `npm run validate`. - -Human review requirements (spec §7) apply to every catalog change. These -include catalog-owner and security-reviewer approval, identity, platform, and -version evidence, and regression evidence for reductions. This prototype -cannot enforce them. - -## Deferred / not implemented - -- **Rust (Cargo) and C#/.NET (NuGet) libraries (spec §6.1):** Not - implemented. The fixtures in `conformance/fixtures/` are the shared - contract those bindings must satisfy. -- **Validation of embedded policies against the real MXC `SandboxPolicy` - schemas:** This prototype validates a catalog-supported subset of the 0.8 - and 0.9 contracts and pins the registered versions in - `contract.v1.json`. Full validation against MXC's released schemas would - need a vendored or published schema artifact after extraction. -- **Cross-platform package CI and host-default tests on real ARM64 or - emulated hosts:** The workflow runs a Windows/Linux/macOS matrix, but the - emulation cases are covered only by unit tests with an injected host. -- **Private or enterprise overlays, and a hosted service:** Out of scope - (spec §13). -- **Catalog entries:** `tool:git`, `tool:node`, and `tool:npm` were migrated - from the earlier config-floor prototype. They have not been observed or - reviewed; `provenance.method` is `prototype-migration`. +See [docs/contributing.md](docs/contributing.md) before changing catalog data. + +## Status: implemented and deferred + +**Implemented (TypeScript):** + +- the four version dimensions +- canonical-digest integrity +- immutable, deep-frozen revisions with CI immutability and monotonicity + checks +- additive strong/weak identity matching with opt-in weak fallback +- platform and architecture variants with native-architecture defaults and + diagnostics +- deterministic, cycle-rejecting dependency resolution +- v1 filesystem-only composition across inputs and dependencies +- the policy-only and diagnostics APIs (single tool or array) +- metadata inspection +- the CLI harness +- the contribution validation pipeline +- package inclusion and offline install checks +- the extractability check + +**Deferred:** + +- Rust (Cargo) and C#/.NET (NuGet) libraries + ([design §6.1](docs/design.md#61-library-distribution-and-consumption)). + `conformance/fixtures/` is the shared contract they must pass. +- Validation of embedded policies against MXC's real released `SandboxPolicy` + schemas ([design §4.2](docs/design.md#42-entry-shape)). The prototype + validates a catalog-supported subset and pins `0.8.0-alpha` and + `0.9.0-alpha` in `catalog/contract.v1.json`. +- Integration tests that run a real tool under an MXC sandbox + ([design §12](docs/design.md#12-test-plan), "Integration"). +- Verification on real ARM64 and emulated hosts. Native-architecture detection + is covered with an injected host. +- Private or enterprise overlays + ([design §13](docs/design.md#13-open-questions)). +- Named-role review enforcement. That belongs to repository governance, not + CI. +- Reviewed catalog data. `tool:git`, `tool:node`, and `tool:npm` were migrated + from an earlier prototype and have not been re-observed + (`provenance.method: prototype-migration`). + +## License + +MIT. See [LICENSE.md](LICENSE.md). diff --git a/policy-catalog/conformance/fixtures/bundled-catalog.json b/policy-catalog/conformance/fixtures/bundled-catalog.json index 9c1aebc33..d23d73e64 100644 --- a/policy-catalog/conformance/fixtures/bundled-catalog.json +++ b/policy-catalog/conformance/fixtures/bundled-catalog.json @@ -1,10 +1,10 @@ { - "$comment": "Language-neutral conformance cases against the bundled catalog. Every binding (TypeScript, Rust, C#) must produce the same `expect` for each case. `expect: null` is the language's no-match value (undefined / None / null). `expectError` names a PolicyCatalogError category.", + "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from getSandboxConfigWithDiagnostics(tools, context), and getSandboxConfig must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` names a PolicyCatalogError category. `tools` is a single input or an array.", "catalog": "bundled", "cases": [ { - "name": "git by invocation name with weak fallback on windows", - "tool": { "invocationName": "git.exe" }, + "name": "git by string shorthand with weak fallback on windows", + "tools": "git.exe", "context": { "platform": "windows", "architecture": "x64", @@ -13,11 +13,6 @@ "symbols": { "git_prefix": "C:\\Program Files\\Git\\cmd" } }, "expect": { - "entryId": "tool:git", - "entryRevision": 1, - "catalogRevision": "2026-09-29.1", - "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, - "resolvedDependencies": [], "policy": { "version": "0.9.0-alpha", "filesystem": { @@ -25,47 +20,51 @@ "readwritePaths": ["C:\\work\\repo"] } }, - "warnings": ["tool:git matched only by invocation name 'git.exe' (weak identity)"] + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [ + { "inputIndex": 0, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + ], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('git.exe') matched tool:git only by invocation name (weak identity)", + "tool:git uses its architecture-neutral windows variant; no x64-specific variant exists" + ] + } } }, { - "name": "node by invocation name on linux", - "tool": { "invocationName": "node" }, - "context": { - "platform": "linux", - "architecture": "arm64", - "allowWeakIdentityFallback": true, - "symbols": { "node_prefix": "/usr/local/bin" } - }, + "name": "node on linux with omitted architecture reports the native-architecture default", + "tools": [{ "invocationName": "node" }], + "context": { "platform": "linux", "allowWeakIdentityFallback": true, "symbols": { "node_prefix": "/usr/local/bin" } }, + "host": { "platform": "linux", "nativeArchitecture": "arm64" }, "expect": { - "entryId": "tool:node", - "entryRevision": 1, - "catalogRevision": "2026-09-29.1", - "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, - "resolvedDependencies": [], "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["/usr/local/bin"] } }, - "warnings": ["tool:node matched only by invocation name 'node' (weak identity)"] + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [ + { "inputIndex": 0, "matches": [{ "entryId": "tool:node", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + ], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('node') matched tool:node only by invocation name (weak identity)", + "architecture was not specified; variants were selected for the native system architecture 'arm64'; the tool's architecture was not verified", + "tool:node uses its architecture-neutral linux variant; no arm64-specific variant exists" + ] + } } }, { - "name": "npm by package URL composes the node dependency on macos", - "tool": { "invocationName": "npx", "packageUrl": "pkg:npm/npm@10.9.0" }, + "name": "npm by package URL composes the node dependency on macos, with both npm predicates attributed", + "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" - } + "symbols": { "npm_prefix": "/opt/homebrew/bin", "npm_cache": "/Users/dev/.npm", "node_prefix": "/opt/homebrew/bin" } }, "expect": { - "entryId": "tool:npm", - "entryRevision": 1, - "catalogRevision": "2026-09-29.1", - "matchedIdentity": { "kind": "purl", "strength": "strong" }, - "resolvedDependencies": [{ "entryId": "tool:node", "entryRevision": 1 }], "policy": { "version": "0.9.0-alpha", "filesystem": { @@ -73,38 +72,146 @@ "readwritePaths": ["/Users/dev/app", "/Users/dev/.npm"] } }, - "warnings": [] + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [ + { + "inputIndex": 0, + "matches": [{ + "entryId": "tool:npm", + "entryRevision": 1, + "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] + }] + } + ], + "resolvedDependencies": [{ "entryId": "tool:node", "entryRevision": 1 }], + "warnings": [ + "tool:npm uses its architecture-neutral macos variant; no arm64-specific variant exists", + "tool:node uses its architecture-neutral macos variant; no arm64-specific variant exists" + ] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["/usr/bin", "/opt/node/bin"], + "readwritePaths": ["/w", "/home/u/.npm"] + } + }, + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [ + { "inputIndex": 0, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }, + { "inputIndex": 1, "matches": [{ "entryId": "tool:npm", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }, + { "inputIndex": 2, "matches": [{ "entryId": "tool:node", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }, + { "inputIndex": 3, "matches": [{ "entryId": "tool:npm", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + ], + "resolvedDependencies": [{ "entryId": "tool:node", "entryRevision": 1 }], + "warnings": [ + "input 0 ('git') matched tool:git only by invocation name (weak identity)", + "input 1 ('npm') matched tool:npm only by invocation name (weak identity)", + "input 2 ('node') matched tool:node only by invocation name (weak identity)", + "input 3 ('npm') matched tool:npm only by invocation name (weak identity)", + "tool:git uses its architecture-neutral linux variant; no x64-specific variant exists", + "tool:npm uses its architecture-neutral linux variant; no x64-specific variant exists", + "tool:node uses its architecture-neutral linux variant; no x64-specific variant exists" + ] + } + } + }, + { + "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": { + "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["/usr/bin"], "readwritePaths": ["/w"] } }, + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [ + { "inputIndex": 0, "matches": [] }, + { "inputIndex": 1, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + ], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('cargo') matched no eligible catalog entry", + "input 1 ('git') matched tool:git only by invocation name (weak identity)", + "tool:git uses its architecture-neutral linux variant; no x64-specific variant exists" + ] + } } }, { "name": "invocation name alone is not accepted without opt-in", - "tool": { "invocationName": "npm" }, + "tools": "npm", "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/w", "symbols": { "npm_prefix": "/p", "npm_cache": "/c", "node_prefix": "/n" } }, - "expect": null + "expect": { + "policy": null, + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [{ "inputIndex": 0, "matches": [] }], + "resolvedDependencies": [], + "warnings": ["input 0 ('npm') matched no eligible catalog entry: tool:npm matched only by invocation name and allowWeakIdentityFallback is not enabled"] + } + } }, { - "name": "unknown tool is no match, never an empty policy", - "tool": { "invocationName": "cargo" }, - "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true }, - "expect": null + "name": "empty input array produces no policy and no per-input records", + "tools": [], + "context": { "platform": "linux", "architecture": "x64" }, + "expect": { + "policy": null, + "diagnostics": { "catalogRevision": "2026-09-29.1", "tools": [], "resolvedDependencies": [], "warnings": [] } + } }, { - "name": "unresolved required symbol is no match, never a partial policy", - "tool": { "invocationName": "git" }, + "name": "unresolved required symbol prevents policy output, with diagnostics", + "tools": "git", "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "/usr/bin" } }, - "expect": null + "expect": { + "policy": null, + "diagnostics": { + "catalogRevision": "2026-09-29.1", + "tools": [ + { "inputIndex": 0, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + ], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('git') matched tool:git only by invocation name (weak identity)", + "tool:git uses its architecture-neutral linux variant; no x64-specific variant exists", + "required symbol 'project_root' (needed by tool:git) is unresolved; supply ResolveContext.projectRoot; no policy was returned" + ] + } + } }, { "name": "unavailable explicit revision is an error, not a substitution", - "tool": { "invocationName": "git" }, + "tools": "git", "context": { "platform": "linux", "architecture": "x64", "catalogRevision": "2099-01-01.1" }, "expectError": "revision-unavailable" }, { "name": "relative symbol value is rejected", - "tool": { "invocationName": "node" }, + "tools": "node", "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "node_prefix": "bin" } }, "expectError": "invalid-context" + }, + { + "name": "symbol values that make access classes overlap across tools are a composition conflict", + "tools": ["git", "node"], + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/opt", "symbols": { "git_prefix": "/usr/bin", "node_prefix": "/opt/node" } }, + "expectError": "composition-conflict" } ] } diff --git a/policy-catalog/conformance/fixtures/synthetic-catalog.json b/policy-catalog/conformance/fixtures/synthetic-catalog.json index 3ae9ee5e4..7a8a9152a 100644 --- a/policy-catalog/conformance/fixtures/synthetic-catalog.json +++ b/policy-catalog/conformance/fixtures/synthetic-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Language-neutral conformance cases against an inline synthetic catalog revision (not published). Covers identity strength, version-range warnings, architecture variants, dependency metadata, and composition. See bundled-catalog.json for the case format.", + "$comment": "Language-neutral conformance cases against an inline synthetic catalog revision (not published). Covers additive identity matching, equal-strength and strong+weak matches to different entries, version-range warnings, native-architecture defaults, exact vs neutral variants, dependency metadata, and composition rejection. See bundled-catalog.json for the case format. `host` overrides the simulated host (default linux/x64).", "catalog": { "catalogSchemaVersion": "1", "catalogRevision": "2000-01-01.1", @@ -25,6 +25,19 @@ ], "provenance": { "method": "fixture", "sourceRevision": "fixture" } }, + { + "entryId": "tool:app-plugin", + "entryRevision": 1, + "displayName": "App plugin host", + "identity": [{ "kind": "invocation-name", "names": ["app"] }], + "platformVariants": [ + { + "when": { "platform": "linux" }, + "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/plugins"] } } + } + ], + "provenance": { "method": "fixture", "sourceRevision": "fixture" } + }, { "entryId": "tool:app", "entryRevision": 5, @@ -63,123 +76,206 @@ "cases": [ { "name": "exact architecture variant precedes the neutral variant; dependency range is unevaluated metadata", - "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app", "detectedVersion": "2.4.1" }, + "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app", "detectedVersion": "2.4.1" }, "context": { "platform": "linux", "architecture": "arm64", "projectRoot": "/w/proj", "symbols": { "git_prefix": "/opt/g" } }, "expect": { - "entryId": "tool:app", - "entryRevision": 5, - "catalogRevision": "2000-01-01.1", - "matchedIdentity": { "kind": "purl", "strength": "strong" }, - "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2, "requiredVersionRange": ">=22" }], "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["/opt/g/lib", "/opt/g/share"], "readwritePaths": ["/w/proj"] } }, - "warnings": [] + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "matches": [{ "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] }] + } + ], + "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2, "requiredVersionRange": ">=22" }], + "warnings": ["tool:base uses its architecture-neutral linux variant; no arm64-specific variant exists"] + } + } + }, + { + "name": "a strong match does not suppress another entry's eligible weak match; both contribute once", + "tools": [{ "invocationName": "app", "packageUrl": "pkg:npm/app" }], + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, + "expect": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["/g/lib", "/g/share", "/g/plugins"], "readwritePaths": ["/w"] } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "matches": [ + { "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] }, + { "entryId": "tool:app-plugin", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] } + ] + } + ], + "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], + "warnings": [ + "input 0 ('app') matched tool:app-plugin only by invocation name (weak identity)", + "input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute", + "tool:app uses its architecture-neutral linux variant; no x64-specific variant exists", + "tool:base uses its architecture-neutral linux variant; no x64-specific variant exists", + "tool:app-plugin uses its architecture-neutral linux variant; no x64-specific variant exists" + ] + } } }, { - "name": "neutral variant is the fallback when no exact architecture exists", - "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, - "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, + "name": "equal-strength weak matches to different entries both contribute, not an ambiguity error", + "tools": "app", + "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, "expect": { - "entryId": "tool:app", - "entryRevision": 5, - "catalogRevision": "2000-01-01.1", - "matchedIdentity": { "kind": "purl", "strength": "strong" }, - "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], "policy": { "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["/g/lib", "/g/share"], "readwritePaths": ["/w"] } + "filesystem": { "readonlyPaths": ["/g/lib", "/g/share", "/g/plugins"], "readwritePaths": ["/w"] } }, - "warnings": [] + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "matches": [ + { "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }, + { "entryId": "tool:app-plugin", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] } + ] + } + ], + "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], + "warnings": [ + "input 0 ('app') matched tool:app only by invocation name (weak identity)", + "input 0 ('app') matched tool:app-plugin only by invocation name (weak identity)", + "input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute", + "tool:app uses its architecture-neutral linux variant; no x64-specific variant exists", + "tool:base uses its architecture-neutral linux variant; no x64-specific variant exists", + "tool:app-plugin uses its architecture-neutral linux variant; no x64-specific variant exists" + ] + } } }, { - "name": "version-range mismatch is a warning, not a refusal", - "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app@3.0.0" }, + "name": "version-range mismatch is a warning, not a refusal; a standalone entry may carry network", + "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app@3.0.0" }, "context": { "platform": "windows", "architecture": "x64", "projectRoot": "D:\\src" }, "expect": { - "entryId": "tool:app", - "entryRevision": 5, - "catalogRevision": "2000-01-01.1", - "matchedIdentity": { "kind": "purl", "strength": "strong" }, - "resolvedDependencies": [], "policy": { "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["D:\\src"] }, "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } }, - "warnings": ["detected version '3.0.0' is outside the reviewed range '>=2 <3' for tool:app"] + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "matches": [{ "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] }] + } + ], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('app'): detected version '3.0.0' is outside the reviewed range '>=2 <3' for tool:app", + "tool:app uses its architecture-neutral windows variant; no x64-specific variant exists" + ] + } } }, { - "name": "no platform variant is no match", - "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, + "name": "no platform variant contributes no match", + "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, "context": { "platform": "macos", "architecture": "arm64", "projectRoot": "/w" }, - "expect": null + "expect": { + "policy": null, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [{ "inputIndex": 0, "matches": [] }], + "resolvedDependencies": [], + "warnings": ["input 0 ('app') matched no eligible catalog entry: tool:app has no variant for macos/arm64; tool:app-plugin matched only by invocation name and allowWeakIdentityFallback is not enabled"] + } + } }, { - "name": "explicit x64 selects the x64 variant when an arm64 variant also exists", - "tool": { "invocationName": "base" }, - "context": { "platform": "windows", "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "C:\\g" } }, + "name": "omitted architecture on an ARM64 host selects the native ARM64 variant", + "tools": "base", + "host": { "platform": "windows", "nativeArchitecture": "arm64" }, + "context": { "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "C:\\g" } }, + "expect": { + "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["C:\\g\\arm64"] } }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [{ "inputIndex": 0, "matches": [{ "entryId": "tool:base", "entryRevision": 2, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('base') matched tool:base only by invocation name (weak identity)", + "architecture was not specified; variants were selected for the native system architecture 'arm64'; the tool's architecture was not verified" + ] + } + } + }, + { + "name": "explicit x64 on the same ARM64 host selects the x64 variant", + "tools": "base", + "host": { "platform": "windows", "nativeArchitecture": "arm64" }, + "context": { "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "C:\\g" } }, "expect": { - "entryId": "tool:base", - "entryRevision": 2, - "catalogRevision": "2000-01-01.1", - "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, - "resolvedDependencies": [], "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["C:\\g\\x64"] } }, - "warnings": ["tool:base matched only by invocation name 'base' (weak identity)"] + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [{ "inputIndex": 0, "matches": [{ "entryId": "tool:base", "entryRevision": 2, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }], + "resolvedDependencies": [], + "warnings": ["input 0 ('base') matched tool:base only by invocation name (weak identity)"] + } } }, { "name": "invocation names are case-insensitive on windows", - "tool": { "invocationName": "APP.CMD" }, + "tools": "APP.CMD", "context": { "platform": "windows", "architecture": "arm64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\p" }, "expect": { - "entryId": "tool:app", - "entryRevision": 5, - "catalogRevision": "2000-01-01.1", - "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, - "resolvedDependencies": [], "policy": { "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["C:\\p"] }, "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } }, - "warnings": ["tool:app matched only by invocation name 'APP.CMD' (weak identity)"] + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [{ "inputIndex": 0, "matches": [{ "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('APP.CMD') matched tool:app only by invocation name (weak identity)", + "tool:app uses its architecture-neutral windows variant; no arm64-specific variant exists" + ] + } } }, { "name": "invocation names are case-sensitive on linux", - "tool": { "invocationName": "APP" }, + "tools": "APP", "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/p", "symbols": { "git_prefix": "/g" } }, - "expect": null - }, - { - "name": "unmatched package URL falls back to weak identity only with opt-in", - "tool": { "invocationName": "app", "packageUrl": "pkg:npm/other" }, - "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, "expect": { - "entryId": "tool:app", - "entryRevision": 5, - "catalogRevision": "2000-01-01.1", - "matchedIdentity": { "kind": "invocation-name", "strength": "weak" }, - "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], - "policy": { - "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["/g/lib", "/g/share"], "readwritePaths": ["/w"] } - }, - "warnings": [ - "package URL 'pkg:npm/other' matched no entry; fell back to invocation-name identity", - "tool:app matched only by invocation name 'app' (weak identity)" - ] + "policy": null, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [{ "inputIndex": 0, "matches": [] }], + "resolvedDependencies": [], + "warnings": ["input 0 ('APP') matched no eligible catalog entry"] + } } }, + { + "name": "network fields cannot compose across entries selected by different inputs", + "tools": ["APP.cmd", "base"], + "context": { "platform": "windows", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\p", "symbols": { "git_prefix": "C:\\g" } }, + "expectError": "composition-conflict" + }, { "name": "symbol values that make access classes overlap are a composition conflict", - "tool": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, + "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/g", "symbols": { "git_prefix": "/g" } }, "expectError": "composition-conflict" } diff --git a/policy-catalog/docs/architecture.md b/policy-catalog/docs/architecture.md new file mode 100644 index 000000000..0b0796f3d --- /dev/null +++ b/policy-catalog/docs/architecture.md @@ -0,0 +1,132 @@ +# Architecture + +This page describes how the TypeScript prototype implements the +[design proposal](design.md). The design is authoritative. Where this page and +the design disagree, the design wins and this page is a bug. + +## Boundary + +The library owns a read-only, integrity-validated catalog and a resolver that +turns tool inputs into a **candidate lower-bound** `SandboxPolicy` +([design §2](design.md#2-ownership-boundary)). + +The library does not: + +- grant access or authorize anything +- create or launch a sandbox +- contact the network or download catalog updates +- run or inspect the candidate tool +- write any consumer state +- import from, or require, the MXC SDKs or executors + +Consumers keep every decision the design assigns to them: whether lookup is +enabled, authorization and elevation, persistence, composition with their own +layers and ceilings, approval, audit, and the final sandbox creation. + +## Modules + +| Module | Responsibility | +|---|---| +| `src/types.ts` | Public types from design §5 (`ToolInput`, `ResolveContext`, `SandboxConfigResolution`, metadata). | +| `src/errors.ts` | `PolicyCatalogError` and its stable failure categories. | +| `src/canonical-json.ts` | Canonical JSON and SHA-256 used for revision integrity. | +| `src/catalog.ts` | Contract and revision validation, variant selection, dependency closure, composition limits. | +| `src/store.ts` | Manifest validation; lazy, digest-checked, deep-frozen revision loading. | +| `src/host.ts` | Host platform, native system architecture, and approved host symbols. | +| `src/resolver.ts` | `getSandboxConfig`, `getSandboxConfigWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries`. | +| `src/history.ts` | `entryRevision` monotonicity and published-revision immutability checks. | +| `src/cli.ts` | `policy-catalog` command-line harness over the public API (functional tests, CI, contributors). | +| `src/tooling.ts` | `./tooling` subpath exports for validation scripts and tests. It is not part of the runtime API. | + +## Resolution pipeline + +One call resolves one input or an array of inputs in a single pass. A single +input is treated exactly like a one-element array. + +1. **Validate inputs and context.** Invalid input throws `invalid-context`. It + is never reported as absence. +2. **Load the revision.** Use the requested `catalogRevision`, or the + installed default. A missing revision is `revision-unavailable`, never a + substitution. A digest mismatch is `integrity`. +3. **Match each input additively** (design §4.3). Entries are visited in + `entryId` order, so catalog file order is irrelevant. For each entry, every + satisfied predicate is collected in declaration order: + - A `purl` match is strong. + - An `invocation-name` match is weak and needs `allowWeakIdentityFallback`. + - Invocation names compare case-insensitively on Windows and macOS, and + case-sensitively on Linux. + - A strong match never suppresses another entry's eligible weak match. +4. **Select a variant** (design §4.4). The exact architecture wins over the + architecture-neutral variant, and another architecture is never used as a + fallback. An omitted architecture uses the native system architecture (see + below), which is detected lazily, only when a candidate entry needs it. +5. **Close over dependencies.** Traversal is depth-first in declaration order + and rejects cycles. Every selected entry is keyed by `entryId`, so it + contributes once no matter how many inputs or edges reach it. +6. **Apply the composition limits** (design §4.5) to the whole selected set: + - every policy declares the same `sandboxPolicy.version` + - with more than one entry, only `filesystem.{deniedPaths,readonlyPaths,readwritePaths}` + may appear +7. **Resolve symbols.** `project_root` comes only from `projectRoot`. Other + symbols come from `symbols`; `host` symbols are derived only for the current + host platform. If any required symbol is missing, the result is + `policy: undefined` plus a warning that names the symbol. The resolver never + returns a partial policy. +8. **Compose.** Paths are substituted, normalized with the platform's path + rules, and de-duplicated per access class in first-seen order. Equal or + ancestor/descendant paths in different classes throw + `composition-conflict`. The resolver never picks an access class. + +Diagnostics follow the design exactly: + +- tool records in input order +- matches ordered by `entryId` +- matched predicates in declaration order +- dependency metadata de-duplicated and ordered by + entryId/entryRevision/requiredVersionRange +- warnings for weak-only matches, multi-entry matches, version-range + mismatches, unmatched inputs, the host-default architecture, and neutral + fallback + +## Native system architecture + +The spec requires the device's native architecture rather than the process +architecture ([design §4.4](design.md#44-platform-variants)). +`nodeHostEnvironment.nativeArchitecture()` reads the following: + +- **Windows:** the machine-wide `PROCESSOR_ARCHITECTURE` in + `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, read + with `%SystemRoot%\System32\reg.exe`. An x64 process emulated on ARM64 sees + `AMD64` in its own environment and from `os.machine()`, but the machine-wide + value is `ARM64`. +- **macOS:** `sysctl -n hw.optional.arm64`, read with `/usr/sbin/sysctl`. It + reports Apple silicon even under Rosetta. Intel Macs lack the key. +- **Linux:** `os.machine()`, the kernel machine type. + +Executables are invoked by absolute path. An unknown or unreadable value throws +`unsupported-host`. The resolver never guesses. The result is cached for the +process. + +## Integrity and immutability + +`catalog/manifest.json` lists every published revision with a SHA-256 over its +canonical JSON form (keys sorted recursively, no insignificant whitespace), so +formatting or line-ending changes never invalidate a digest. Loaded revisions +are deep-frozen. The validation pipeline compares proposed changes against a +base git ref and rejects any change to a published revision's manifest entry +or content ([design §10](design.md#10-immutable-revisions)). + +## Failure categories + +| Category | Meaning | +|---|---| +| `integrity` | Catalog data cannot be read or does not match its published digest. | +| `validation` | Catalog, manifest, or contract data violates the catalog contract. | +| `revision-unavailable` | An explicitly requested revision is not installed. | +| `invalid-context` | The caller's inputs or `ResolveContext` are invalid. | +| `unsupported-host` | The host platform or native architecture cannot be mapped or determined. | +| `composition-conflict` | The selected entries cannot be composed under the v1 rules. | + +Absence is not a failure. `getSandboxConfig` returns `undefined`, and +`getSandboxConfigWithDiagnostics` returns `policy: undefined` with its +diagnostics. diff --git a/policy-catalog/docs/contributing.md b/policy-catalog/docs/contributing.md new file mode 100644 index 000000000..c585ca345 --- /dev/null +++ b/policy-catalog/docs/contributing.md @@ -0,0 +1,105 @@ +# Contributing + +This page describes the contribution flow for catalog data and for the library +([design §7](design.md#7-contribution-and-review)). No client or library can +write a catalog entry at runtime. Every change is a pull request. + +## Catalog changes + +Published revisions are immutable +([design §10](design.md#10-immutable-revisions)). Never edit a file under +`catalog/revisions/` that is already listed in `catalog/manifest.json`. That +includes corrections and security fixes: publish a new revision instead. + +1. Copy the latest revision to `catalog/revisions/.json` and set + `catalogRevision` to the same identifier. +2. Make the change. Bump `entryRevision` for every entry that changes + semantically, and leave unchanged entries alone. Validation enforces both + rules. +3. Build, compute the new file's digest, and append it to the manifest: + + ```text + npm run build + npm run digest -- catalog/revisions/.json + ``` + + Add `{ catalogRevision, file, sha256 }` to the end of `revisions` and point + `defaultRevision` at it. +4. Add or update cases in `conformance/fixtures/`. Every entry in the default + revision must appear in at least one bundled-catalog case. +5. Run the full check: + + ```text + npm run check + POLICY_CATALOG_BASE_REF=origin/main npm run validate + ``` + +### Entry requirements + +Each new entry, or each requirement expansion, needs the following +([design §7](design.md#7-contribution-and-review)): + +- identity evidence and the supported tool version ranges +- evidence for each platform variant +- a minimized requirement set; capture it with MXC Learning Mode before the + pull request ([design §8](design.md#8-relationship-to-learning-mode)) +- test fixtures +- `provenance` that references the review evidence + +A requirement reduction needs regression evidence that every supported tool +version still works under the narrower requirement. + +Review requires one catalog-owner approval and one security/policy-reviewer +approval, plus tool- or scenario-owner evidence where available. CI cannot +enforce this; the repository's branch rules and CODEOWNERS must. + +### Rules enforced by `npm run validate` + +- JSON Schema conformance (`schema/`) +- digest integrity for every revision in the manifest +- exact registered `SandboxPolicy` versions (`catalog/contract.v1.json`) +- entry-ID uniqueness, with no repeated identity predicate within one entry +- valid platform and architecture selectors: no duplicate exact selector and + at most one architecture-neutral variant per platform +- no backend-specific keys in a variant +- dependency closure within the same revision, with cycles rejected +- only declared symbols; every path is anchored at a symbol, with no literal, + user-specific, wildcard, or `..` paths +- no wildcard network grants: no default-allow, no allow rule without `to`, + no `/0` CIDR +- unsupported-field rejection +- v1 composition limits within every dependency closure +- `entryRevision` monotonicity across consecutive revisions +- published-revision immutability against `POLICY_CATALOG_BASE_REF` +- deterministic, file-order-independent resolution for every selector +- package inclusion +- fixture coverage + +## Library changes + +Changes to the library API or behavior must keep the shared contract intact: + +- Any behavior visible across languages goes into `conformance/fixtures/`, + and the change must describe what the Rust and C# bindings need to match + ([design §6.2](design.md#62-cross-language-consistency-and-support)). +- Failure categories and absence semantics are part of the contract. Changing + them is a breaking change. +- Run `npm run check`, `npm run test:functional`, and + `npm run check:extract -- --worktree` before opening a pull request. + +## Test layout + +| Suite | Location | Command | +|---|---|---| +| Unit and conformance | `tests/unit/*.test.ts` | `npm run test:unit` | +| Functional (CLI end-to-end) | `tests/functional/*.test.ts` | `npm run test:functional` | +| Catalog validation | `scripts/validate-catalog.mjs` | `npm run validate` | +| Package contents | `scripts/check-pack.mjs` | `npm run check:pack` | +| Offline install smoke test | `scripts/package-smoke.mjs` | `npm run smoke:package` | +| Standalone extraction | `scripts/check-extractable.mjs` | `npm run check:extract` | + +Tests use Node's built-in `node:test` runner with the spec reporter, compiled +by `tsc` into `dist-tests/`. This mirrors the layout of the MXC TypeScript SDK +(`sdk/node/tests/unit`, `sdk/node/tests/integration`). Unit tests import the +package through its own name, so they exercise the same compiled `dist/` +output that ships. diff --git a/policy-catalog/docs/design.md b/policy-catalog/docs/design.md new file mode 100644 index 000000000..81cc6f2b5 --- /dev/null +++ b/policy-catalog/docs/design.md @@ -0,0 +1,809 @@ + + +# Feature Spec: Known-tool Policy Floors + +**Status:** Proposed public preview catalog. This is not an approved, shipped, or +implemented catalog. + +**IMPORTANT NOTE:** This is a time-limited bridge, not a long-term supported +Microsoft product. Applying a published floor does not guarantee that a tool's +end-to-end workflow will work under process containment. The catalog and its +dedicated repository are expected to be retired when Learning Mode provides +the replacement workflow. + +--- + +## 1. Problem Statement + +Developers frequently disable process isolation after enabling it breaks tools +needed for their workflow. This makes the first-run experience for process +containment poor and reduces adoption before developers can identify the +missing policy. + +The near-term mitigation is a public, reviewable set of known per-tool policy +floors. Consumers can apply a candidate floor instead of starting from no tool +knowledge, and contributors can iterate on the data as failures are found. A +floor only describes a known minimum requirement. It does not prove that every +process, dependency, credential, service, or network interaction in an +end-to-end workflow is covered. + +[#779](https://github.com/microsoft/mxc/pull/779) proposed the initial config +floor data model and SDK resolver. This document narrows that proposal into a +temporary catalog contract with explicit identity, platform, dependency, +revision, and inspection behavior. It reuses #779's model where possible and +calls out differences directly. + +This document does not restate general MXC sandboxing concepts already covered +by [`docs/sandbox-policy/0.8.0/policy.md`](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/policy.md) or +[`docs/versioning.md`](https://github.com/microsoft/mxc/blob/main/docs/versioning.md). It covers only what a policy store adds. + +### Non-goals + +- This does not change what the sandbox backend enforces, or `SandboxPolicy` / + `ContainerConfig` schema semantics. A catalog entry embeds an existing + `SandboxPolicy`; it does not define a parallel vocabulary. +- This is not a trust or attestation mechanism, and it does not authorize + anything. See [§9](#9-trust-model). +- This is not a guarantee that a complete tool workflow will succeed under + process containment. +- This does not define how any specific consumer stores, displays, or lets a + user approve requirements. See [§2](#2-ownership-boundary). +- This does not define Learning Mode's candidate-generation or review UX. See + [§8](#8-relationship-to-learning-mode). + +### MXC feature impact and defaults + +This is a standalone catalog and library proposal. Following the feature-impact +checklist in [`docs/authoring-a-new-feature.md`](https://github.com/microsoft/mxc/blob/main/docs/authoring-a-new-feature.md): + +- **Policy changes:** None. Catalog entries embed an existing, registered + `SandboxPolicy`. +- **ContainerConfig changes:** None. The catalog does not add configuration + fields or change omission behavior in an existing contract. +- **OS and backend changes:** None. Backends continue to validate whether they + can enforce the resolved policy. +- **MXC SDK changes:** None. This proposal does not add catalog lookup, types, + or resolver code to the MXC SDKs. +- **Standalone libraries:** The dedicated catalog repository provides its own + library API for TypeScript/JavaScript, Rust, and C#/.NET. See + [§6](#6-intended-repository-and-packaging-boundary). + +The proposed **public preview** designation describes the catalog's support +status. It does not add an MXC schema feature, activate the +`--experimental` runtime gate, or change executor behavior. + +Defaults and omission behavior are: + +- Existing callers do not perform catalog lookup automatically. A consumer + must explicitly enable or invoke it. +- Omitted `ResolveContext.platform` uses the current host platform. +- Omitted `ResolveContext.architecture` uses the device's native system + architecture, not the architecture of the library's process or a detected + tool build. Explicit caller selection takes precedence. See the selection + rules and emulation risk in [§4.4](#44-platform-variants). +- Omitted `ResolveContext.catalogRevision` uses the currently installed + catalog revision. +- Omitted `ResolveContext.allowWeakIdentityFallback` is `false`. +- Omitted `projectRoot` and `symbols` provide no caller overrides. The resolver + may use approved host-known symbols, but it does not invent machine-specific + values. A selected entry with an unresolved required symbol is not + resolvable. +- Omitted `packageUrl` or `detectedVersion` supplies no matching evidence. The + resolver does not fabricate either value. +- If no policy can be resolved, `getSandboxConfig` returns `undefined`. + `getSandboxConfigWithDiagnostics` instead returns a result whose `policy` is + `undefined`, preserving the diagnostics. The consumer's restrictive baseline + remains unchanged. + +## 2. Ownership boundary + +The proposed dedicated catalog project owns an integrity-validated, versioned, +read-only data set of known-tool sandbox requirements, its resolver libraries, +and their public APIs. MXC continues to own the existing `SandboxPolicy` +contract, but not the catalog entries, libraries, repository, or publication +lifecycle. + +The catalog states a candidate minimum that a tool needs. It does not grant +access, modify caller state, create a sandbox, or guarantee workflow success. + +Everything else is a consumer decision: + +- Whether automatic catalog lookup is enabled at all. +- Access-profile mapping, elevation preference, and per-tool authorization. +- Persistence of accepted requirements (which tools, which catalog/entry + revision, when). +- Composition with the consumer's own user, learned, and invocation-specific + policy layers, and with non-overridable OS/enterprise/device ceilings. +- Approval UX, audit, and the final call into `createConfigFromPolicy()` / + sandbox creation. + +A catalog lookup can only ever narrow what a consumer still has to decide for +itself. `getSandboxConfig` returns a candidate composed requirement or +`undefined`; its diagnostics counterpart also reports how that result was +obtained. The consumer decides whether and how to act on it. This mirrors +#779's floor/policy distinction, discussed further in +[§3](#3-relationship-to-the-config-floors-proposal): a resolved entry is a +lower bound asserted by the tool ecosystem, never an upper bound the host is +required to grant. + +## 3. Relationship to the config-floors proposal + +| #779 (config floors) | This document (policy store) | +|---|---| +| One `schemaVersion` for the whole table | Four separate version dimensions: `catalogSchemaVersion`, `catalogRevision`, per-entry `entryRevision`, and per-variant `sandboxPolicy.version` ([§4.1](#41-versions)) | +| Strongest satisfied identity predicate describes a match | All eligible matching entries contribute; identity evidence is retained without stronger matches suppressing weaker ones ([§4.3](#43-identity)) | +| One `sandboxPolicy` per entry; `when.platform` only conditions dependencies | One complete `SandboxPolicy` per platform variant; a variant cannot name a containment backend ([§4.4](#44-platform-variants)) | +| `requires` composition unspecified beyond "union" | Composition limited to a small, explicit, field-by-field set for the first contract version; everything else is rejected until a rule exists ([§4.5](#45-dependencies-and-composition)) | +| `getSandboxConfigForTool(tools: string[])` returns one composed policy | `getSandboxConfig` accepts one tool or an array and returns one policy; `getSandboxConfigWithDiagnostics` adds attribution, with catalog inspection kept separate ([§5](#5-api-surface)) | +| No revision/publication model | Immutable published catalog revisions; corrections publish a new revision ([§10](#10-immutable-revisions)) | + +The data model, the floor/policy direction argument, multi-tool composition, +and the identity layering problem (invocation name vs. launcher artifact vs. +executing image) build on #779. The matching, composition, and trust refinements +are stated here rather than implied by that reference. + +## 4. Data model + +### 4.1 Versions + +| Field | Meaning | +|---|---| +| `catalogSchemaVersion` | Version of the catalog JSON shape itself. | +| `catalogRevision` | Immutable identifier for one published, fully reviewed catalog. | +| `entryRevision` | Monotonic revision of a single entry, for cache invalidation and audit comparison. | +| `sandboxPolicy.version` | The exact registered `SandboxPolicy` contract version used by one platform variant. | + +These identifiers serve separate purposes and do not advance in lockstep. +Registering a new `SandboxPolicy` contract does not change existing catalog +data and therefore does not require a new catalog revision. Migrating a +platform variant to that contract changes the entry's content, so publication +of that migration must increment both `entryRevision` and `catalogRevision`. +A catalog revision may still change without incrementing unaffected entries. +Tool version constraints (`versionRange`, below) are a fifth, orthogonal axis. +They describe which builds of a tool an entry was observed against, not +anything about the catalog. + +### 4.2 Entry shape + +```json +{ + "entryId": "tool:npm", + "entryRevision": 3, + "displayName": "npm / npx", + "identity": [ + { "kind": "purl", "value": "pkg:npm/npm", "versionRange": ">=10 <12" }, + { "kind": "invocation-name", "names": ["npm", "npm.cmd", "npx", "npx.cmd"] } + ], + "platformVariants": [ + { + "when": { "platform": "windows" }, + "dependencies": [{ "entryId": "tool:node", "versionRange": ">=22" }], + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": ["${npm_prefix}"], + "readwritePaths": ["${project_root}", "${npm_cache}"] + } + } + } + ], + "provenance": { "method": "reviewed-observation", "sourceRevision": "opaque-review-reference" } +} +``` + +Invariants: + +- `entryId` is stable, unique, namespaced, and is the only key `requires`/ + dependency edges may reference. +- `entryRevision` increases on every semantic change to the entry. +- Variant selection follows the deterministic rules in + [§4.4](#44-platform-variants). No selected variant means the tool is + unsupported on that platform, not that it needs an empty policy, and not + `undefined` conflated with "requires nothing" (see [#779, "Defaults and + omission"](https://github.com/microsoft/mxc/pull/779)). +- Symbols (`${project_root}`, `${npm_cache}`, OS well-known folders) are + resolved by the resolver before a policy is returned; catalog data never + ships a literal, machine-specific path. This is unchanged from #779. +- An embedded `sandboxPolicy` is validated against the real `SandboxPolicy` + schema for its declared `version`. The catalog schema does not duplicate + that validation. + +### 4.3 Identity + +`identity` describes the predicates a candidate can satisfy for an entry, +using the layering #779 §3.1 establishes (invocation name vs. launcher artifact +vs. executing image; falsifiable-against-a-local-artifact as the admission test +for a new kind). + +Matching is additive across entries. For each input tool, the resolver collects +every entry with a satisfied, eligible identity predicate and an applicable +platform/architecture variant. It does not choose a single winning entry: + +- A package-identity match does not suppress another entry's eligible + invocation-name match. Equal-strength matches to different entries also + contribute; they are not ambiguity errors. +- Identity strength describes the matching evidence, not precedence between + entries. Diagnostics retain all satisfied identity predicates for each + contributing entry. +- Multiple predicates matching the same entry do not add its policy multiple + times. Entries shared across input tools or dependency chains likewise + contribute once, while diagnostics preserve the per-input matches. +- Multiple matching entries for one input produce a diagnostic warning, not + a refusal. Their policies must still satisfy the composition rules in + [§4.5](#45-dependencies-and-composition). +- Diagnostic tool records follow input order, matches are ordered by + `entryId`, and matched predicates follow their declaration order within the + entry. Catalog file order does not select or exclude a match. + +The existing matching qualifications remain: + +- A version range on an identity predicate is advisory matching evidence, not + a gate. A detected mismatch returns a diagnostic alongside the resolved + policy rather than silently degrading precision, and the consumer decides + what to do with the mismatch. +- Invocation-name-only identity is the always-available fallback, not the + default outcome. Whether a consumer accepts an invocation-name-only match + automatically, or requires opt-in, is unresolved. See + [§13](#13-open-questions). Under the current proposed default, an entry + matched only by invocation name participates when + `allowWeakIdentityFallback` is `true`. Compose-all-matches does not bypass + that option. + +### 4.4 Platform variants + +Supported platforms are `windows`, `linux`, and `macos`. Supported architecture +selectors are `x64` and `arm64`. A variant selector has this closed shape: + +```ts +interface PlatformVariantSelector { + platform: "windows" | "linux" | "macos"; + architecture?: "x64" | "arm64"; +} +``` + +A platform variant is a complete requirement statement: one full +`SandboxPolicy`, not a patch applied to a base policy, plus any +platform-specific dependencies. Variants are never merged. Architecture is a +catalog selector, not a field added to the embedded `SandboxPolicy`. Omitting +`when.architecture` makes a catalog variant architecture-neutral; omitting the +caller's `ResolveContext.architecture` instead requests the host default. + +Selection first filters by platform, then uses the following precedence: + +| Caller context | Preferred variant | Fallback | +|---|---|---| +| Explicit `architecture: "x64"` | x64 for the selected platform | Architecture-neutral for that platform | +| Explicit `architecture: "arm64"` | ARM64 for the selected platform | Architecture-neutral for that platform | +| Architecture omitted | Device's native system architecture for the selected platform | Architecture-neutral for that platform | + +The native system architecture is the architecture reported by the host OS, +not the architecture of the process hosting the library. For example, on an +ARM64 device with both x64 and ARM64 catalog variants and no neutral variant, +omitting architecture selects ARM64. An explicit `architecture: "x64"` selects +x64 on that same device. The resolver does not require a neutral variant to +return a result when the effective architecture has an exact match. + +Catalog validation rejects duplicate exact selectors and more than one +architecture-neutral variant for the same platform. If neither an exact nor +architecture-neutral variant exists, that entry contributes no match, not an +empty policy or a variant for a different architecture. A failure to determine +the native system architecture when it is needed is a library error, not a +guessed selection. + +**Emulation risk:** A host-derived default does not establish the architecture +of the installed tool. An x64 tool running under emulation on an ARM64 device +may need the x64 variant rather than the default ARM64 variant. The resolver +does not inspect or run the tool to discover its architecture. Callers that +know the relevant tool and runtime requirements should select architecture +explicitly and remain responsible for deciding whether the result applies. +Neither explicit selection nor a host default guarantees that the returned +policy is sufficient or minimal. Host-derived selection and neutral fallback +are surfaced through `diagnostics.warnings` by the diagnostics API +([§5.1](#51-runtime-lookup)). + +A platform variant must not name a specific MXC containment backend. Policies +stay backend-neutral; the selected backend still decides whether a stated +requirement can be realized on that host. + +### 4.5 Dependencies and composition + +Dependencies reference another entry's `entryId` and live inside the platform +variant when platform-specific. An optional `versionRange` records which +dependency versions supplied the reviewed evidence. The v1 resolver has no +dependency inventory, so it does not evaluate that range or use it for +matching. It returns the range as unevaluated metadata for consumer inspection. +Catalog validation checks only that the range is syntactically valid. +Resolution is otherwise transitive, cycle-rejecting, and deterministic, and +the diagnostics API returns the resolved dependency metadata alongside the +policy. + +The same composition rules apply to all entries matched by one tool, entries +matched by different tools in an array, and their transitive dependencies. +Each selected entry contributes its policy once, even when reached through +multiple inputs or dependency edges. Repeated contribution is de-duplicated +by entry ID within the selected catalog revision, not by discarding match +attribution. A shared `ResolveContext` applies to the whole lookup. + +Unlike #779, this document does not treat "union the policies" as sufficient +composition. Silently unioning arbitrary `SandboxPolicy` objects across a +dependency chain hides exactly the kind of conflicting-field problem that +made #779 exclude `proxy` from the embedded object. For the first contract +version, cross-entry composition is limited to the exact +`filesystem.deniedPaths`, `filesystem.readonlyPaths`, and +`filesystem.readwritePaths` fields: + +1. Every policy in the selected entries and their dependency closure must + declare the same `sandboxPolicy.version`. +2. Paths are resolved, normalized using the selected platform's path rules, + and de-duplicated within the same access class. +3. Catalog validation rejects equal or ancestor/descendant paths that occur in + different access classes. It never chooses between denied, read-only, and + read-write access implicitly. +4. The non-conflicting, normalized lists are merged into the returned policy. + +The v1 contract does not compose `network`. In particular, it defines no merge +for `network.egress.default`, `network.egress.allow`, +`network.egress.deny`, `network.ingress.default`, or +`network.ingress.hostLoopback`. Composition rejects a selected set of entries +where policies from more than one entry would require composing any `network` +field. The same rejection applies to timeout, clipboard, lifecycle, UI, proxy, +and every other policy field without an explicit cross-entry rule. A lookup +resolving to only one entry without dependencies may still use +catalog-supported policy fields because no cross-entry merge occurs. + +## 5. API surface + +The standalone libraries separate runtime resolution from catalog inspection. +Resolution accepts one tool or an array and composes all applicable matching +entries and dependencies into one `SandboxPolicy`. Callers choose a policy-only +operation or a diagnostic operation over the same resolution logic. Neither +implicitly returns the whole catalog. These are in-process library calls, not +a hosted service or additions to the MXC SDKs. + +The signatures below use TypeScript to describe the shared contract. Rust and +C# expose the same operations and metadata with idiomatic names and types. +TypeScript and C# expose single-tool and array overloads; Rust uses an idiomatic +one-or-many input type because it does not support function overloading. An +absent policy is `undefined` in TypeScript/JavaScript, `None` in Rust, and +`null` in C#. Library failures remain distinct from policy absence. + +### 5.1 Runtime lookup + +```ts +interface ToolCandidate { + invocationName: string; + packageUrl?: string; + detectedVersion?: string; +} + +type ToolInput = string | ToolCandidate; + +interface ResolveContext { + projectRoot?: string; + symbols?: Record; + platform?: "windows" | "linux" | "macos"; + architecture?: "x64" | "arm64"; + catalogRevision?: string; + allowWeakIdentityFallback?: boolean; +} + +interface SandboxConfigResolution { + policy: SandboxPolicy | undefined; + diagnostics: { + catalogRevision: string; + tools: Array<{ + inputIndex: number; + matches: Array<{ + entryId: string; + entryRevision: number; + matchedIdentities: Array<{ + kind: string; + strength: "strong" | "weak"; + }>; + }>; + }>; + resolvedDependencies: Array<{ + entryId: string; + entryRevision: number; + requiredVersionRange?: string; + }>; + warnings: string[]; + }; +} + +declare function getSandboxConfig( + tool: ToolInput, + ctx?: ResolveContext +): SandboxPolicy | undefined; + +declare function getSandboxConfig( + tools: readonly ToolInput[], + ctx?: ResolveContext +): SandboxPolicy | undefined; + +declare function getSandboxConfigWithDiagnostics( + tool: ToolInput, + ctx?: ResolveContext +): SandboxConfigResolution; + +declare function getSandboxConfigWithDiagnostics( + tools: readonly ToolInput[], + ctx?: ResolveContext +): SandboxConfigResolution; +``` + +A string input is shorthand for `{ invocationName: tool }`; it supplies no +package or version evidence and follows the same weak-identity option as an +object input. For example, name-only lookup under the current proposed opt-in +rule is: + +```ts +const ctx = { allowWeakIdentityFallback: true }; +const policy = getSandboxConfig("npm", ctx); +const combinedPolicy = getSandboxConfig(["git", "npm"], ctx); +const result = getSandboxConfigWithDiagnostics("npm", ctx); +const combinedResult = + getSandboxConfigWithDiagnostics(["git", "npm"], ctx); +``` + +Single-tool lookup is equivalent to a one-element array; its diagnostic +`inputIndex` is `0`. A caller retaining separate policies per tool can use +single-tool calls. A caller wanting one sandbox for several tools passes an +array. Both forms compose every eligible matching entry, not just the +strongest match, and the selected dependencies. + +`getSandboxConfig` returns the composed `SandboxPolicy` directly, not a wrapper +or a `ContainerConfig`. `getSandboxConfigWithDiagnostics` returns that same +policy with attribution and warnings from the same resolution pass. Callers +choose one operation; retrieving diagnostics does not require a second lookup +or process-global "last result" state. + +Following #779, an unmatched input contributes no requirements while matched +inputs still contribute. Each input has a diagnostic record; an unmatched +input has an empty `matches` list and a warning. An empty input array or an +all-unmatched lookup produces no policy, not an empty policy: +`getSandboxConfig` returns `undefined`, while the diagnostics operation returns +a `SandboxConfigResolution` with `policy: undefined`. An empty array has no +per-input records. Unresolved required symbols in selected entries prevent a +policy from being returned and produce diagnostics; they are not grounds for +silently omitting a selected requirement to produce a partial policy. + +Multiple matching entries for one input are listed in `matches`, with a +warning identifying that input and the contributing entry IDs. Shared entries +remain attributed to every matching input even though their policy is +composed once. Dependency diagnostics retain each distinct +entry/revision/required-version-range combination, ordered by those fields; +repeated metadata does not mean repeated policy contribution. + +When architecture is omitted, diagnostics include a warning naming the +effective native system architecture and stating that the tool's architecture +was not verified. Architecture-neutral fallback is also identified. These +diagnostics describe selection; they do not attest to the installed tool's +architecture. The policy-only operation does not expose warnings or +attribution; consumers needing them use `getSandboxConfigWithDiagnostics`. + +### 5.2 Setup and inspection + +```ts +type CatalogPlatform = "windows" | "linux" | "macos"; +type CatalogArchitecture = "x64" | "arm64"; + +type CatalogIdentityMetadata = + | { kind: "purl"; value: string; versionRange?: string } + | { kind: "invocation-name"; names: string[] }; + +interface CatalogEntryMetadata { + catalogRevision: string; + entryId: string; + entryRevision: number; + displayName: string; + identity: CatalogIdentityMetadata[]; + platformVariants: Array<{ + platform: CatalogPlatform; + architecture?: CatalogArchitecture; + dependencyEntryIds: string[]; + sandboxPolicyVersion: string; + }>; + provenance: { + method: string; + sourceRevision: string; + }; +} + +listCatalogEntries(): CatalogEntryMetadata[]; +getCatalogInfo(): { catalogSchemaVersion: string; catalogRevision: string }; +``` + +This supports setup UI, catalog browsing, and update decisions without paying +the cost of policy resolution, and keeps "give me everything" out of the +runtime lookup path entirely. Metadata exposes selectors, dependency IDs, and +provenance, but not an unresolved or resolved policy body. + +### 5.3 Consumer obligations + +A consumer that uses this API: + +1. Decides whether automatic lookup is enabled at all. +2. When persisting an accepted policy, retains its `catalogRevision` and + contributing entry IDs/revisions from diagnostics, whether the policy + covers one tool or several. +3. Keeps catalog-derived requirements in a layer separate from its own user, + learned, and invocation-specific policy. +4. Applies its own authorization, elevation, and restrictive-composition + rules on top. +5. Enforces its OS, enterprise, device, and backend ceilings regardless of + what the catalog returned. +6. Fails closed when a required entry cannot be realized on the current + host/backend. It falls back to its own restrictive baseline and does not + run uncontained. +7. Uses the diagnostics operation when attribution or audit is needed, and + records matched identities, catalog/entry revisions, warnings, and approval + state in its own audit trail. + +The catalog libraries never write a consumer's policy store. A consumer's own +capability observation (see [§8](#8-relationship-to-learning-mode)) can produce +candidate evidence for a future contribution to this catalog; it is not a +mechanism for mutating the catalog at request time. + +## 6. Intended repository and packaging boundary + +The catalog is intended to live in a new public repository outside +`microsoft/mxc`. Its schema, entries, resolver libraries, contribution history, +validation, and publication workflow belong there. This specification remains +in MXC while the proposed contract is reviewed. No catalog repository or +package is created by this proposal. + +MXC retains the existing `SandboxPolicy` contract. The catalog libraries +produce policy data conforming to that contract; they do not require an MXC +executor or execution library to perform lookup. A consumer that uses MXC +passes its final, authorized policy to an existing MXC SDK separately. +Catalog and library releases do not require an MXC SDK release or changes to +MXC repository governance. + +### 6.1 Library distribution and consumption + +The initial library language coverage matches MXC's current first-party SDK +languages, but the packages are owned and released by the catalog project: + +| Language | Distribution | API form | +|---|---|---| +| TypeScript / JavaScript | npm package | JavaScript library with TypeScript declarations | +| Rust | Cargo crate | Public Rust library API | +| C# / .NET | NuGet package | Managed library API | + +Repository and package names remain to be selected. This language match does +not require copying MXC's native-binding architecture or exposing sandbox +execution operations. + +A consumer: + +1. Installs and pins the standalone library package for its language. Each + package includes a reviewed default catalog revision for local use. +2. Calls `getCatalogInfo()` or `listCatalogEntries()` for inspection. + `getSandboxConfig()` returns a policy for one tool or an array; + `getSandboxConfigWithDiagnostics()` adds match attribution and warnings. +3. Handles policy absence without widening its restrictive baseline. It + reviews the composed policy and uses the diagnostics operation when it + needs contributing identities, revisions, and warnings, applying the + consumer obligations in [§5.3](#53-consumer-obligations). +4. Supplies its final policy to its chosen execution integration. The catalog + library does not launch a sandbox. + +Lookup is local and does not download updates, contact a hosted service, or +run the candidate tool. `ResolveContext.catalogRevision` selects an available +local revision, not a network lookup; an explicitly requested revision that +is unavailable is an error, not a substitution with a different revision. +An omitted revision uses the library's installed default. + +Catalog revisions are also published as immutable, language-neutral data +artifacts. A library package version identifies the library release, not the +catalog revision or embedded `SandboxPolicy.version`; it declares the catalog +schema and policy versions it supports and reports its bundled +`catalogRevision`. Publishing newer data can update the packages' bundled +revision without changing resolver behavior. Installing an update does not +rewrite a consumer's previously accepted per-tool policies. + +### 6.2 Cross-language consistency and support + +All three libraries use the same catalog format and shared conformance +fixtures. Given the same catalog revision, tool inputs, and explicit resolution +context, they must agree on matching, variant selection, dependency metadata, +resolved policy, warnings, and failure categories. Language-specific absence +and error types must preserve those distinctions. + +Shared fixtures cover platform path semantics as well as ordinary lookup; +matching function names alone is not compatibility. Package CI must also +exercise installation, public API usage, and host-derived defaults on the +supported platforms. Implementation sharing between languages is a separate +engineering decision, not a requirement to depend on MXC's engine. + +Supporting three languages includes maintaining parity, dependencies, +documentation, and releases, not only writing the initial implementations. +The libraries and catalog have the same limited public-preview horizon and +are intended to retire together when Learning Mode replaces this workflow. + +## 7. Contribution and review + +- Catalog contributions are pull requests against the dedicated catalog + repository. No client or SDK can write a catalog entry at runtime. +- Every entry change includes identity evidence, supported tool version + range(s), platform evidence, a minimized requirement set, test fixtures, + and provenance. +- CI validates schema conformance, exact `SandboxPolicy` version registration, + entry-ID uniqueness, dependency closure and cycle-freedom, symbol validity, + absence of unsafe user-specific literal paths, unsupported-field rejection, + deterministic resolution, and package inclusion. +- A new entry or a requirement expansion requires one catalog-owner approval + and one security/policy-reviewer approval, plus tool- or scenario-owner + evidence where available. +- A requirement reduction requires regression evidence that every supported + tool version still functions under the narrower requirement. +- Library API and implementation contributions are reviewed in the dedicated + catalog repository. These contribution requirements do not require + applications to seek maintainer approval to use the public catalog or + libraries. + +## 8. Relationship to Learning Mode + +Learning Mode is the intended long-term solution. The known-tool catalog only +reduces immediate first-run failures while that workflow is completed. It is +not a parallel long-term policy platform. + +MXC's learning-mode capabilities (`learningModeLogging`, +`permissiveLearningMode`, `captureDenials`; see +[`docs/learning-mode/capabilities.md`](https://github.com/microsoft/mxc/blob/main/docs/learning-mode/capabilities.md)) are the +substrate a contributor can use to observe what a tool actually touches, the +same way [#779 §5.1](https://github.com/microsoft/mxc/pull/779) describes for +config floors. That observation workflow is unchanged by this document and +remains **a contributor step that happens before a pull request**, not +consumer runtime behavior and not a catalog-mutation path. + +Whether and how a consumer turns its own runtime capability observations into +a candidate catalog contribution or a locally scoped policy suggestion is +that consumer's design. No runtime submission hook is proposed here. When +Learning Mode can provide the required observation and policy-authoring +experience directly, this catalog should be retired rather than promoted into +a durable platform. + +## 9. Trust model + +This document tightens #779's trust framing rather than replacing it. Entries +assert *need*, not authorization, but incorrect data has two different +outcomes: + +- An understated floor omits a requirement and can cause the tool or its + end-to-end workflow to fail under the resulting policy. +- An overstated floor can fail against a narrower consumer ceiling. If a + consumer instead approves or adopts it and its ceiling permits the request, + the effective policy contains unnecessary capability. + +What changes from #779 is the review bar. #779 described community-contributed, +unsigned, unwarranted data. This contract requires named-role approval +([§7](#7-contribution-and-review)) before an entry publishes, and publishes +under an immutable, integrity-validated revision ([§10](#10-immutable-revisions)). +That raises confidence in the data; it does not change what the data *is*. The +catalog still carries no security guarantee or independent authority. A +consumer must review the requirement and intersect it with its own policy +rather than adopt it outright. The catalog can influence a consumer's +decision, so consumer approval and restrictive ceilings remain required even +though the catalog cannot grant capability by itself. See +[#779 §2.1](https://github.com/microsoft/mxc/pull/779) for why that layering is +honest about what such a choice costs. + +## 10. Immutable revisions + +Published catalog revisions are immutable. A correction, including a security +fix to an over-broad entry, publishes a new `catalogRevision` and bumps the +affected `entryRevision`; it never rewrites a revision a consumer may already +have cached or recorded in an audit trail. + +## 11. Backward compatibility + +- No change to `SandboxPolicy` or `ContainerConfig` schema. +- No change to executor behavior. +- No change to the MXC SDK APIs or dependencies. Catalog lookup requires an + explicit call to a standalone library; existing MXC callers see no behavior + change. +- Catalog schema and API compatibility are limited to the stopgap's support + horizon. Retirement in favor of Learning Mode is an expected outcome, not a + normal promotion milestone. + +## 12. Test plan + +**Resolver libraries (TypeScript/JavaScript, Rust, and C#/.NET)** + +- shared conformance fixtures produce equivalent results and failure + categories in all three languages +- one-tool and one-element-array overloads produce equivalent policies and + diagnostics; the simple API returns the same policy as the diagnostic API +- multiple input tools compose all matching entries and dependencies into one + policy; repeated inputs or shared dependencies do not duplicate contributions +- known and unknown inputs compose the known requirements and report each + unmatched input; empty and all-unmatched arrays return no policy, never an + empty policy, while the diagnostic API preserves the resolution metadata +- omitted context uses host platform and native system architecture, the + installed catalog revision, no caller symbol overrides, and no weak-identity + fallback +- an unresolved required symbol prevents policy output, with diagnostics, + rather than silently omitting selected requirements +- one input matching several entries composes all eligible matches, including + equal-strength matches; a stronger match does not suppress a weaker eligible + match, and diagnostics preserve all matching entries and identity evidence +- multiple predicates matching the same entry contribute that policy once; + file order does not change matching or composition +- string shorthand and object inputs obey the same weak-identity fallback + option; additive matching does not bypass it +- version-range mismatch produces a warning, not a refusal +- exact-architecture variant precedes the platform-only variant; duplicate + selectors are rejected; no matching variant produces `undefined` +- on an ARM64 host with both architecture-specific variants and no neutral + variant, omitted architecture selects ARM64; explicit x64 selects x64 +- a library process running as x64 under emulation on an ARM64 host still + defaults to the native ARM64 system architecture, not its process + architecture +- a missing exact variant falls back to the platform's neutral variant; + a different architecture's variant is never used as a fallback +- successful host-derived selection and neutral fallback produce the + diagnostics specified in [§5.1](#51-runtime-lookup); host-architecture + detection failure produces a library error, not a guessed match +- dependency chain resolution, including cycles (terminate, no duplication) +- dependency `versionRange` is returned as unevaluated metadata and never used + for v1 resolver matching +- restricted composition rules ([§4.5](#45-dependencies-and-composition)): + same-class path de-duplication; cross-class path overlap, mixed policy + versions, network fields, and other unsupported composed fields are rejected + at validation time rather than resolved +- symbol resolution on Windows, Linux, and macOS + +**Data (CI)** + +- every entry and platform variant validates against the catalog schema and + the `SandboxPolicy` schema for its declared version +- `dependencies[].entryId` references resolve within the same catalog revision +- no literal absolute user-specific paths; no wildcard filesystem/network grants +- catalog/entry revision monotonicity across a proposed change + +**Integration** + +- each package installs and performs lookup without an MXC executor or + execution library; lookup requires no network access +- the bundled catalog revision matches `getCatalogInfo()`; selecting an + unavailable revision fails explicitly, without falling back to another + revision +- a package update leaves previously accepted consumer policies unchanged +- a representative tool that fails under a minimal consumer policy succeeds + once its resolved entry is composed in +- the same tool still fails when the consumer's policy forbids what the entry + requests (the floor never widens the consumer's ceiling) + +## 13. Open questions + +Recommended answers are proposals for review, not decisions. + +| Question | Recommended answer | +|---|---| +| What is the dedicated repository name and owning team? | Use a public repository outside `microsoft/mxc`; publish a separately versioned artifact so catalog updates are not coupled to SDK releases. | +| Is invocation-name-only identity accepted automatically, or does it require explicit consumer opt-in? | Treat it as a fallback requiring explicit opt-in (`allowWeakIdentityFallback`), not the default. | +| What happens on a detected tool-version mismatch: `undefined`, or a warning-bearing result the consumer may still use? | Return the resolved result with a warning; refusing outright removes information the consumer needs to decide for itself. | +| Are private or enterprise catalog overlays in scope, and if so with what precedence? | Defer until the shared catalog contract and its API are stable; define precedence explicitly before any library implementation adds overlay support. | +| Should the first contract version's composition vocabulary expand beyond [§4.5](#45-dependencies-and-composition) before implementation? | No. Start with conflict-rejecting filesystem composition and expand only with an explicit, reviewed rule per field. | +| Who owns catalog schema, data, and library API review? | Assign catalog, library, and security reviewers in the dedicated repository; no MXC SDK integration is proposed. | +| Should the libraries share a resolver implementation or implement the contract independently? | Choose based on dependency footprint and maintenance cost, with shared conformance fixtures required either way. | + +## 14. Related work + +- [`microsoft/mxc#779`](https://github.com/microsoft/mxc/pull/779) - Sandbox + Config Floors feature spec. This document's data model, floor/policy + direction argument, and identity-layering analysis build directly on it. +- [`ChazGo/mxc#1`](https://github.com/ChazGo/mxc/pull/1) - draft SDK resolver + and catalog prototype exercising lookup, dependency closure, and symbol + resolution against an earlier version of this shape. +- [`docs/sandbox-policy/0.8.0/policy.md`](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/policy.md) - + the `SandboxPolicy` contract every catalog entry embeds. +- [`docs/versioning.md`](https://github.com/microsoft/mxc/blob/main/docs/versioning.md) - the versioning model + [§4.1](#41-versions) builds on. diff --git a/policy-catalog/typescript/package-lock.json b/policy-catalog/package-lock.json similarity index 100% rename from policy-catalog/typescript/package-lock.json rename to policy-catalog/package-lock.json diff --git a/policy-catalog/package.json b/policy-catalog/package.json new file mode 100644 index 000000000..8a1ad58bf --- /dev/null +++ b/policy-catalog/package.json @@ -0,0 +1,55 @@ +{ + "name": "@mxc-prototype/policy-catalog", + "version": "0.0.0-prototype", + "private": true, + "description": "PROTOTYPE of the known-tool policy catalog library (TypeScript/JavaScript). Not published; the package name is a placeholder until a dedicated repository exists.", + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tooling": { + "types": "./dist/tooling.d.ts", + "import": "./dist/tooling.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "policy-catalog": "dist/cli.js" + }, + "files": [ + "dist/", + "catalog/", + "schema/", + "LICENSE.md", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "build:test-unit": "cd tests/unit && tsc -p tsconfig.json", + "build:test-functional": "cd tests/functional && tsc -p tsconfig.json", + "clean": "node scripts/clean.mjs", + "typecheck": "npm run build && npm run build:test-unit && npm run build:test-functional", + "test": "npm run test:unit", + "test:unit": "npm run build && npm run build:test-unit && node scripts/check-tests-present.mjs dist-tests/tests/unit && node --test --test-reporter spec \"dist-tests/tests/unit/*.test.js\"", + "test:functional": "npm run build && npm run build:test-functional && node scripts/check-tests-present.mjs dist-tests/tests/functional && node --test --test-reporter spec \"dist-tests/tests/functional/*.test.js\"", + "validate": "npm run build && node scripts/validate-catalog.mjs", + "digest": "node scripts/catalog-digest.mjs", + "check:pack": "npm run build && node scripts/check-pack.mjs", + "check:extract": "node scripts/check-extractable.mjs", + "smoke:package": "npm run build && node scripts/package-smoke.mjs", + "check": "npm run typecheck && npm run test:unit && npm run test:functional && npm run validate && npm run check:pack && npm run smoke:package", + "prepublishOnly": "npm run build" + }, + "license": "MIT", + "devDependencies": { + "@types/node": "24.13.4", + "ajv": "8.20.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/policy-catalog/typescript/scripts/catalog-digest.mjs b/policy-catalog/scripts/catalog-digest.mjs similarity index 100% rename from policy-catalog/typescript/scripts/catalog-digest.mjs rename to policy-catalog/scripts/catalog-digest.mjs diff --git a/policy-catalog/scripts/check-extractable.mjs b/policy-catalog/scripts/check-extractable.mjs new file mode 100644 index 000000000..9ec7f8d62 --- /dev/null +++ b/policy-catalog/scripts/check-extractable.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Extractability gate: copies the git-tracked contents of this directory +// (what `git filter-repo --subdirectory-filter policy-catalog` would keep) +// into a fresh temporary git repository and runs the full check there. It +// fails if anything reaches outside the directory: imports, relative links, +// scripts, or an undeclared dependency on the enclosing checkout. +// +// npm run check:extract # uses committed files (HEAD) +// npm run check:extract -- --worktree # uses tracked files as they are on disk +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageDir = fileURLToPath(new URL('..', import.meta.url)); +const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); +const repoRoot = git(['rev-parse', '--show-toplevel'], packageDir).trim(); +const prefix = relative(repoRoot, packageDir).replaceAll('\\', '/'); +const useWorktree = process.argv.includes('--worktree'); + +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-extract-')); +const target = join(work, 'repo'); +try { + if (useWorktree) { + const files = git(['ls-files', '-z', '--', '.'], packageDir).split('\0').filter(Boolean); + for (const file of files) { + const destination = join(target, file); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(packageDir, file), destination); + } + console.log(`Copied ${files.length} tracked files from the working tree.`); + } else { + // `git archive HEAD:` yields exactly the subdirectory-filtered tree. + const tar = join(work, 'tree.tar'); + git(['archive', '--format=tar', '-o', tar, `HEAD:${prefix}`], repoRoot); + mkdirSync(target); + execFileSync('tar', ['-xf', tar, '-C', target], { stdio: 'inherit' }); + console.log(`Extracted HEAD:${prefix}.`); + } + + // A fresh repository, so git-dependent checks run against the extracted root. + git(['init', '-q'], target); + git(['-c', 'user.name=extract-check', '-c', 'user.email=extract-check@invalid', 'add', '-A'], target); + git(['-c', 'user.name=extract-check', '-c', 'user.email=extract-check@invalid', 'commit', '-q', '-m', 'extract'], target); + + // Relative Markdown links must resolve inside the extracted tree. + const brokenLinks = []; + for (const file of git(['ls-files', '*.md'], target).split('\n').filter(Boolean)) { + const text = readFileSync(join(target, file), 'utf8').replace(/```[\s\S]*?```/g, ''); + for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) { + const link = match[1].split('#')[0]; + if (!link || /^[a-z][a-z0-9+.-]*:/i.test(link)) continue; + const resolved = resolve(dirname(join(target, file)), link); + if (relative(target, resolved).startsWith('..')) { + brokenLinks.push(`${file}: '${match[1]}' leaves the repository`); + continue; + } + try { + readFileSync(resolved); + } catch (error) { + if (error.code === 'EISDIR') continue; + brokenLinks.push(`${file}: '${match[1]}' does not exist`); + } + } + } + if (brokenLinks.length > 0) { + console.error('Extractability check FAILED (links):'); + for (const link of brokenLinks) console.error(` - ${link}`); + process.exitCode = 1; + } else { + const npmCli = process.env.npm_execpath; + if (!npmCli) throw new Error('run this script through `npm run check:extract`'); + const npm = args => execFileSync(process.execPath, [npmCli, ...args], { cwd: target, stdio: 'inherit' }); + npm(['ci', '--no-audit', '--no-fund']); + npm(['run', 'check']); + npm(['run', 'test:functional']); + console.log('\nExtractability check OK: the directory builds, tests, validates, and packs as a standalone repository.'); + } +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/policy-catalog/scripts/check-pack.mjs b/policy-catalog/scripts/check-pack.mjs new file mode 100644 index 000000000..0ff7e28b2 --- /dev/null +++ b/policy-catalog/scripts/check-pack.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package inclusion gate (design §7): `npm pack --dry-run` must ship the +// compiled library, the CLI, the bundled catalog, and the schemas, and must not +// ship sources, tests, fixtures, or build-only scripts. +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const npmCli = process.env.npm_execpath; +if (!npmCli) { + console.error('run this script through `npm run check:pack`'); + process.exit(2); +} +const [packed] = JSON.parse(execFileSync(process.execPath, [npmCli, 'pack', '--dry-run', '--json'], { cwd: root, encoding: 'utf8' })); +const files = packed.files.map(file => file.path.replaceAll('\\', '/')).sort(); + +const manifest = JSON.parse(readFileSync(new URL('../catalog/manifest.json', import.meta.url), 'utf8')); +const required = [ + 'LICENSE.md', + 'README.md', + 'package.json', + 'dist/index.js', + 'dist/index.d.ts', + 'dist/tooling.js', + 'dist/tooling.d.ts', + 'dist/cli.js', + 'catalog/contract.v1.json', + 'catalog/manifest.json', + ...manifest.revisions.map(revision => `catalog/${revision.file}`), + 'schema/catalog.v1.schema.json', + 'schema/manifest.v1.schema.json', +]; +const forbidden = [/^src\//, /^tests\//, /^dist-tests\//, /^conformance\//, /^scripts\//, /^docs\//, /^\.github\//, /^node_modules\//, /\.test\.js$/]; + +const errors = [ + ...required.filter(file => !files.includes(file)).map(file => `missing ${file}`), + ...files.filter(file => forbidden.some(pattern => pattern.test(file))).map(file => `unexpected ${file}`), +]; +if (errors.length > 0) { + console.error('Package contents check FAILED:'); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); +} +console.log(`Package contents check OK: ${files.length} files, ${packed.size} bytes packed (${packed.name}@${packed.version}).`); +for (const file of files) console.log(` ${file}`); diff --git a/policy-catalog/scripts/check-tests-present.mjs b/policy-catalog/scripts/check-tests-present.mjs new file mode 100644 index 000000000..993372cf4 --- /dev/null +++ b/policy-catalog/scripts/check-tests-present.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Fails when a test directory holds no *.test.js files. `node --test ` +// exits 0 when it finds nothing, so a renamed or empty test directory would +// otherwise pass silently. Mirrors MXC's scripts/versioning/check-tests-present.js. +// +// node scripts/check-tests-present.mjs +import { existsSync, readdirSync } from 'node:fs'; + +const dir = process.argv[2]; +if (!dir || !existsSync(dir)) { + console.error(`Test presence check FAILED: '${dir ?? '(none)'}' does not exist.`); + process.exit(1); +} +// Only regular files count; a directory named `x.test.js` runs nothing. +const files = readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.test.js')); +if (files.length === 0) { + console.error(`Test presence check FAILED: no *.test.js files in '${dir}'.`); + process.exit(1); +} +console.log(`Test presence check OK: ${files.length} test file(s) in ${dir}.`); diff --git a/policy-catalog/scripts/clean.mjs b/policy-catalog/scripts/clean.mjs new file mode 100644 index 000000000..e99dbe467 --- /dev/null +++ b/policy-catalog/scripts/clean.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Removes build output owned by this package only. +import { rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +for (const dir of ['dist', 'dist-tests']) { + rmSync(fileURLToPath(new URL(`../${dir}/`, import.meta.url)), { recursive: true, force: true }); +} diff --git a/policy-catalog/scripts/package-smoke.mjs b/policy-catalog/scripts/package-smoke.mjs new file mode 100644 index 000000000..e84bb0f29 --- /dev/null +++ b/policy-catalog/scripts/package-smoke.mjs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package-level integration smoke test (design §12 "Integration"): packs the +// library, installs the tarball into an empty temporary project with no +// network access and no MXC SDK or executor, and exercises the public API. +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageDir = fileURLToPath(new URL('..', import.meta.url)); +// Run npm through its JS entry point so no shell is involved on any platform. +const npmCli = process.env.npm_execpath; +if (!npmCli) { + throw new Error('run this script through `npm run smoke:package`'); +} +const run = (args, cwd) => execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8' }); +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-smoke-')); +try { + const packed = JSON.parse(run(['pack', '--json', '--pack-destination', work], packageDir)); + const tarball = join(work, packed[0].filename); + const files = packed[0].files.map(f => f.path); + // Exact package contents are gated by check-pack.mjs; this checks the install. + if (!files.includes('catalog/manifest.json')) throw new Error('package is missing the bundled catalog'); + const consumer = join(work, 'consumer'); + mkdirSync(consumer); + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: 'consumer', private: true, type: 'module' })); + run(['install', '--offline', '--no-audit', '--no-fund', tarball], consumer); + const manifest = JSON.parse(readFileSync(join(packageDir, 'catalog', 'manifest.json'), 'utf8')); + writeFileSync(join(consumer, 'smoke.mjs'), ` + import { getCatalogInfo, listCatalogEntries, getSandboxConfig, getSandboxConfigWithDiagnostics, PolicyCatalogError } from '@mxc-prototype/policy-catalog'; + import { execFileSync } from 'node:child_process'; + import assert from 'node:assert/strict'; + assert.equal(getCatalogInfo().catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); + assert.ok(listCatalogEntries().length > 0); + // Weak identity requires opt-in, so a bare name yields no policy. + assert.equal(getSandboxConfig('git', { platform: 'linux', architecture: 'x64' }), undefined); + const ctx = { platform: 'linux', architecture: 'x64', allowWeakIdentityFallback: true, + projectRoot: '/p', symbols: { git_prefix: '/usr/bin', node_prefix: '/n', npm_prefix: '/n', npm_cache: '/c' } }; + assert.deepEqual(getSandboxConfig(['git', 'npm'], ctx).filesystem.readwritePaths, ['/p', '/c']); + const r = getSandboxConfigWithDiagnostics('git', ctx); + assert.equal(r.diagnostics.tools[0].matches[0].entryId, 'tool:git'); + assert.throws(() => getSandboxConfig('git', { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); + // The packaged CLI runs from the installed tree. + const info = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'info'], { encoding: 'utf8' })); + assert.equal(info.catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); + // On POSIX, npm installs the bin as a symlink; running through it must still work. + if (process.platform !== 'win32') { + const viaBin = JSON.parse(execFileSync(process.execPath, ['node_modules/.bin/policy-catalog', 'info'], { encoding: 'utf8' })); + assert.equal(viaBin.catalogRevision, info.catalogRevision); + } + console.log('package smoke ok'); + `); + process.stdout.write(execFileSync(process.execPath, ['smoke.mjs'], { cwd: consumer, encoding: 'utf8' })); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/policy-catalog/typescript/scripts/validate-catalog.mjs b/policy-catalog/scripts/validate-catalog.mjs similarity index 64% rename from policy-catalog/typescript/scripts/validate-catalog.mjs rename to policy-catalog/scripts/validate-catalog.mjs index 8b3d01aa0..bd48162c4 100644 --- a/policy-catalog/typescript/scripts/validate-catalog.mjs +++ b/policy-catalog/scripts/validate-catalog.mjs @@ -1,12 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Contribution/CI validation pipeline for the catalog (spec §7, §12 "Data"). +// Contribution/CI validation pipeline for the catalog (design §7, §12 "Data"). // // 1. JSON Schema conformance of contract, manifest, and every revision. // 2. Integrity: every revision matches its published canonical SHA-256. // 3. Semantic validation shared with the resolver: registered -// SandboxPolicy versions, identity uniqueness/ordering, selectors, +// SandboxPolicy versions, entry-ID uniqueness, per-entry identity +// predicates, selectors, // dependency closure and cycle freedom, symbol validity, no literal or // user-specific paths, no wildcard grants, unsupported-field rejection, // backend neutrality, and v1 composition limits. @@ -14,7 +15,8 @@ // 5. Immutability of already-published revisions against a base git ref // (POLICY_CATALOG_BASE_REF, e.g. origin/main), when provided. // 6. Deterministic resolution: every entry resolves identically twice for -// every platform/architecture selector. +// every platform/architecture selector, and the whole-catalog lookup is +// independent of input order and of catalog file order. // 7. Package inclusion: the bundled default revision equals the repository // manifest default. // 8. Every entry has a conformance fixture case. @@ -23,10 +25,11 @@ import { readFileSync, readdirSync } from 'node:fs'; import { createRequire } from 'node:module'; import { join, relative } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { PolicyCatalog, loadCatalogDirectory, bundledCatalogStore } from '../dist/index.js'; +import { CatalogStore, PolicyCatalog, loadCatalogDirectory, bundledCatalogStore } from '../dist/index.js'; import { ARCHITECTURES, PLATFORMS, + canonicalSha256, checkPublishedImmutability, checkStoreHistory, selectVariant, @@ -35,7 +38,7 @@ import { const require = createRequire(import.meta.url); const Ajv2020 = require('ajv/dist/2020'); -const root = fileURLToPath(new URL('../../', import.meta.url)); +const root = fileURLToPath(new URL('../', import.meta.url)); const catalogDir = join(root, 'catalog'); const schemaDir = join(root, 'schema'); const readJson = file => JSON.parse(readFileSync(file, 'utf8')); @@ -134,10 +137,14 @@ step('deterministic resolution', () => { symbols: Object.fromEntries(Object.keys(symbols).map(s => [s, `${base}${sep}${s}`])), }; const tool = { invocationName: name ?? 'unused', ...(purl ? { packageUrl: purl } : {}) }; - const first = JSON.stringify(catalog.resolveCatalogEntry(tool, ctx)); - const second = JSON.stringify(catalog.resolveCatalogEntry(tool, ctx)); - if (first === undefined || first !== second) { + const first = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + const second = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + if (first.policy === undefined) { + found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} produced no policy: ${first.diagnostics.warnings.join('; ')}`); + } else if (JSON.stringify(first) !== JSON.stringify(second)) { found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} did not resolve deterministically`); + } else if (!first.diagnostics.tools[0].matches.some(match => match.entryId === entry.entryId)) { + found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} was not matched by its own identity`); } } } @@ -147,11 +154,57 @@ step('deterministic resolution', () => { }); step('package inclusion', () => { + // The package ships `catalog/` itself; the bundled loader must see exactly + // the revisions this pipeline validated. const bundled = bundledCatalogStore(); return bundled.defaultRevision === store.defaultRevision && JSON.stringify(bundled.availableRevisions) === JSON.stringify(store.availableRevisions) ? [] - : ['bundled package catalog does not match the repository catalog; rebuild the package']; + : ['bundled catalog does not match the repository catalog']; +}); + +step('order independence', () => { + // Catalog file order and input order must not change the composed policy. + const revision = store.revision(); + const reversed = { ...structuredClone(revision), entries: [...structuredClone(revision.entries)].reverse() }; + const digest = canonicalSha256(reversed); + const reversedStore = new CatalogStore({ + contract: store.contract, + manifest: { catalogSchemaVersion: '1', defaultRevision: revision.catalogRevision, revisions: [{ catalogRevision: revision.catalogRevision, file: `revisions/${revision.catalogRevision}.json`, sha256: digest }] }, + readRevision: () => structuredClone(reversed), + }); + const names = revision.entries.map(e => e.identity.find(p => p.kind === 'invocation-name')?.names[0]).filter(Boolean); + const found = []; + for (const platform of PLATFORMS) { + for (const architecture of ARCHITECTURES) { + const sep = platform === 'windows' ? '\\' : '/'; + const base = platform === 'windows' ? 'C:\\ci' : '/ci'; + const ctx = { + platform, + architecture, + allowWeakIdentityFallback: true, + projectRoot: `${base}${sep}project`, + symbols: Object.fromEntries(Object.entries(store.contract.symbols).filter(([, d]) => d.source !== 'context').map(([s]) => [s, `${base}${sep}${s}`])), + }; + const run = (catalogStore, tools) => { + try { + return JSON.stringify(new PolicyCatalog(catalogStore).getSandboxConfig(tools, ctx)); + } catch (error) { + return `error:${error.category ?? error.message}`; + } + }; + const a = run(store, names); + const b = run(reversedStore, names); + const c = run(store, [...names].reverse()); + if (a !== b) found.push(`${platform}/${architecture}: catalog file order changed the result`); + if (a !== c && !a.startsWith('error:')) { + // Paths keep first-seen order, so only set equality is required across input order. + const set = s => JSON.stringify(Object.fromEntries(Object.entries(JSON.parse(s)?.filesystem ?? {}).map(([k, v]) => [k, [...v].sort()]))); + if (set(a) !== set(c)) found.push(`${platform}/${architecture}: input order changed the composed requirement set`); + } + } + } + return found; }); step('entry fixtures', () => { @@ -161,7 +214,9 @@ step('entry fixtures', () => { const fixture = readJson(join(fixtureDir, name)); if (fixture.catalog !== 'bundled') continue; for (const c of fixture.cases) { - if (c.expect?.entryId) covered.add(c.expect.entryId); + for (const tool of c.expect?.diagnostics?.tools ?? []) { + for (const match of tool.matches) covered.add(match.entryId); + } } } const found = []; diff --git a/policy-catalog/typescript/src/canonical-json.ts b/policy-catalog/src/canonical-json.ts similarity index 100% rename from policy-catalog/typescript/src/canonical-json.ts rename to policy-catalog/src/canonical-json.ts diff --git a/policy-catalog/typescript/src/catalog.ts b/policy-catalog/src/catalog.ts similarity index 91% rename from policy-catalog/typescript/src/catalog.ts rename to policy-catalog/src/catalog.ts index 94ed45a1f..eeae99300 100644 --- a/policy-catalog/typescript/src/catalog.ts +++ b/policy-catalog/src/catalog.ts @@ -14,7 +14,7 @@ export const CATALOG_SCHEMA_VERSION = '1'; export const PLATFORMS: readonly CatalogPlatform[] = ['windows', 'linux', 'macos']; export const ARCHITECTURES: readonly CatalogArchitecture[] = ['x64', 'arm64']; -/** Fields that the v1 contract can compose across entries (spec §4.5). */ +/** Fields that the v1 contract can compose across entries (design §4.5). */ export const COMPOSABLE_FILESYSTEM_FIELDS = ['deniedPaths', 'readonlyPaths', 'readwritePaths'] as const; export type AccessClass = (typeof COMPOSABLE_FILESYSTEM_FIELDS)[number]; @@ -158,7 +158,7 @@ function validateTemplatePath(value: string, at: string, contract: CatalogContra fail(`'${at}' references unknown symbol '${match[1]}'`); } } - // Catalog data never ships a literal, machine-specific path (spec §4.2): + // Catalog data never ships a literal, machine-specific path (design §4.2): // every filesystem requirement must be anchored at a declared symbol. if (!ANCHORED_SYMBOL.test(value)) { fail(`'${at}' must start with a declared symbol; literal paths are not allowed`); @@ -404,15 +404,16 @@ function validateIdentity(raw: unknown, at: string): IdentityPredicate[] { } fail(`'${itemAt}.kind' is not a supported identity kind`); }); - // identity is ordered strongest to weakest (spec §4.3). - let sawWeak = false; - predicates.forEach((predicate, index) => { - const weak = IDENTITY_STRENGTH[predicate.kind] === 'weak'; - if (!weak && sawWeak) { - fail(`'${at}[${index}]' is a strong predicate after a weak one; identity must be ordered strongest first`); - } - sawWeak ||= weak; - }); + // Matching is additive (design §4.3): predicates of different entries may + // overlap, but one entry must not repeat a predicate, because that would + // make its matched-identity diagnostics ambiguous. + const seen = new Set(); + for (const key of predicates.flatMap(identityKeys)) { + if (seen.has(key)) { + fail(`'${at}' repeats identity '${key}'`); + } + seen.add(key); + } return predicates; } @@ -511,12 +512,26 @@ function validateEntry(raw: unknown, at: string, contract: CatalogContract): Cat // Variant selection and dependency closure (shared by validation and resolver) // --------------------------------------------------------------------------- +/** + * Stable comparison keys for one predicate. Invocation names are folded + * case-insensitively so a repeat is caught on every platform. + */ +function identityKeys(predicate: IdentityPredicate): string[] { + return predicate.kind === 'purl' + ? [`purl:${parsePurl(predicate.value)!.key}`] + : predicate.names.map(name => `invocation-name:${name.toLowerCase()}`); +} + export interface VariantSelection { variant: PlatformVariant; exact: boolean; } -/** Selects the variant for a host: exact architecture first, then neutral (spec §4.4). */ +/** + * Selects the variant for a host (design §4.4): the exact architecture first, + * then the platform's architecture-neutral variant. A different architecture's + * variant is never a fallback. + */ export function selectVariant( entry: CatalogEntry, platform: CatalogPlatform, @@ -534,8 +549,8 @@ export function selectVariant( export interface ClosureNode { entry: CatalogEntry; variant: PlatformVariant; - /** Range from the first edge that reached this node, in traversal order. */ - requiredVersionRange?: string; + /** False when the architecture-neutral variant was selected as a fallback. */ + exact: boolean; } export type ClosureResult = @@ -549,7 +564,7 @@ export type ClosureResult = */ export function dependencyClosure( root: CatalogEntry, - rootVariant: PlatformVariant, + rootSelection: VariantSelection, byId: ReadonlyMap, platform: CatalogPlatform, architecture: CatalogArchitecture, @@ -559,7 +574,7 @@ export function dependencyClosure( const stack: string[] = []; let failure: ClosureResult | undefined; - const visit = (entry: CatalogEntry, variant: PlatformVariant, range: string | undefined): void => { + const visit = (entry: CatalogEntry, selection: VariantSelection): void => { if (failure) { return; } @@ -571,8 +586,8 @@ export function dependencyClosure( return; } stack.push(entry.entryId); - nodes.push({ entry, variant, ...(range !== undefined ? { requiredVersionRange: range } : {}) }); - for (const dependency of variant.dependencies ?? []) { + nodes.push({ entry, variant: selection.variant, exact: selection.exact }); + for (const dependency of selection.variant.dependencies ?? []) { const target = byId.get(dependency.entryId); if (!target) { failure = { ok: false, reason: 'missing-entry', detail: `${entry.entryId} -> ${dependency.entryId}` }; @@ -587,19 +602,24 @@ export function dependencyClosure( }; return; } - visit(target, selected.variant, dependency.versionRange); + visit(target, selected); + if (failure) { + return; + } } stack.pop(); done.add(entry.entryId); }; - visit(root, rootVariant, undefined); + visit(root, rootSelection); return failure ?? { ok: true, nodes }; } /** - * Composition limits for the v1 vocabulary (spec §4.5): returns a violation - * description, or `undefined` when the closure can be composed. + * Composition limits for the v1 vocabulary (design §4.5). Applies to any set + * of selected entries: one entry's dependency closure, several entries matched + * by one input, or entries matched by several inputs. Returns a violation + * description, or `undefined` when the set can be composed. */ export function compositionViolation(nodes: readonly ClosureNode[]): string | undefined { const versions = new Set(nodes.map(node => node.variant.sandboxPolicy.version)); @@ -624,25 +644,14 @@ export function compositionViolation(nodes: readonly ClosureNode[]): string | un // Revision-level validation // --------------------------------------------------------------------------- -function identityKeys(entry: CatalogEntry): string[] { - const keys: string[] = []; - for (const predicate of entry.identity) { - if (predicate.kind === 'purl') { - keys.push(`purl:${parsePurl(predicate.value)!.key}`); - } else { - // Invocation names are unique case-insensitively so matching is - // unambiguous on every platform, including Windows. - keys.push(...predicate.names.map(name => `invocation-name:${name.toLowerCase()}`)); - } - } - return keys; -} - /** - * Validates one catalog revision against the v1 contract: shape, identity - * uniqueness and ordering, platform selectors, symbols, unsafe paths, backend - * neutrality, dependency closure and cycle-freedom for every supported - * platform/architecture, and the v1 composition limits. + * Validates one catalog revision against the v1 contract (design §7): shape, + * entry-ID uniqueness, per-entry identity predicates, platform selectors, + * registered policy versions, symbols, unsafe paths, backend neutrality, + * dependency closure and cycle-freedom for every supported + * platform/architecture, and the v1 composition limits within each closure. + * Composition across independently matched entries depends on the caller's + * inputs and is checked by the resolver. */ export function validateCatalogRevision(raw: unknown, contract: CatalogContract): CatalogRevision { if (!isRecord(raw)) { @@ -662,19 +671,11 @@ export function validateCatalogRevision(raw: unknown, contract: CatalogContract) const entries = raw.entries.map((entry, index) => validateEntry(entry, `entries[${index}]`, contract)); const byId = new Map(); - const identityOwners = new Map(); for (const entry of entries) { if (byId.has(entry.entryId)) { fail(`duplicate entryId '${entry.entryId}'`); } byId.set(entry.entryId, entry); - for (const key of identityKeys(entry)) { - const owner = identityOwners.get(key); - if (owner !== undefined && owner !== entry.entryId) { - fail(`identity '${key}' is claimed by both '${owner}' and '${entry.entryId}'`); - } - identityOwners.set(key, entry.entryId); - } } for (const entry of entries) { @@ -694,7 +695,7 @@ export function validateCatalogRevision(raw: unknown, contract: CatalogContract) if (!selected) { continue; } - const closure = dependencyClosure(entry, selected.variant, byId, platform, architecture); + const closure = dependencyClosure(entry, selected, byId, platform, architecture); if (!closure.ok) { fail(`'${entry.entryId}' on ${platform}/${architecture}: ${closure.reason} (${closure.detail})`); } diff --git a/policy-catalog/src/cli.ts b/policy-catalog/src/cli.ts new file mode 100644 index 000000000..ab068e20f --- /dev/null +++ b/policy-catalog/src/cli.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Command-line harness over the public library API. It is intended for +// contributors, CI, and functional tests; it is not a sandbox launcher. +// +// policy-catalog info +// policy-catalog list +// policy-catalog resolve [--diagnostics] [--platform P] [--architecture A] +// [--revision R] [--project-root PATH] [--symbol name=value]... +// [--allow-weak] [--purl URL] [--detected-version V] ... +// policy-catalog verify [--catalog DIR] +// +// Exit codes: 0 success (including "no policy" for resolve), 1 library +// failure (PolicyCatalogError), 2 usage error. Output is JSON on stdout. +import { realpathSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { PolicyCatalogError } from './errors.js'; +import { PolicyCatalog } from './resolver.js'; +import { bundledCatalogStore, loadCatalogDirectory, type CatalogStore } from './store.js'; +import { checkStoreHistory } from './history.js'; +import type { CatalogArchitecture, CatalogPlatform, ResolveContext, ToolCandidate } from './types.js'; + +class UsageError extends Error {} + +function takeValue(args: string[], index: number, flag: string): string { + const value = args[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw new UsageError(`${flag} requires a value`); + } + return value; +} + +function storeFrom(catalogDir: string | undefined): CatalogStore { + return catalogDir === undefined + ? bundledCatalogStore() + : loadCatalogDirectory(pathToFileURL(`${resolvePath(catalogDir)}/`)); +} + +function print(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +export function main(argv: string[]): number { + const [command, ...rest] = argv; + try { + let catalogDir: string | undefined; + const args: string[] = []; + for (let i = 0; i < rest.length; i += 1) { + if (rest[i] === '--catalog') { + catalogDir = takeValue(rest, i, '--catalog'); + i += 1; + } else { + args.push(rest[i]); + } + } + switch (command) { + case 'info': + print(new PolicyCatalog(storeFrom(catalogDir)).getCatalogInfo()); + return 0; + case 'list': + print(new PolicyCatalog(storeFrom(catalogDir)).listCatalogEntries()); + return 0; + case 'verify': { + const store = storeFrom(catalogDir); + const errors = checkStoreHistory(store); + print({ ok: errors.length === 0, defaultRevision: store.defaultRevision, revisions: store.availableRevisions, errors }); + return errors.length === 0 ? 0 : 1; + } + case 'resolve': + return resolveCommand(args, catalogDir); + default: + throw new UsageError(command === undefined ? 'missing command' : `unknown command '${command}'`); + } + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`policy-catalog: ${error.message}\nusage: policy-catalog [options]\n`); + return 2; + } + if (error instanceof PolicyCatalogError) { + print({ error: { category: error.category, message: error.message } }); + return 1; + } + throw error; + } +} + +function resolveCommand(args: string[], catalogDir: string | undefined): number { + const ctx: ResolveContext = {}; + const symbols: Record = {}; + const tools: Array = []; + let diagnostics = false; + let purl: string | undefined; + let detectedVersion: string | undefined; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case '--diagnostics': + diagnostics = true; + break; + case '--allow-weak': + ctx.allowWeakIdentityFallback = true; + break; + case '--platform': + ctx.platform = takeValue(args, i++, arg) as CatalogPlatform; + break; + case '--architecture': + ctx.architecture = takeValue(args, i++, arg) as CatalogArchitecture; + break; + case '--revision': + ctx.catalogRevision = takeValue(args, i++, arg); + break; + case '--project-root': + ctx.projectRoot = takeValue(args, i++, arg); + break; + case '--symbol': { + const pair = takeValue(args, i++, arg); + const eq = pair.indexOf('='); + if (eq <= 0) { + throw new UsageError('--symbol expects name=value'); + } + symbols[pair.slice(0, eq)] = pair.slice(eq + 1); + break; + } + case '--purl': + purl = takeValue(args, i++, arg); + break; + case '--detected-version': + detectedVersion = takeValue(args, i++, arg); + break; + default: + if (arg.startsWith('--')) { + throw new UsageError(`unknown option '${arg}'`); + } + // --purl / --detected-version apply to the next tool name only. + tools.push(purl === undefined && detectedVersion === undefined + ? arg + : { invocationName: arg, ...(purl !== undefined ? { packageUrl: purl } : {}), ...(detectedVersion !== undefined ? { detectedVersion } : {}) }); + purl = undefined; + detectedVersion = undefined; + } + } + if (purl !== undefined || detectedVersion !== undefined) { + throw new UsageError('--purl/--detected-version must precede a tool name'); + } + if (Object.keys(symbols).length > 0) { + ctx.symbols = symbols; + } + const catalog = new PolicyCatalog(storeFrom(catalogDir)); + if (diagnostics) { + print(catalog.getSandboxConfigWithDiagnostics(tools, ctx)); + } else { + // `null` is JSON's rendering of "no policy"; it is never an empty policy. + print(catalog.getSandboxConfig(tools, ctx) ?? null); + } + return 0; +} + +// npm installs the bin as a symlink on POSIX, so compare real paths; a plain +// URL comparison would silently do nothing when run through the symlink. +function invokedDirectly(): boolean { + const entry = process.argv[1]; + if (entry === undefined) { + return false; + } + try { + return realpathSync(resolvePath(entry)) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedDirectly()) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/policy-catalog/typescript/src/errors.ts b/policy-catalog/src/errors.ts similarity index 94% rename from policy-catalog/typescript/src/errors.ts rename to policy-catalog/src/errors.ts index ba7021e41..e0d766629 100644 --- a/policy-catalog/typescript/src/errors.ts +++ b/policy-catalog/src/errors.ts @@ -4,7 +4,7 @@ /** * Language-neutral library failure categories. Shared conformance fixtures * assert on these values, so every language binding must preserve them. - * A library failure is never a "no match" result (spec §5). + * A library failure is never a "no match" result (design §5). */ export type PolicyCatalogErrorCategory = /** Bundled data does not match its published integrity digest. */ diff --git a/policy-catalog/typescript/src/history.ts b/policy-catalog/src/history.ts similarity index 96% rename from policy-catalog/typescript/src/history.ts rename to policy-catalog/src/history.ts index c93bdf8b7..51e963a6d 100644 --- a/policy-catalog/typescript/src/history.ts +++ b/policy-catalog/src/history.ts @@ -13,7 +13,7 @@ function entrySemanticKey(entry: CatalogEntry): string { /** * Checks entry-revision monotonicity between two consecutive published - * catalog revisions (spec §4.1, §10): a semantically changed entry must bump + * catalog revisions (design §4.1, §10): a semantically changed entry must bump * `entryRevision`; an unchanged entry must keep it; it never decreases. */ export function checkEntryRevisions(previous: CatalogRevision, next: CatalogRevision): string[] { @@ -70,7 +70,7 @@ export interface PublishedState { /** * Enforces immutability of published revisions across a proposed change - * (spec §10): previously published revisions keep their manifest entry, + * (design §10): previously published revisions keep their manifest entry, * digest, and file bytes, and new revisions are only appended. */ export function checkPublishedImmutability(base: PublishedState, proposed: PublishedState): string[] { diff --git a/policy-catalog/src/host.ts b/policy-catalog/src/host.ts new file mode 100644 index 000000000..77100de8b --- /dev/null +++ b/policy-catalog/src/host.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PolicyCatalogError } from './errors.js'; +import type { CatalogArchitecture, CatalogPlatform } from './types.js'; + +/** Host facts the resolver uses only when the caller omits them. */ +export interface HostEnvironment { + platform(): CatalogPlatform; + /** + * The device's native system architecture (design §4.4). This is not the + * architecture of the process hosting the library, and not a detected tool + * build. Throw when it cannot be determined; never guess. + */ + nativeArchitecture(): CatalogArchitecture; + /** Approved host-known symbols (`source: "host"` in the contract) for the current host. */ + symbol(name: string): string | undefined; +} + +function hostPlatform(): CatalogPlatform { + switch (os.platform()) { + case 'win32': + return 'windows'; + case 'darwin': + return 'macos'; + case 'linux': + return 'linux'; + default: + throw new PolicyCatalogError('unsupported-host', `host platform '${os.platform()}' has no catalog selector`); + } +} + +/** Maps an OS-reported machine/architecture string to a catalog selector. */ +export function architectureFromMachine(machine: string): CatalogArchitecture | undefined { + switch (machine.trim().toLowerCase()) { + case 'x86_64': + case 'amd64': + case 'x64': + return 'x64'; + case 'arm64': + case 'aarch64': + return 'arm64'; + default: + return undefined; + } +} + +function run(file: string, args: string[]): string { + return execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); +} + +/** + * Reads the native architecture without trusting the current process's view. + * + * - Windows: an x64 process emulated on ARM64 sees `PROCESSOR_ARCHITECTURE=AMD64` + * and an x64 `os.machine()`. The machine-wide value in the registry reports + * the native architecture. + * - macOS: a Rosetta-translated process sees `x86_64` from `uname`. + * `hw.optional.arm64` reports Apple silicon regardless of translation, and + * Intel Macs do not define it. + * - Linux: `uname` reports the kernel's machine type. + * + * Executables are invoked by absolute path so PATH cannot redirect them. + */ +function detectNativeArchitecture(): CatalogArchitecture { + const platform = os.platform(); + let reported: string | undefined; + try { + if (platform === 'win32') { + const reg = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'reg.exe'); + const output = run(reg, [ + 'query', + 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', + '/v', + 'PROCESSOR_ARCHITECTURE', + ]); + reported = /PROCESSOR_ARCHITECTURE\s+REG_SZ\s+(\S+)/i.exec(output)?.[1]; + } else if (platform === 'darwin') { + let appleSilicon = false; + try { + appleSilicon = run('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64']).trim() === '1'; + } catch { + // The key is absent on Intel Macs. + } + reported = appleSilicon ? 'arm64' : os.machine(); + } else { + reported = os.machine(); + } + } catch (error) { + throw new PolicyCatalogError('unsupported-host', `native system architecture could not be determined: ${(error as Error).message}`); + } + const architecture = reported === undefined ? undefined : architectureFromMachine(reported); + if (!architecture) { + throw new PolicyCatalogError('unsupported-host', `native system architecture '${reported ?? 'unknown'}' has no catalog selector`); + } + return architecture; +} + +let nativeArchitectureCache: CatalogArchitecture | undefined; + +/** Default host environment. The native architecture is detected once per process, on first need. */ +export const nodeHostEnvironment: HostEnvironment = { + platform: hostPlatform, + nativeArchitecture() { + nativeArchitectureCache ??= detectNativeArchitecture(); + return nativeArchitectureCache; + }, + symbol(name) { + switch (name) { + case 'user_home': + return os.homedir() || undefined; + case 'temp_dir': + return os.tmpdir() || undefined; + default: + return undefined; + } + }, +}; diff --git a/policy-catalog/typescript/src/index.ts b/policy-catalog/src/index.ts similarity index 62% rename from policy-catalog/typescript/src/index.ts rename to policy-catalog/src/index.ts index 60865e6c4..cd4f0e5aa 100644 --- a/policy-catalog/typescript/src/index.ts +++ b/policy-catalog/src/index.ts @@ -1,18 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Runtime lookup (spec §5.1) and setup/inspection (spec §5.2) are separate -// entry points. Neither launches a sandbox, contacts a network service, or -// writes consumer state. +// Runtime lookup (design §5.1) and setup/inspection (design §5.2) are +// separate entry points. None of them launches a sandbox, contacts a network +// service, or writes consumer state. export { - resolveCatalogEntry, + getSandboxConfig, + getSandboxConfigWithDiagnostics, listCatalogEntries, getCatalogInfo, PolicyCatalog, - nodeHostEnvironment, - type HostEnvironment, } from './resolver.js'; +export { nodeHostEnvironment, type HostEnvironment } from './host.js'; + export { CatalogStore, loadCatalogDirectory, @@ -32,7 +33,9 @@ export type { CatalogNetworkRule, CatalogPlatform, CatalogSandboxPolicy, + IdentityStrength, ResolveContext, - ResolvedToolEntry, + SandboxConfigResolution, ToolCandidate, + ToolInput, } from './types.js'; diff --git a/policy-catalog/typescript/src/purl.ts b/policy-catalog/src/purl.ts similarity index 100% rename from policy-catalog/typescript/src/purl.ts rename to policy-catalog/src/purl.ts diff --git a/policy-catalog/src/resolver.ts b/policy-catalog/src/resolver.ts new file mode 100644 index 000000000..92871720c --- /dev/null +++ b/policy-catalog/src/resolver.ts @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as path from 'node:path'; +import { PolicyCatalogError } from './errors.js'; +import { nodeHostEnvironment, type HostEnvironment } from './host.js'; +import { parsePurl, type ParsedPurl } from './purl.js'; +import { satisfiesVersionRange } from './version-range.js'; +import { + ARCHITECTURES, + COMPOSABLE_FILESYSTEM_FIELDS, + IDENTITY_STRENGTH, + PLATFORMS, + compositionViolation, + dependencyClosure, + findCrossClassOverlap, + pathKeySegments, + policySymbols, + selectVariant, + type AccessClass, + type CatalogEntry, + type ClosureNode, + type IdentityPredicate, + type VariantSelection, +} from './catalog.js'; +import { bundledCatalogStore, type CatalogStore } from './store.js'; +import type { + CatalogArchitecture, + CatalogEntryMetadata, + CatalogInfo, + CatalogPlatform, + CatalogSandboxPolicy, + ResolveContext, + SandboxConfigResolution, + ToolCandidate, + ToolInput, +} from './types.js'; + +type Diagnostics = SandboxConfigResolution['diagnostics']; +type ToolRecord = Diagnostics['tools'][number]; +type DependencyRecord = Diagnostics['resolvedDependencies'][number]; + +interface EntryMatch { + entry: CatalogEntry; + selection: VariantSelection; + satisfied: IdentityPredicate[]; +} + +const SYMBOL_PATTERN = /\$\{([a-z][a-z0-9_]*)\}/g; + +function pathApi(platform: CatalogPlatform): path.PlatformPath { + return platform === 'windows' ? path.win32 : path.posix; +} + +function normalizePath(value: string, platform: CatalogPlatform): string { + const api = pathApi(platform); + const normalized = api.normalize(value); + const root = api.parse(normalized).root; + return normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized; +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function toCandidate(input: ToolInput, index: number): ToolCandidate { + const candidate = typeof input === 'string' ? { invocationName: input } : input; + if (candidate === null || typeof candidate !== 'object') { + throw new PolicyCatalogError('invalid-context', `tool input ${index} must be a string or a ToolCandidate`); + } + if (typeof candidate.invocationName !== 'string' || candidate.invocationName.length === 0) { + throw new PolicyCatalogError('invalid-context', `tool input ${index}: invocationName must be a non-empty string`); + } + if (/[\\/]/.test(candidate.invocationName)) { + throw new PolicyCatalogError('invalid-context', `tool input ${index}: invocationName must be a bare name, not a path`); + } + for (const key of ['packageUrl', 'detectedVersion'] as const) { + if (candidate[key] !== undefined && (typeof candidate[key] !== 'string' || candidate[key].length === 0)) { + throw new PolicyCatalogError('invalid-context', `tool input ${index}: ${key} must be a non-empty string when present`); + } + } + return candidate; +} + +function describeInput(index: number, tool: ToolCandidate): string { + return `input ${index} ('${tool.invocationName}')`; +} + +/** + * Read-only view over one catalog store. Runtime resolution and inspection are + * separate operations; neither mutates catalog data or any consumer state. + */ +export class PolicyCatalog { + private readonly store: CatalogStore; + private readonly host: HostEnvironment; + + constructor(store: CatalogStore, host: HostEnvironment = nodeHostEnvironment) { + this.store = store; + this.host = host; + } + + // ------------------------------------------------------------------------- + // Setup and inspection (design §5.2) + // ------------------------------------------------------------------------- + + getCatalogInfo(): CatalogInfo { + const revision = this.store.revision(); + return { catalogSchemaVersion: revision.catalogSchemaVersion, catalogRevision: revision.catalogRevision }; + } + + /** Metadata for every entry in the installed revision, ordered by `entryId`. Never includes a policy body. */ + listCatalogEntries(): CatalogEntryMetadata[] { + const revision = this.store.revision(); + return [...revision.entries] + .sort((a, b) => compareStrings(a.entryId, b.entryId)) + .map(entry => ({ + catalogRevision: revision.catalogRevision, + entryId: entry.entryId, + entryRevision: entry.entryRevision, + displayName: entry.displayName, + identity: entry.identity.map(predicate => (predicate.kind === 'purl' + ? { kind: 'purl' as const, value: predicate.value, ...(predicate.versionRange !== undefined ? { versionRange: predicate.versionRange } : {}) } + : { kind: 'invocation-name' as const, names: [...predicate.names] })), + platformVariants: entry.platformVariants.map(variant => ({ + platform: variant.when.platform, + ...(variant.when.architecture !== undefined ? { architecture: variant.when.architecture } : {}), + dependencyEntryIds: (variant.dependencies ?? []).map(dependency => dependency.entryId), + sandboxPolicyVersion: variant.sandboxPolicy.version, + })), + provenance: { ...entry.provenance }, + })); + } + + // ------------------------------------------------------------------------- + // Runtime lookup (design §5.1) + // ------------------------------------------------------------------------- + + /** Returns the composed candidate policy, or `undefined` when no policy can be resolved. */ + getSandboxConfig(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): CatalogSandboxPolicy | undefined { + return this.resolve(tools, ctx).policy; + } + + /** + * Resolves one tool or an array of tools in one pass and returns the + * composed candidate policy with attribution and warnings. + * + * `policy` is `undefined` for an empty input, when no input matched, or + * when a selected entry requires an unresolved symbol. It is never an empty + * stand-in policy. Library failures (integrity, unavailable revision, + * invalid context, host detection, composition conflict) throw + * {@link PolicyCatalogError}; they are never reported as absence. + * + * The result is a candidate lower bound, not authorization. Consumers keep + * authorization, ceilings, persistence, composition, approval, and audit. + */ + getSandboxConfigWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): SandboxConfigResolution { + return this.resolve(tools, ctx); + } + + private resolve(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext): SandboxConfigResolution { + // A single input is exactly a one-element array (design §5.1). + const inputs = (Array.isArray(tools) ? tools : [tools]) as readonly ToolInput[]; + const candidates = inputs.map(toCandidate); + this.validateContext(ctx); + const revision = this.store.revision(ctx.catalogRevision); + const platform = ctx.platform ?? this.host.platform(); + const allowWeak = ctx.allowWeakIdentityFallback === true; + + // The native architecture is resolved lazily: a lookup that never needs + // variant selection must not fail on host detection (design §4.4). + let architecture = ctx.architecture; + const effectiveArchitecture = (): CatalogArchitecture => { + architecture ??= this.host.nativeArchitecture(); + return architecture; + }; + + const warnings: string[] = []; + const toolRecords: ToolRecord[] = []; + const selected = new Map(); + const byId = new Map(revision.entries.map(entry => [entry.entryId, entry])); + // Entry order in the catalog file never selects or excludes a match. + const ordered = [...revision.entries].sort((a, b) => compareStrings(a.entryId, b.entryId)); + + candidates.forEach((tool, inputIndex) => { + const matches = this.matchTool(ordered, tool, inputIndex, platform, allowWeak, effectiveArchitecture, warnings); + toolRecords.push({ + inputIndex, + matches: matches.map(match => ({ + entryId: match.entry.entryId, + entryRevision: match.entry.entryRevision, + matchedIdentities: match.satisfied.map(predicate => ({ kind: predicate.kind, strength: IDENTITY_STRENGTH[predicate.kind] })), + })), + }); + if (matches.length > 1) { + warnings.push(`${describeInput(inputIndex, tool)} matched ${matches.length} entries (${matches.map(m => m.entry.entryId).join(', ')}); all contribute`); + } + for (const match of matches) { + const closure = dependencyClosure(match.entry, match.selection, byId, platform, effectiveArchitecture()); + if (!closure.ok) { + // A validated revision cannot reach this; treat it as corrupt data. + throw new PolicyCatalogError('validation', `dependency resolution failed: ${closure.reason} (${closure.detail})`); + } + for (const node of closure.nodes) { + if (!selected.has(node.entry.entryId)) { + selected.set(node.entry.entryId, node); + } + } + } + }); + + const diagnostics: Diagnostics = { + catalogRevision: revision.catalogRevision, + tools: toolRecords, + resolvedDependencies: dependencyRecords([...selected.values()], byId), + warnings, + }; + const nodes = [...selected.values()]; + if (nodes.length === 0) { + return { policy: undefined, diagnostics }; + } + + if (ctx.architecture === undefined) { + warnings.push(`architecture was not specified; variants were selected for the native system architecture '${effectiveArchitecture()}'; the tool's architecture was not verified`); + } + for (const node of nodes) { + if (!node.exact) { + warnings.push(`${node.entry.entryId} uses its architecture-neutral ${platform} variant; no ${effectiveArchitecture()}-specific variant exists`); + } + } + + const violation = compositionViolation(nodes); + if (violation) { + throw new PolicyCatalogError('composition-conflict', `selected entries cannot be composed: ${violation}`); + } + + const symbols = this.resolveSymbols(nodes, ctx, platform, warnings); + if (!symbols) { + return { policy: undefined, diagnostics }; + } + return { policy: composePolicy(nodes, symbols, platform), diagnostics }; + } + + /** + * Collects every eligible entry for one input, ordered by `entryId` + * (design §4.3). Matching is additive: a strong match never suppresses + * another entry's eligible weak match. + */ + private matchTool( + ordered: readonly CatalogEntry[], + tool: ToolCandidate, + inputIndex: number, + platform: CatalogPlatform, + allowWeak: boolean, + architecture: () => CatalogArchitecture, + warnings: string[], + ): EntryMatch[] { + let purl: ParsedPurl | undefined; + if (tool.packageUrl !== undefined) { + purl = parsePurl(tool.packageUrl); + if (!purl) { + throw new PolicyCatalogError('invalid-context', `${describeInput(inputIndex, tool)}: '${tool.packageUrl}' is not a valid package URL`); + } + } + // Windows and default macOS volumes resolve executables case-insensitively; + // Linux does not. + const fold = platform === 'linux' ? (value: string) => value : (value: string) => value.toLowerCase(); + const invocation = fold(tool.invocationName); + + const matches: EntryMatch[] = []; + const skipped: string[] = []; + for (const entry of ordered) { + const satisfied = entry.identity.filter(predicate => (predicate.kind === 'purl' + ? purl !== undefined && parsePurl(predicate.value)?.key === purl.key + : predicate.names.some(name => fold(name) === invocation))); + if (satisfied.length === 0) { + continue; + } + const strong = satisfied.some(predicate => IDENTITY_STRENGTH[predicate.kind] === 'strong'); + if (!strong && !allowWeak) { + skipped.push(`${entry.entryId} matched only by invocation name and allowWeakIdentityFallback is not enabled`); + continue; + } + const selection = selectVariant(entry, platform, architecture()); + if (!selection) { + skipped.push(`${entry.entryId} has no variant for ${platform}/${architecture()}`); + continue; + } + for (const predicate of satisfied) { + if (predicate.kind !== 'purl' || predicate.versionRange === undefined) { + continue; + } + const evidence = tool.detectedVersion ?? purl?.version; + if (evidence === undefined) { + continue; + } + const inRange = satisfiesVersionRange(evidence, predicate.versionRange); + if (inRange !== true) { + warnings.push(`${describeInput(inputIndex, tool)}: detected version '${evidence}' ${inRange === false ? 'is outside' : 'could not be compared with'} the reviewed range '${predicate.versionRange}' for ${entry.entryId}`); + } + } + if (!strong) { + warnings.push(`${describeInput(inputIndex, tool)} matched ${entry.entryId} only by invocation name (weak identity)`); + } + matches.push({ entry, selection, satisfied }); + } + if (matches.length === 0) { + warnings.push(`${describeInput(inputIndex, tool)} matched no eligible catalog entry${skipped.length > 0 ? `: ${skipped.join('; ')}` : ''}`); + } + return matches; + } + + private validateContext(ctx: ResolveContext): void { + if (ctx === null || typeof ctx !== 'object') { + throw new PolicyCatalogError('invalid-context', 'ResolveContext must be an object'); + } + if (ctx.platform !== undefined && !PLATFORMS.includes(ctx.platform)) { + throw new PolicyCatalogError('invalid-context', `ResolveContext.platform '${String(ctx.platform)}' is unsupported`); + } + if (ctx.architecture !== undefined && !ARCHITECTURES.includes(ctx.architecture)) { + throw new PolicyCatalogError('invalid-context', `ResolveContext.architecture '${String(ctx.architecture)}' is unsupported`); + } + if (ctx.projectRoot !== undefined && (typeof ctx.projectRoot !== 'string' || ctx.projectRoot.length === 0)) { + throw new PolicyCatalogError('invalid-context', 'ResolveContext.projectRoot must be a non-empty string when present'); + } + const contract = this.store.contract; + for (const [name, value] of Object.entries(ctx.symbols ?? {})) { + const definition = contract.symbols[name]; + if (!definition) { + throw new PolicyCatalogError('invalid-context', `ResolveContext.symbols.${name} is not a catalog symbol`); + } + if (definition.source === 'context') { + throw new PolicyCatalogError('invalid-context', `symbol '${name}' is supplied through ResolveContext.projectRoot, not symbols`); + } + if (typeof value !== 'string') { + throw new PolicyCatalogError('invalid-context', `ResolveContext.symbols.${name} must be a string`); + } + } + } + + /** + * Resolves every symbol the selected entries need. Returns `undefined` + * (with warnings) when any required symbol is unresolved: a selected + * requirement is never silently dropped to produce a partial policy. + */ + private resolveSymbols( + nodes: readonly ClosureNode[], + ctx: ResolveContext, + platform: CatalogPlatform, + warnings: string[], + ): Map | undefined { + const contract = this.store.contract; + const api = pathApi(platform); + const values = new Map(); + const missing = new Map(); + for (const node of nodes) { + for (const name of policySymbols(node.variant.sandboxPolicy)) { + if (values.has(name)) { + continue; + } + const definition = contract.symbols[name]; + let value: string | undefined; + if (definition.source === 'context') { + value = ctx.projectRoot; + } else { + value = ctx.symbols?.[name]; + // Host-derived symbols describe the current host only; they are + // never derived for an explicitly different target platform. + if (value === undefined && definition.source === 'host' && platform === this.host.platform()) { + value = this.host.symbol(name); + } + } + if (value === undefined) { + missing.set(name, [...(missing.get(name) ?? []), node.entry.entryId]); + continue; + } + if (value.includes('${') || !api.isAbsolute(value)) { + throw new PolicyCatalogError('invalid-context', `symbol '${name}' must resolve to an absolute ${platform} path`); + } + values.set(name, value); + } + } + if (missing.size > 0) { + for (const [name, entryIds] of [...missing].sort(([a], [b]) => compareStrings(a, b))) { + const hint = contract.symbols[name].source === 'context' ? 'ResolveContext.projectRoot' : `ResolveContext.symbols.${name}`; + warnings.push(`required symbol '${name}' (needed by ${entryIds.join(', ')}) is unresolved; supply ${hint}; no policy was returned`); + } + return undefined; + } + return values; + } +} + +/** + * Distinct dependency edges among the selected entries, ordered by entryId, + * entryRevision, then requiredVersionRange (absent first) (design §5.1). + */ +function dependencyRecords(nodes: readonly ClosureNode[], byId: ReadonlyMap): DependencyRecord[] { + const records = new Map(); + for (const node of nodes) { + for (const dependency of node.variant.dependencies ?? []) { + const target = byId.get(dependency.entryId)!; + const key = JSON.stringify([target.entryId, target.entryRevision, dependency.versionRange ?? null]); + records.set(key, { + entryId: target.entryId, + entryRevision: target.entryRevision, + ...(dependency.versionRange !== undefined ? { requiredVersionRange: dependency.versionRange } : {}), + }); + } + } + return [...records.values()].sort((a, b) => compareStrings(a.entryId, b.entryId) + || a.entryRevision - b.entryRevision + || (a.requiredVersionRange === undefined ? (b.requiredVersionRange === undefined ? 0 : -1) + : b.requiredVersionRange === undefined ? 1 : compareStrings(a.requiredVersionRange, b.requiredVersionRange))); +} + +/** Composes the v1 vocabulary (design §4.5). Callers have already checked `compositionViolation`. */ +function composePolicy( + nodes: readonly ClosureNode[], + symbols: ReadonlyMap, + platform: CatalogPlatform, +): CatalogSandboxPolicy { + const classes: Partial> = {}; + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + const seen = new Set(); + const out: string[] = []; + for (const node of nodes) { + for (const template of node.variant.sandboxPolicy.filesystem?.[field] ?? []) { + const resolved = normalizePath(template.replace(SYMBOL_PATTERN, (_match, name: string) => symbols.get(name)!), platform); + const key = pathKeySegments(resolved, platform).join('\u0000'); + if (!seen.has(key)) { + seen.add(key); + out.push(resolved); + } + } + } + if (out.length > 0) { + classes[field] = out; + } + } + const overlap = findCrossClassOverlap(classes, platform); + if (overlap) { + // Symbol values can make distinct templates collide. The resolver never + // chooses an access class implicitly. + throw new PolicyCatalogError('composition-conflict', `resolved paths overlap across access classes: ${overlap}`); + } + + const root = nodes[0].variant.sandboxPolicy; + const result: CatalogSandboxPolicy = { version: root.version }; + if (nodes.some(node => node.variant.sandboxPolicy.filesystem !== undefined)) { + result.filesystem = {}; + for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { + if (classes[field]) { + result.filesystem[field] = classes[field]; + } + } + } + // compositionViolation guarantees these fields exist only when exactly one + // entry is selected, so they are copied, never merged. + if (root.network !== undefined) { + result.network = structuredClone(root.network); + } + if (root.ui !== undefined) { + result.ui = structuredClone(root.ui); + } + if (root.timeoutMs !== undefined) { + result.timeoutMs = root.timeoutMs; + } + return result; +} + +let defaultCatalog: PolicyCatalog | undefined; + +function bundled(): PolicyCatalog { + defaultCatalog ??= new PolicyCatalog(bundledCatalogStore()); + return defaultCatalog; +} + +/** Composed candidate policy for one tool, from the bundled catalog. */ +export function getSandboxConfig(tool: ToolInput, ctx?: ResolveContext): CatalogSandboxPolicy | undefined; +/** Composed candidate policy for several tools, from the bundled catalog. */ +export function getSandboxConfig(tools: readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined; +export function getSandboxConfig(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined { + return bundled().getSandboxConfig(tools, ctx); +} + +/** Composed candidate policy plus attribution for one tool. See {@link PolicyCatalog.getSandboxConfigWithDiagnostics}. */ +export function getSandboxConfigWithDiagnostics(tool: ToolInput, ctx?: ResolveContext): SandboxConfigResolution; +/** Composed candidate policy plus attribution for several tools. */ +export function getSandboxConfigWithDiagnostics(tools: readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution; +export function getSandboxConfigWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution { + return bundled().getSandboxConfigWithDiagnostics(tools, ctx); +} + +/** Lists bundled catalog entry metadata. See {@link PolicyCatalog.listCatalogEntries}. */ +export function listCatalogEntries(): CatalogEntryMetadata[] { + return bundled().listCatalogEntries(); +} + +/** Reports the bundled catalog schema version and revision. */ +export function getCatalogInfo(): CatalogInfo { + return bundled().getCatalogInfo(); +} diff --git a/policy-catalog/typescript/src/store.ts b/policy-catalog/src/store.ts similarity index 97% rename from policy-catalog/typescript/src/store.ts rename to policy-catalog/src/store.ts index 87fceb69d..81ecc636d 100644 --- a/policy-catalog/typescript/src/store.ts +++ b/policy-catalog/src/store.ts @@ -191,8 +191,9 @@ export function loadCatalogDirectory(directory: URL): CatalogStore { let bundled: CatalogStore | undefined; /** - * The catalog bundled with this package. The build copies the repository's - * `catalog/` directory next to the compiled output (`/../catalog/`). + * The catalog bundled with this package: `/catalog/`, resolved + * relative to the compiled `dist/` output. The package ships the same + * directory the repository validates, so there is no copy step. */ export function bundledCatalogStore(): CatalogStore { bundled ??= loadCatalogDirectory(new URL('../catalog/', import.meta.url)); diff --git a/policy-catalog/typescript/src/tooling.ts b/policy-catalog/src/tooling.ts similarity index 86% rename from policy-catalog/typescript/src/tooling.ts rename to policy-catalog/src/tooling.ts index 7fd983e9f..6c573c1e1 100644 --- a/policy-catalog/typescript/src/tooling.ts +++ b/policy-catalog/src/tooling.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Contribution and CI tooling (spec §7). Not part of the runtime lookup API; +// Contribution and CI tooling (design §7). Not part of the runtime lookup API; // exported under the `./tooling` subpath so validation scripts and tests can // share the resolver's exact rules. export { canonicalJson, canonicalSha256 } from './canonical-json.js'; @@ -26,4 +26,5 @@ export { type PublishedState, } from './history.js'; export { isValidVersionRange, satisfiesVersionRange } from './version-range.js'; +export { architectureFromMachine } from './host.js'; export { parsePurl } from './purl.js'; diff --git a/policy-catalog/typescript/src/types.ts b/policy-catalog/src/types.ts similarity index 62% rename from policy-catalog/typescript/src/types.ts rename to policy-catalog/src/types.ts index 558c234f3..d7fa09049 100644 --- a/policy-catalog/typescript/src/types.ts +++ b/policy-catalog/src/types.ts @@ -1,18 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -/** Catalog platform selector values (spec §4.4). */ +/** Catalog platform selector values (design §4.4). */ export type CatalogPlatform = 'windows' | 'linux' | 'macos'; -/** Catalog architecture selector values (spec §4.4). */ +/** Catalog architecture selector values (design §4.4). */ export type CatalogArchitecture = 'x64' | 'arm64'; /** * The catalog-supported subset of MXC's `SandboxPolicy` authoring contract. * * This library deliberately does not import the MXC SDK. The shape is - * structurally compatible with the MXC SDK `SandboxPolicy` type so a consumer - * can pass a (reviewed, authorized) result to `createConfigFromPolicy()`. + * structurally compatible with the MXC SDK `SandboxPolicy` type, so a consumer + * can pass a reviewed, authorized result to `createConfigFromPolicy()`. * Backend-specific keys are not part of the catalog vocabulary. */ export interface CatalogSandboxPolicy { @@ -46,14 +46,17 @@ export interface CatalogNetworkRule { ports?: Array<{ protocol?: 'tcp' | 'udp' | 'icmp' | 'any'; port?: number; endPort?: number }>; } -/** Runtime lookup input (spec §5.1). */ +/** Runtime lookup input (design §5.1). */ export interface ToolCandidate { invocationName: string; packageUrl?: string; detectedVersion?: string; } -/** Runtime lookup context (spec §5.1). */ +/** A string is shorthand for `{ invocationName: tool }` (design §5.1). */ +export type ToolInput = string | ToolCandidate; + +/** Runtime lookup context, shared by every input in one lookup (design §5.1). */ export interface ResolveContext { projectRoot?: string; symbols?: Record; @@ -63,27 +66,40 @@ export interface ResolveContext { allowWeakIdentityFallback?: boolean; } -/** Runtime lookup result (spec §5.1). */ -export interface ResolvedToolEntry { - entryId: string; - entryRevision: number; - catalogRevision: string; - matchedIdentity: { kind: string; strength: 'strong' | 'weak' }; - resolvedDependencies: Array<{ - entryId: string; - entryRevision: number; - requiredVersionRange?: string; - }>; - policy: CatalogSandboxPolicy; - warnings: string[]; +/** Strength of a matched identity predicate. */ +export type IdentityStrength = 'strong' | 'weak'; + +/** Result of `getSandboxConfigWithDiagnostics` (design §5.1). */ +export interface SandboxConfigResolution { + policy: CatalogSandboxPolicy | undefined; + diagnostics: { + catalogRevision: string; + tools: Array<{ + inputIndex: number; + matches: Array<{ + entryId: string; + entryRevision: number; + matchedIdentities: Array<{ + kind: string; + strength: IdentityStrength; + }>; + }>; + }>; + resolvedDependencies: Array<{ + entryId: string; + entryRevision: number; + requiredVersionRange?: string; + }>; + warnings: string[]; + }; } -/** Inspection metadata (spec §5.2). */ +/** Inspection metadata (design §5.2). */ export type CatalogIdentityMetadata = | { kind: 'purl'; value: string; versionRange?: string } | { kind: 'invocation-name'; names: string[] }; -/** Inspection metadata (spec §5.2). No policy body is exposed. */ +/** Inspection metadata (design §5.2). It never exposes a policy body. */ export interface CatalogEntryMetadata { catalogRevision: string; entryId: string; @@ -102,7 +118,7 @@ export interface CatalogEntryMetadata { }; } -/** Result of `getCatalogInfo()` (spec §5.2). */ +/** Result of `getCatalogInfo()` (design §5.2). */ export interface CatalogInfo { catalogSchemaVersion: string; catalogRevision: string; diff --git a/policy-catalog/typescript/src/version-range.ts b/policy-catalog/src/version-range.ts similarity index 100% rename from policy-catalog/typescript/src/version-range.ts rename to policy-catalog/src/version-range.ts diff --git a/policy-catalog/tests/functional/cli.test.ts b/policy-catalog/tests/functional/cli.test.ts new file mode 100644 index 000000000..e1bf8d99e --- /dev/null +++ b/policy-catalog/tests/functional/cli.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Functional (end-to-end) tests. They run the compiled CLI (`dist/cli.js`) as a +// separate process against the real bundled catalog and against tampered or +// invalid copies of it, then assert on exit codes and JSON output. Unlike the +// unit tests, they exercise the shipped entry point, file loading, integrity +// verification, and host detection exactly as a consumer would. Mirrors MXC's +// sdk/node/tests/integration layout (compiled node:test files, spec reporter). +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// dist-tests/tests/functional/ -> package root +const packageRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const cli = join(packageRoot, 'dist', 'cli.js'); +const catalogDir = join(packageRoot, 'catalog'); +const manifest = JSON.parse(readFileSync(join(catalogDir, 'manifest.json'), 'utf8')); + +interface CliResult { + status: number | null; + json: any; + stderr: string; +} + +function run(...args: string[]): CliResult { + const result = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' }); + let json: any; + try { + json = result.stdout.trim() === '' ? undefined : JSON.parse(result.stdout); + } catch { + json = result.stdout; + } + return { status: result.status, json, stderr: result.stderr }; +} + +const PLATFORM_CONTEXT = { + windows: { root: 'C:\\work\\app', prefix: 'C:\\tools', cache: 'C:\\cache\\npm' }, + linux: { root: '/work/app', prefix: '/opt/tools', cache: '/var/cache/npm' }, + macos: { root: '/Users/dev/app', prefix: '/opt/homebrew/bin', cache: '/Users/dev/.npm' }, +} as const; + +function fullContext(platform: keyof typeof PLATFORM_CONTEXT, architecture: string): string[] { + const c = PLATFORM_CONTEXT[platform]; + return [ + '--platform', platform, + '--architecture', architecture, + '--allow-weak', + '--project-root', c.root, + '--symbol', `git_prefix=${c.prefix}`, + '--symbol', `node_prefix=${c.prefix}`, + '--symbol', `npm_prefix=${c.prefix}`, + '--symbol', `npm_cache=${c.cache}`, + ]; +} + +describe('policy-catalog CLI: bundled catalog', () => { + it('info reports the installed default revision', () => { + const result = run('info'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json, { catalogSchemaVersion: '1', catalogRevision: manifest.defaultRevision }); + }); + + it('verify checks integrity and history of every installed revision', () => { + const result = run('verify'); + assert.equal(result.status, 0, JSON.stringify(result.json)); + assert.equal(result.json.ok, true); + assert.deepEqual(result.json.revisions, manifest.revisions.map((r: any) => r.catalogRevision)); + }); + + it('list returns metadata only, sorted by entryId', () => { + const result = run('list'); + assert.equal(result.status, 0); + const ids = result.json.map((e: any) => e.entryId); + assert.deepEqual(ids, [...ids].sort()); + assert.ok(!JSON.stringify(result.json).includes('readwritePaths')); + }); + + for (const platform of ['windows', 'linux', 'macos'] as const) { + for (const architecture of ['x64', 'arm64']) { + it(`resolves the full tool set on ${platform}/${architecture} deterministically`, () => { + const c = PLATFORM_CONTEXT[platform]; + const first = run('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); + const second = run('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); + assert.equal(first.status, 0, first.stderr); + assert.deepEqual(first.json, second.json); + const { policy, diagnostics } = first.json; + assert.equal(policy.version, '0.9.0-alpha'); + assert.deepEqual(policy.filesystem.readwritePaths, [c.root, c.cache]); + assert.deepEqual(policy.filesystem.readonlyPaths, [c.prefix]); + assert.equal(policy.network, undefined, 'v1 never composes network'); + assert.deepEqual(diagnostics.tools.map((t: any) => t.matches.map((m: any) => m.entryId)), [['tool:git'], ['tool:npm'], ['tool:node']]); + assert.deepEqual(diagnostics.resolvedDependencies, [{ entryId: 'tool:node', entryRevision: 1 }]); + }); + } + } + + it('policy-only output equals the diagnostics policy', () => { + const ctx = fullContext('linux', 'x64'); + const plain = run('resolve', ...ctx, 'npm'); + const diag = run('resolve', '--diagnostics', ...ctx, 'npm'); + assert.deepEqual(plain.json, diag.json.policy); + }); + + it('strong package-URL identity does not need the weak-fallback opt-in', () => { + const c = PLATFORM_CONTEXT.linux; + const result = run('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--project-root', c.root, + '--symbol', `npm_prefix=${c.prefix}`, '--symbol', `npm_cache=${c.cache}`, '--symbol', `node_prefix=${c.prefix}`, + '--purl', 'pkg:npm/npm@10.9.0', 'npm'); + assert.equal(result.status, 0, result.stderr); + // Every satisfied predicate is attributed in declaration order (design §4.3); + // the strong one makes the entry eligible without the weak-fallback opt-in. + assert.deepEqual(result.json.diagnostics.tools[0].matches[0].matchedIdentities.map((i: any) => i.strength), ['strong', 'weak']); + assert.notEqual(result.json.policy, null); + }); + + it('omitted architecture uses the native system architecture and says so', () => { + const c = PLATFORM_CONTEXT.linux; + const result = run('resolve', '--diagnostics', '--platform', 'linux', '--allow-weak', '--project-root', c.root, '--symbol', `git_prefix=${c.prefix}`, 'git'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.json.diagnostics.warnings.join('\n'), /native system architecture '(x64|arm64)'; the tool's architecture was not verified/); + }); + + it('unknown tools, weak-only matches without opt-in, and empty input produce null, never an empty policy', () => { + assert.equal(run('resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', 'cargo').json, null); + assert.equal(run('resolve', ...fullContext('linux', 'x64').filter(a => a !== '--allow-weak'), 'git').json, null); + const empty = run('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64'); + assert.equal(empty.status, 0); + assert.deepEqual(empty.json, { diagnostics: { catalogRevision: manifest.defaultRevision, tools: [], resolvedDependencies: [], warnings: [] } }); + }); + + it('an unresolved required symbol yields null with an actionable warning', () => { + const result = run('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/usr/bin', 'git'); + assert.equal(result.status, 0); + assert.equal(result.json.policy, undefined); + assert.match(result.json.diagnostics.warnings.join('\n'), /required symbol 'project_root'.*supply ResolveContext\.projectRoot/); + }); + + it('library failures exit 1 with a stable error category', () => { + const cases: Array<[string[], string]> = [ + [['resolve', '--revision', '1999-01-01.1', 'git'], 'revision-unavailable'], + [['resolve', '--platform', 'plan9', 'git'], 'invalid-context'], + [['resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'node_prefix=relative', 'node'], 'invalid-context'], + [['resolve', '--platform', 'linux', '--architecture', 'x64', '--symbol', 'unknown_symbol=/x', 'git'], 'invalid-context'], + [['resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], 'composition-conflict'], + ]; + for (const [args, category] of cases) { + const result = run(...args); + assert.equal(result.status, 1, `${args.join(' ')} -> ${result.stderr}`); + assert.equal(result.json.error.category, category, args.join(' ')); + } + }); + + it('usage errors exit 2 without JSON on stdout', () => { + for (const args of [[], ['bogus'], ['resolve', '--platform'], ['resolve', '--symbol', 'noequals', 'git'], ['resolve', '--purl', 'pkg:npm/npm']]) { + const result = run(...args); + assert.equal(result.status, 2, args.join(' ')); + assert.equal(result.json, undefined); + assert.match(result.stderr, /usage: policy-catalog/); + } + }); +}); + +describe('policy-catalog CLI: integrity failure modes on a copied catalog', () => { + let work: string; + const copy = (): string => { + const dir = mkdtempSync(join(work, 'catalog-')); + cpSync(catalogDir, dir, { recursive: true }); + return dir; + }; + const revisionFile = (dir: string) => join(dir, manifest.revisions.at(-1).file); + + before(() => { + work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-')); + }); + after(() => { + rmSync(work, { recursive: true, force: true }); + }); + + it('an untouched copy verifies and resolves like the bundled catalog', () => { + const dir = copy(); + assert.equal(run('verify', '--catalog', dir).status, 0); + const ctx = fullContext('linux', 'x64'); + assert.deepEqual(run('resolve', '--catalog', dir, ...ctx, 'git').json, run('resolve', ...ctx, 'git').json); + }); + + it('formatting-only edits keep the canonical digest valid', () => { + const dir = copy(); + const file = revisionFile(dir); + writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, 'utf8')))); + assert.equal(run('verify', '--catalog', dir).status, 0); + }); + + it('a semantic edit to a published revision fails integrity, never silently resolving', () => { + const dir = copy(); + const file = revisionFile(dir); + const data = JSON.parse(readFileSync(file, 'utf8')); + data.entries[0].platformVariants[0].sandboxPolicy.filesystem.readwritePaths.push('${user_home}'); + writeFileSync(file, JSON.stringify(data, null, 2)); + const verify = run('verify', '--catalog', dir); + assert.equal(verify.status, 1, verify.stderr); + assert.ok(verify.json?.errors, `verify produced no JSON report; stderr: ${verify.stderr}`); + assert.match(verify.json.errors.join('\n'), /\[integrity\].*does not match the published digest/); + const resolve = run('resolve', '--catalog', dir, ...fullContext('windows', 'x64'), 'git'); + assert.equal(resolve.status, 1); + assert.equal(resolve.json.error.category, 'integrity'); + }); + + it('a missing revision file is an integrity failure', () => { + const dir = copy(); + rmSync(revisionFile(dir)); + const result = run('info', '--catalog', dir); + assert.equal(result.status, 1); + assert.equal(result.json.error.category, 'integrity'); + }); + + it('a manifest whose default names an unlisted revision is a validation failure', () => { + const dir = copy(); + const file = join(dir, 'manifest.json'); + writeFileSync(file, JSON.stringify({ ...manifest, defaultRevision: '2099-01-01.1' })); + const result = run('info', '--catalog', dir); + assert.equal(result.status, 1); + assert.equal(result.json.error.category, 'validation'); + }); +}); diff --git a/policy-catalog/tests/functional/tsconfig.json b/policy-catalog/tests/functional/tsconfig.json new file mode 100644 index 000000000..58bf962e7 --- /dev/null +++ b/policy-catalog/tests/functional/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "../../dist-tests", + "rootDir": "../..", + "strict": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/policy-catalog/tests/unit/conformance.test.ts b/policy-catalog/tests/unit/conformance.test.ts new file mode 100644 index 000000000..37ba0177f --- /dev/null +++ b/policy-catalog/tests/unit/conformance.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Runs the language-neutral conformance fixtures (conformance/fixtures/*.json). +// Other language bindings must run the same files with the same expectations. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { bundledCatalog, catalogFor, errorCategory, fixedHost, fixtureFiles, fixturesDir, readJson } from './helpers.js'; + +function toExpected(expect: any): any { + return { ...expect, policy: expect.policy ?? undefined }; +} + +for (const name of fixtureFiles()) { + const fixture = readJson(new URL(name, fixturesDir)); + describe(`conformance: ${name}`, () => { + for (const testCase of fixture.cases) { + it(testCase.name, () => { + const host = fixedHost(testCase.host?.platform ?? 'linux', testCase.host?.nativeArchitecture ?? 'x64'); + const catalog = fixture.catalog === 'bundled' ? bundledCatalog(host) : catalogFor(fixture.catalog, host); + if (testCase.expectError !== undefined) { + assert.equal(errorCategory(() => catalog.getSandboxConfigWithDiagnostics(testCase.tools, testCase.context)), testCase.expectError); + assert.equal(errorCategory(() => catalog.getSandboxConfig(testCase.tools, testCase.context)), testCase.expectError); + return; + } + const expected = toExpected(testCase.expect); + assert.deepEqual(catalog.getSandboxConfigWithDiagnostics(testCase.tools, testCase.context), expected); + // The policy-only operation returns the same policy from the same logic. + assert.deepEqual(catalog.getSandboxConfig(testCase.tools, testCase.context), expected.policy); + // A single input is exactly a one-element array. + if (!Array.isArray(testCase.tools)) { + assert.deepEqual(catalog.getSandboxConfigWithDiagnostics([testCase.tools], testCase.context), expected); + } + }); + } + }); +} diff --git a/policy-catalog/typescript/test/helpers.ts b/policy-catalog/tests/unit/helpers.ts similarity index 90% rename from policy-catalog/typescript/test/helpers.ts rename to policy-catalog/tests/unit/helpers.ts index 252b2127f..539c6c2ff 100644 --- a/policy-catalog/typescript/test/helpers.ts +++ b/policy-catalog/tests/unit/helpers.ts @@ -8,11 +8,11 @@ import { PolicyCatalogError, bundledCatalogStore, type HostEnvironment, -} from '../dist/index.js'; -import { canonicalSha256 } from '../dist/tooling.js'; +} from '@mxc-prototype/policy-catalog'; +import { canonicalSha256 } from '@mxc-prototype/policy-catalog/tooling'; -export const fixturesDir = new URL('../../conformance/fixtures/', import.meta.url); -export const catalogDir = new URL('../../catalog/', import.meta.url); +export const fixturesDir = new URL('../../../conformance/fixtures/', import.meta.url); +export const catalogDir = new URL('../../../catalog/', import.meta.url); export function readJson(url: URL): any { return JSON.parse(readFileSync(url, 'utf8')); @@ -28,7 +28,7 @@ export function fixedHost( ): HostEnvironment { return { platform: () => platform, - architecture: () => architecture, + nativeArchitecture: () => architecture, symbol: name => symbols[name], }; } diff --git a/policy-catalog/tests/unit/resolver.test.ts b/policy-catalog/tests/unit/resolver.test.ts new file mode 100644 index 000000000..80e65d43e --- /dev/null +++ b/policy-catalog/tests/unit/resolver.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + PolicyCatalog, + getCatalogInfo, + getSandboxConfig, + getSandboxConfigWithDiagnostics, + listCatalogEntries, +} from '@mxc-prototype/policy-catalog'; +import { architectureFromMachine } from '@mxc-prototype/policy-catalog/tooling'; +import { bundledCatalog, catalogFor, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; + +const v = '0.9.0-alpha'; +const weak = { allowWeakIdentityFallback: true } as const; + +describe('runtime lookup: defaults (design §1)', () => { + const archCatalog = (host = fixedHost('windows', 'arm64')) => catalogFor(revisionWith([ + entry('tool:a', { + identity: [{ kind: 'purl', value: 'pkg:npm/a' }, { kind: 'invocation-name', names: ['a'] }], + platformVariants: [ + { when: { platform: 'windows', architecture: 'x64' }, sandboxPolicy: { version: v, timeoutMs: 1 } }, + { when: { platform: 'windows', architecture: 'arm64' }, sandboxPolicy: { version: v, timeoutMs: 2 } }, + ], + }), + ]), host); + + it('omitted context: host platform, native architecture, installed revision, no weak fallback', () => { + const catalog = archCatalog(); + assert.equal(catalog.getSandboxConfig('a'), undefined, 'weak identity requires opt-in'); + const result = catalog.getSandboxConfigWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }); + assert.equal(result.policy?.timeoutMs, 2, 'native arm64 selected'); + assert.equal(result.diagnostics.catalogRevision, '2000-01-01.1'); + assert.match(result.diagnostics.warnings.join('\n'), /native system architecture 'arm64'; the tool's architecture was not verified/); + }); + + it('explicit architecture takes precedence and suppresses the host-default warning', () => { + const result = archCatalog().getSandboxConfigWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }, { architecture: 'x64' }); + assert.equal(result.policy?.timeoutMs, 1); + assert.deepEqual(result.diagnostics.warnings, []); + }); + + it('a different architecture variant is never a fallback', () => { + const catalog = catalogFor(revisionWith([ + entry('tool:a', { platformVariants: [{ when: { platform: 'linux', architecture: 'arm64' }, sandboxPolicy: { version: v } }] }), + ])); + const result = catalog.getSandboxConfigWithDiagnostics('a', { ...weak, architecture: 'x64' }); + assert.equal(result.policy, undefined); + assert.match(result.diagnostics.warnings[0], /tool:a has no variant for linux\/x64/); + }); + + it('host architecture detection failure is a library error, not a guessed selection', () => { + const host = { ...fixedHost(), nativeArchitecture: (): never => { throw new Error('unknown machine'); } }; + const catalog = new PolicyCatalog(storeFor([revisionWith([entry('tool:t')])]), host); + assert.throws(() => catalog.getSandboxConfig('t', { ...weak, projectRoot: '/p' }), /unknown machine/); + // Detection is not needed when nothing matches, so it is not attempted. + assert.equal(catalog.getSandboxConfig('nothing', weak), undefined); + // An explicit architecture never consults the host. + assert.deepEqual(catalog.getSandboxConfig('t', { ...weak, architecture: 'x64', projectRoot: '/p' })?.filesystem, { readwritePaths: ['/p'] }); + }); + + it('maps OS machine names to selectors and rejects unknown ones', () => { + assert.equal(architectureFromMachine('AMD64'), 'x64'); + assert.equal(architectureFromMachine('x86_64'), 'x64'); + assert.equal(architectureFromMachine('ARM64'), 'arm64'); + assert.equal(architectureFromMachine('aarch64'), 'arm64'); + assert.equal(architectureFromMachine('riscv64'), undefined); + assert.equal(architectureFromMachine('x86'), undefined); + }); + + it('never fabricates projectRoot or caller symbols', () => { + const result = bundledCatalog().getSandboxConfigWithDiagnostics('git', { ...weak, architecture: 'x64' }); + assert.equal(result.policy, undefined); + assert.match(result.diagnostics.warnings.join('\n'), /required symbol 'git_prefix'.*required symbol 'project_root'/s); + }); + + it('derives host symbols only for the current host platform, and caller values override them', () => { + const revision = revisionWith([ + entry('tool:t', { + platformVariants: [ + { when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, + { when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, + ], + }), + ]); + const catalog = catalogFor(revision, fixedHost('linux', 'x64', { user_home: '/home/me' })); + assert.deepEqual(catalog.getSandboxConfig('t', weak)?.filesystem, { readonlyPaths: ['/home/me/.cfg'] }); + assert.equal(catalog.getSandboxConfig('t', { ...weak, platform: 'macos', architecture: 'arm64' }), undefined); + assert.deepEqual(catalog.getSandboxConfig('t', { ...weak, symbols: { user_home: '/srv/u' } })?.filesystem, { readonlyPaths: ['/srv/u/.cfg'] }); + }); +}); + +describe('runtime lookup: inputs and failures (design §5.1)', () => { + it('string shorthand, object input, and a one-element array are equivalent', () => { + const catalog = bundledCatalog(); + const ctx = { ...weak, architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/g' } }; + const a = catalog.getSandboxConfigWithDiagnostics('git', ctx); + assert.deepEqual(catalog.getSandboxConfigWithDiagnostics({ invocationName: 'git' }, ctx), a); + assert.deepEqual(catalog.getSandboxConfigWithDiagnostics(['git'], ctx), a); + assert.equal(a.diagnostics.tools[0].inputIndex, 0); + }); + + it('string shorthand obeys the weak-identity option exactly like an object input', () => { + const catalog = bundledCatalog(); + const ctx = { architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/g' } }; + assert.equal(catalog.getSandboxConfig('git', ctx), undefined); + assert.equal(catalog.getSandboxConfig({ invocationName: 'git' }, ctx), undefined); + }); + + it('rejects invalid context and inputs as library failures, not absence', () => { + const catalog = bundledCatalog(); + const cases: Array<[unknown, unknown]> = [ + ['', {}], + ['/usr/bin/git', {}], + [{ invocationName: 'npm', packageUrl: 'npm' }, {}], + [{ invocationName: 'npm', detectedVersion: '' }, {}], + [[42], {}], + ['git', { platform: 'plan9' }], + ['git', { architecture: 'x86' }], + ['git', { symbols: { nope: '/x' } }], + ['git', { symbols: { project_root: '/x' } }], + ['git', { projectRoot: '' }], + ]; + for (const [tools, ctx] of cases) { + assert.equal(errorCategory(() => catalog.getSandboxConfig(tools as any, ctx as any)), 'invalid-context', JSON.stringify([tools, ctx])); + } + }); + + it('is deterministic and returns caller-owned copies', () => { + const catalog = bundledCatalog(); + const ctx = { architecture: 'x64' as const, projectRoot: '/p', symbols: { npm_prefix: '/n', npm_cache: '/c', node_prefix: '/n' } }; + const tool = { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }; + const first = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + first.policy!.filesystem!.readwritePaths!.push('/mutated'); + first.diagnostics.warnings.push('mutated'); + first.diagnostics.tools[0].matches.length = 0; + const second = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + assert.deepEqual(second.policy?.filesystem, { readonlyPaths: ['/n'], readwritePaths: ['/p', '/c'] }); + assert.equal(second.diagnostics.tools[0].matches.length, 1); + assert.ok(!second.diagnostics.warnings.includes('mutated')); + }); + + it('de-duplicates normalized paths within an access class using platform path rules', () => { + const catalog = catalogFor(revisionWith([ + entry('tool:w', { + platformVariants: [{ when: { platform: 'windows' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}\\'] } } }], + }), + ])); + const policy = catalog.getSandboxConfig('w', { platform: 'windows', architecture: 'x64', ...weak, symbols: { git_prefix: 'C:\\Tools', node_prefix: 'c:\\tools' } }); + assert.deepEqual(policy?.filesystem, { readonlyPaths: ['C:\\Tools'] }); + // Linux paths are case-sensitive, so the same values stay distinct there. + const linux = catalogFor(revisionWith([ + entry('tool:l', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}/'] } } }] }), + ])); + assert.deepEqual(linux.getSandboxConfig('l', { ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } })?.filesystem, { readonlyPaths: ['/Tools', '/tools'] }); + }); + + it('dependency chain resolution composes each entry once, in deterministic order', () => { + const variant = (deps: string[], path: string) => ({ + when: { platform: 'linux' }, + ...(deps.length ? { dependencies: deps.map(entryId => ({ entryId })) } : {}), + sandboxPolicy: { version: v, filesystem: { readonlyPaths: [`\${project_root}/${path}`] } }, + }); + const catalog = catalogFor(revisionWith([ + entry('tool:top', { platformVariants: [variant(['tool:left', 'tool:right'], 'top')] }), + entry('tool:left', { platformVariants: [variant(['tool:leaf'], 'left')] }), + entry('tool:right', { platformVariants: [variant(['tool:leaf'], 'right')] }), + entry('tool:leaf', { platformVariants: [variant([], 'leaf')] }), + ])); + const result = catalog.getSandboxConfigWithDiagnostics('top', { ...weak, projectRoot: '/r' }); + assert.deepEqual(result.diagnostics.resolvedDependencies.map(d => d.entryId), ['tool:leaf', 'tool:left', 'tool:right']); + assert.deepEqual(result.policy?.filesystem?.readonlyPaths, ['/r/top', '/r/left', '/r/leaf', '/r/right']); + }); + + it('dependency diagnostics retain distinct required ranges, sorted', () => { + const catalog = catalogFor(revisionWith([ + entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:c', versionRange: '>=2' }], sandboxPolicy: { version: v } }] }), + entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:c' }], sandboxPolicy: { version: v } }] }), + entry('tool:c', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), + ])); + const result = catalog.getSandboxConfigWithDiagnostics(['a', 'b', 'a'], weak); + assert.deepEqual(result.diagnostics.resolvedDependencies, [ + { entryId: 'tool:c', entryRevision: 1 }, + { entryId: 'tool:c', entryRevision: 1, requiredVersionRange: '>=2' }, + ]); + assert.deepEqual(result.policy, { version: v }); + }); + + it('catalog file order does not change matching, attribution, or composition', () => { + const entries = [ + entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['x'] }], platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${project_root}/b'] } } }] }), + entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['x'] }], platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${project_root}/a'] } } }] }), + ]; + const forward = catalogFor(revisionWith(entries)).getSandboxConfigWithDiagnostics('x', { ...weak, projectRoot: '/r' }); + const backward = catalogFor(revisionWith([...entries].reverse())).getSandboxConfigWithDiagnostics('x', { ...weak, projectRoot: '/r' }); + assert.deepEqual(forward, backward); + assert.deepEqual(forward.diagnostics.tools[0].matches.map(m => m.entryId), ['tool:a', 'tool:b']); + assert.deepEqual(forward.policy?.filesystem?.readonlyPaths, ['/r/a', '/r/b']); + }); + + it('rejects mixed policy versions across inputs at lookup time', () => { + const catalog = catalogFor(revisionWith([ + entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.8.0-alpha' } }] }), + entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), + ])); + assert.equal(errorCategory(() => catalog.getSandboxConfig(['a', 'b'], weak)), 'composition-conflict'); + // Each tool on its own is fine. + assert.deepEqual(catalog.getSandboxConfig('a', weak), { version: '0.8.0-alpha' }); + }); +}); + +describe('setup and inspection (design §5.2)', () => { + it('reports the bundled revision, which matches the manifest default', () => { + assert.deepEqual(getCatalogInfo(), { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' }); + }); + + it('lists metadata ordered by entryId, without any policy body', () => { + const entries = listCatalogEntries(); + assert.deepEqual(entries.map(e => e.entryId), ['tool:git', 'tool:node', 'tool:npm']); + const npm = entries.find(e => e.entryId === 'tool:npm')!; + assert.deepEqual(npm.platformVariants[0], { platform: 'windows', dependencyEntryIds: ['tool:node'], sandboxPolicyVersion: v }); + const text = JSON.stringify(entries); + assert.ok(!text.includes('Paths')); + assert.ok(!text.includes('${')); + assert.ok(!text.includes('sandboxPolicy"')); + }); + + it('module-level functions use the bundled catalog', () => { + const ctx = { ...weak, platform: 'linux' as const, architecture: 'x64' as const }; + assert.equal(getSandboxConfig('definitely-unknown-tool', ctx), undefined); + const result = getSandboxConfigWithDiagnostics(['definitely-unknown-tool'], ctx); + assert.equal(result.policy, undefined); + assert.deepEqual(result.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); + }); +}); diff --git a/policy-catalog/typescript/test/store.test.ts b/policy-catalog/tests/unit/store.test.ts similarity index 84% rename from policy-catalog/typescript/test/store.test.ts rename to policy-catalog/tests/unit/store.test.ts index 086ec25ea..9d47a5610 100644 --- a/policy-catalog/typescript/test/store.test.ts +++ b/policy-catalog/tests/unit/store.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { PolicyCatalog, bundledCatalogStore } from '../dist/index.js'; +import { PolicyCatalog, bundledCatalogStore } from '@mxc-prototype/policy-catalog'; import { canonicalJson, canonicalSha256, @@ -12,7 +12,7 @@ import { checkStoreHistory, validateContract, validateCatalogRevision, -} from '../dist/tooling.js'; +} from '@mxc-prototype/policy-catalog/tooling'; import { contract, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; const parsedContract = validateContract(contract); @@ -35,7 +35,9 @@ describe('integrity', () => { const store = storeFor([revision], { digests: { '2000-01-01.1': '0'.repeat(64) } }); assert.equal(errorCategory(() => store.revision()), 'integrity'); const catalog = new PolicyCatalog(store, fixedHost()); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'a' }, { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorCategory(() => catalog.getSandboxConfig('a', { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorCategory(() => catalog.getSandboxConfigWithDiagnostics('a', { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorCategory(() => catalog.listCatalogEntries()), 'integrity'); assert.equal(errorCategory(() => catalog.getCatalogInfo()), 'integrity'); }); @@ -61,9 +63,13 @@ describe('versioning and immutable revisions', () => { const r2 = revisionWith([entry('tool:a', { entryRevision: 2, displayName: 'renamed' }), entry('tool:b')], '2000-01-02.1'); const catalog = new PolicyCatalog(storeFor([r1, r2]), fixedHost()); const ctx = { allowWeakIdentityFallback: true, projectRoot: '/p' }; - assert.equal(catalog.resolveCatalogEntry({ invocationName: 'a' }, ctx)?.entryRevision, 2); - assert.equal(catalog.resolveCatalogEntry({ invocationName: 'a' }, { ...ctx, catalogRevision: '2000-01-01.1' })?.entryRevision, 1); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'a' }, { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision-unavailable'); + const revisionOf = (extra = {}) => { + const result = catalog.getSandboxConfigWithDiagnostics('a', { ...ctx, ...extra }); + return [result.diagnostics.catalogRevision, result.diagnostics.tools[0].matches[0]?.entryRevision]; + }; + assert.deepEqual(revisionOf(), ['2000-01-02.1', 2]); + assert.deepEqual(revisionOf({ catalogRevision: '2000-01-01.1' }), ['2000-01-01.1', 1]); + assert.equal(errorCategory(() => catalog.getSandboxConfig('a', { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision-unavailable'); }); it('loaded revisions are deeply frozen (read-only)', () => { diff --git a/policy-catalog/tests/unit/tsconfig.json b/policy-catalog/tests/unit/tsconfig.json new file mode 100644 index 000000000..58bf962e7 --- /dev/null +++ b/policy-catalog/tests/unit/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "../../dist-tests", + "rootDir": "../..", + "strict": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/policy-catalog/typescript/test/validation.test.ts b/policy-catalog/tests/unit/validation.test.ts similarity index 90% rename from policy-catalog/typescript/test/validation.test.ts rename to policy-catalog/tests/unit/validation.test.ts index b317740f4..809a30db3 100644 --- a/policy-catalog/typescript/test/validation.test.ts +++ b/policy-catalog/tests/unit/validation.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { validateCatalogRevision, validateContract } from '../dist/tooling.js'; +import { validateCatalogRevision, validateContract } from '@mxc-prototype/policy-catalog/tooling'; import { contract, entry, revisionWith } from './helpers.js'; const parsedContract = validateContract(contract); @@ -83,13 +83,17 @@ describe('catalog validation (contribution/CI rules)', () => { assert.doesNotThrow(net({ egress: { default: 'deny', allow: [{ to: [{ cidr: '192.0.2.0/24' }], ports: [{ protocol: 'tcp', port: 443 }] }] } })); }); - it('enforces identity uniqueness across entries and strongest-first ordering', () => { - assert.throws(validate([entry('tool:a'), entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['A'] }] })]), /claimed by both/); - assert.throws(validate([ + it('allows overlapping identities across entries (additive matching) but not repeats within one entry', () => { + // Design §4.3: equal-strength matches to different entries both contribute. + assert.doesNotThrow(validate([entry('tool:a'), entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['A'] }] })])); + assert.doesNotThrow(validate([ entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/x' }] }), entry('tool:b', { identity: [{ kind: 'purl', value: 'pkg:NPM/x' }] }), - ]), /claimed by both/); - assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['a'] }, { kind: 'purl', value: 'pkg:npm/a' }] })]), /ordered strongest first/); + ])); + // Predicate order within an entry carries no precedence. + assert.doesNotThrow(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['a'] }, { kind: 'purl', value: 'pkg:npm/a' }] })])); + assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['a', 'A'] }] })]), /repeats identity/); + assert.throws(validate([entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/a' }, { kind: 'purl', value: 'pkg:npm/a' }] })]), /repeats identity/); assert.throws(validate([entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/a@1.0.0' }] })]), /must not pin a version/); assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['bin/a'] }] })]), /bare invocation name/); assert.throws(validate([entry('tool:a', { identity: [{ kind: 'sha256', value: 'x' }] })]), /not a supported identity kind/); diff --git a/policy-catalog/typescript/test/version-range.test.ts b/policy-catalog/tests/unit/version-range.test.ts similarity index 97% rename from policy-catalog/typescript/test/version-range.test.ts rename to policy-catalog/tests/unit/version-range.test.ts index d2dfd6ce0..64c657673 100644 --- a/policy-catalog/typescript/test/version-range.test.ts +++ b/policy-catalog/tests/unit/version-range.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { isValidVersionRange, parsePurl, satisfiesVersionRange } from '../dist/tooling.js'; +import { isValidVersionRange, parsePurl, satisfiesVersionRange } from '@mxc-prototype/policy-catalog/tooling'; describe('version ranges', () => { it('accepts the v1 grammar and rejects everything else', () => { diff --git a/policy-catalog/typescript/tsconfig.json b/policy-catalog/tsconfig.json similarity index 100% rename from policy-catalog/typescript/tsconfig.json rename to policy-catalog/tsconfig.json diff --git a/policy-catalog/typescript/package.json b/policy-catalog/typescript/package.json deleted file mode 100644 index 5e5a3003b..000000000 --- a/policy-catalog/typescript/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@mxc-prototype/policy-catalog", - "version": "0.0.0-prototype", - "private": true, - "description": "PROTOTYPE of the known-tool policy catalog library (TypeScript/JavaScript). Not published; package name is a placeholder pending the dedicated repository.", - "type": "module", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./tooling": { - "types": "./dist/tooling.d.ts", - "import": "./dist/tooling.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "dist/", - "catalog/", - "README.md" - ], - "scripts": { - "build": "tsc -p tsconfig.json && node scripts/copy-catalog.mjs", - "build:test": "tsc -p tsconfig.test.json", - "test": "npm run build && npm run build:test && node --test dist-test/*.test.js", - "validate": "npm run build && node scripts/validate-catalog.mjs", - "digest": "node scripts/catalog-digest.mjs", - "smoke:package": "npm run build && node scripts/package-smoke.mjs" - }, - "license": "MIT", - "devDependencies": { - "@types/node": "24.13.4", - "ajv": "8.20.0", - "typescript": "5.9.3" - }, - "engines": { - "node": ">=24.0.0" - } -} diff --git a/policy-catalog/typescript/scripts/copy-catalog.mjs b/policy-catalog/typescript/scripts/copy-catalog.mjs deleted file mode 100644 index f2c6e4510..000000000 --- a/policy-catalog/typescript/scripts/copy-catalog.mjs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Bundles the repository's catalog data into the package (`/catalog/`), -// so the library resolves locally without network access (spec §6.1). -import { cpSync, rmSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -const source = fileURLToPath(new URL('../../catalog/', import.meta.url)); -const target = fileURLToPath(new URL('../catalog/', import.meta.url)); -rmSync(target, { recursive: true, force: true }); -cpSync(source, target, { recursive: true }); diff --git a/policy-catalog/typescript/scripts/package-smoke.mjs b/policy-catalog/typescript/scripts/package-smoke.mjs deleted file mode 100644 index 070adf088..000000000 --- a/policy-catalog/typescript/scripts/package-smoke.mjs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Package-level integration smoke test (spec §12 "Integration"): packs the -// library, installs the tarball into an empty temporary project with no -// network access and no MXC SDK or executor, and exercises the public API. -import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const packageDir = fileURLToPath(new URL('..', import.meta.url)); -// Run npm through its JS entry point so no shell is involved on any platform. -const npmCli = process.env.npm_execpath; -if (!npmCli) { - throw new Error('run this script through `npm run smoke:package`'); -} -const run = (args, cwd) => execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8' }); -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-smoke-')); -try { - const packed = JSON.parse(run(['pack', '--json', '--pack-destination', work], packageDir)); - const tarball = join(work, packed[0].filename); - const files = packed[0].files.map(f => f.path); - for (const required of ['catalog/manifest.json', 'catalog/contract.v1.json', 'dist/index.js', 'dist/index.d.ts']) { - if (!files.includes(required)) throw new Error(`package is missing ${required}`); - } - if (files.some(f => f.startsWith('src/') || f.startsWith('test/'))) { - throw new Error('package unexpectedly includes sources or tests'); - } - const consumer = join(work, 'consumer'); - mkdirSync(consumer); - writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: 'consumer', private: true, type: 'module' })); - run(['install', '--offline', '--no-audit', '--no-fund', tarball], consumer); - const manifest = JSON.parse(readFileSync(join(packageDir, '..', 'catalog', 'manifest.json'), 'utf8')); - writeFileSync(join(consumer, 'smoke.mjs'), ` - import { getCatalogInfo, listCatalogEntries, resolveCatalogEntry, PolicyCatalogError } from '@mxc-prototype/policy-catalog'; - import assert from 'node:assert/strict'; - assert.equal(getCatalogInfo().catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); - assert.ok(listCatalogEntries().length > 0); - assert.equal(resolveCatalogEntry({ invocationName: 'git' }), undefined); - const r = resolveCatalogEntry({ invocationName: 'git' }, { - platform: 'linux', architecture: 'x64', allowWeakIdentityFallback: true, - projectRoot: '/p', symbols: { git_prefix: '/usr/bin' }, - }); - assert.equal(r.entryId, 'tool:git'); - assert.throws(() => resolveCatalogEntry({ invocationName: 'git' }, { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); - console.log('package smoke ok'); - `); - process.stdout.write(execFileSync(process.execPath, ['smoke.mjs'], { cwd: consumer, encoding: 'utf8' })); -} finally { - rmSync(work, { recursive: true, force: true }); -} diff --git a/policy-catalog/typescript/src/resolver.ts b/policy-catalog/typescript/src/resolver.ts deleted file mode 100644 index d705c530a..000000000 --- a/policy-catalog/typescript/src/resolver.ts +++ /dev/null @@ -1,428 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import * as os from 'node:os'; -import * as path from 'node:path'; -import { PolicyCatalogError } from './errors.js'; -import { parsePurl } from './purl.js'; -import { satisfiesVersionRange } from './version-range.js'; -import { - ARCHITECTURES, - COMPOSABLE_FILESYSTEM_FIELDS, - IDENTITY_STRENGTH, - PLATFORMS, - compositionViolation, - dependencyClosure, - findCrossClassOverlap, - pathKeySegments, - policySymbols, - selectVariant, - type AccessClass, - type CatalogEntry, - type CatalogRevision, - type IdentityPredicate, -} from './catalog.js'; -import { bundledCatalogStore, type CatalogStore } from './store.js'; -import type { - CatalogArchitecture, - CatalogEntryMetadata, - CatalogInfo, - CatalogPlatform, - CatalogSandboxPolicy, - ResolveContext, - ResolvedToolEntry, - ToolCandidate, -} from './types.js'; - -/** Host facts the resolver may use when the caller omits them. */ -export interface HostEnvironment { - platform(): CatalogPlatform; - architecture(): CatalogArchitecture; - /** Approved host-known symbols (`source: "host"` in the contract) for the current host. */ - symbol(name: string): string | undefined; -} - -function hostPlatform(): CatalogPlatform { - switch (os.platform()) { - case 'win32': - return 'windows'; - case 'darwin': - return 'macos'; - case 'linux': - return 'linux'; - default: - throw new PolicyCatalogError('unsupported-host', `host platform '${os.platform()}' has no catalog selector`); - } -} - -function hostArchitecture(): CatalogArchitecture { - // Use the OS-reported machine type rather than the Node.js build - // architecture (process.arch). Callers that know the tool's architecture - // should pass ResolveContext.architecture explicitly. - const machine = os.machine().toLowerCase(); - if (machine === 'x86_64' || machine === 'amd64' || machine === 'x64') { - return 'x64'; - } - if (machine === 'arm64' || machine === 'aarch64') { - return 'arm64'; - } - throw new PolicyCatalogError('unsupported-host', `host architecture '${machine}' has no catalog selector`); -} - -/** Default host environment backed by Node.js `os`. */ -export const nodeHostEnvironment: HostEnvironment = { - platform: hostPlatform, - architecture: hostArchitecture, - symbol(name) { - switch (name) { - case 'user_home': - return os.homedir() || undefined; - case 'temp_dir': - return os.tmpdir() || undefined; - default: - return undefined; - } - }, -}; - -interface IdentityMatch { - entry: CatalogEntry; - predicate: IdentityPredicate; - warnings: string[]; -} - -function pathApi(platform: CatalogPlatform): path.PlatformPath { - return platform === 'windows' ? path.win32 : path.posix; -} - -function normalizePath(value: string, platform: CatalogPlatform): string { - const api = pathApi(platform); - const normalized = api.normalize(value); - const root = api.parse(normalized).root; - return normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized; -} - -function validateContext(ctx: ResolveContext): void { - if (ctx.platform !== undefined && !PLATFORMS.includes(ctx.platform)) { - throw new PolicyCatalogError('invalid-context', `ResolveContext.platform '${String(ctx.platform)}' is unsupported`); - } - if (ctx.architecture !== undefined && !ARCHITECTURES.includes(ctx.architecture)) { - throw new PolicyCatalogError('invalid-context', `ResolveContext.architecture '${String(ctx.architecture)}' is unsupported`); - } -} - -function validateTool(tool: ToolCandidate): void { - if (typeof tool?.invocationName !== 'string' || tool.invocationName.length === 0) { - throw new PolicyCatalogError('invalid-context', 'ToolCandidate.invocationName must be a non-empty string'); - } - if (/[\\/]/.test(tool.invocationName)) { - throw new PolicyCatalogError('invalid-context', 'ToolCandidate.invocationName must be a bare name, not a path'); - } -} - -/** - * Read-only view over one catalog store. Resolution and inspection are - * separate operations; neither mutates catalog data or any consumer state. - */ -export class PolicyCatalog { - private readonly store: CatalogStore; - private readonly host: HostEnvironment; - - constructor(store: CatalogStore, host: HostEnvironment = nodeHostEnvironment) { - this.store = store; - this.host = host; - } - - // ------------------------------------------------------------------------- - // Setup and inspection (spec §5.2) - // ------------------------------------------------------------------------- - - getCatalogInfo(): CatalogInfo { - const revision = this.store.revision(); - return { catalogSchemaVersion: revision.catalogSchemaVersion, catalogRevision: revision.catalogRevision }; - } - - /** Metadata for every entry in the installed revision, ordered by `entryId`. Never includes a policy body. */ - listCatalogEntries(): CatalogEntryMetadata[] { - const revision = this.store.revision(); - return [...revision.entries] - .sort((a, b) => (a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0)) - .map(entry => ({ - catalogRevision: revision.catalogRevision, - entryId: entry.entryId, - entryRevision: entry.entryRevision, - displayName: entry.displayName, - identity: entry.identity.map(predicate => (predicate.kind === 'purl' - ? { kind: 'purl' as const, value: predicate.value, ...(predicate.versionRange !== undefined ? { versionRange: predicate.versionRange } : {}) } - : { kind: 'invocation-name' as const, names: [...predicate.names] })), - platformVariants: entry.platformVariants.map(variant => ({ - platform: variant.when.platform, - ...(variant.when.architecture !== undefined ? { architecture: variant.when.architecture } : {}), - dependencyEntryIds: (variant.dependencies ?? []).map(dependency => dependency.entryId), - sandboxPolicyVersion: variant.sandboxPolicy.version, - })), - provenance: { ...entry.provenance }, - })); - } - - // ------------------------------------------------------------------------- - // Runtime lookup (spec §5.1) - // ------------------------------------------------------------------------- - - /** - * Resolves one tool's candidate minimum requirement. - * - * Returns `undefined` when there is no acceptable identity match, no - * applicable platform variant, or a required symbol is unresolved. Throws - * {@link PolicyCatalogError} for library failures (integrity, unavailable - * revision, invalid context, composition conflict), which are never - * reported as "no match". - * - * The result is a candidate lower bound, not authorization: consumers apply - * their own authorization, ceilings, persistence, composition, and audit. - */ - resolveCatalogEntry(tool: ToolCandidate, ctx: ResolveContext = {}): ResolvedToolEntry | undefined { - validateTool(tool); - validateContext(ctx); - this.validateContextSymbols(ctx); - const revision = this.store.revision(ctx.catalogRevision); - - const platform = ctx.platform ?? this.host.platform(); - const architecture = ctx.architecture ?? this.host.architecture(); - - const match = this.matchIdentity(revision, tool, ctx.allowWeakIdentityFallback === true, platform); - if (!match) { - return undefined; - } - - const selected = selectVariant(match.entry, platform, architecture); - if (!selected) { - return undefined; - } - - const byId = new Map(revision.entries.map(entry => [entry.entryId, entry])); - const closure = dependencyClosure(match.entry, selected.variant, byId, platform, architecture); - if (!closure.ok) { - // A validated revision cannot reach this; treat it as corrupt data. - throw new PolicyCatalogError('validation', `dependency resolution failed: ${closure.reason} (${closure.detail})`); - } - const violation = compositionViolation(closure.nodes); - if (violation) { - throw new PolicyCatalogError('validation', `composition rejected: ${violation}`); - } - - const symbols = this.resolveSymbols(closure.nodes.flatMap(node => policySymbols(node.variant.sandboxPolicy)), ctx, platform); - if (!symbols) { - return undefined; - } - - const policy = this.composePolicy(closure.nodes.map(node => node.variant.sandboxPolicy), symbols, platform); - - return { - entryId: match.entry.entryId, - entryRevision: match.entry.entryRevision, - catalogRevision: revision.catalogRevision, - matchedIdentity: { kind: match.predicate.kind, strength: IDENTITY_STRENGTH[match.predicate.kind] }, - resolvedDependencies: closure.nodes.slice(1).map(node => ({ - entryId: node.entry.entryId, - entryRevision: node.entry.entryRevision, - ...(node.requiredVersionRange !== undefined ? { requiredVersionRange: node.requiredVersionRange } : {}), - })), - policy, - warnings: match.warnings, - }; - } - - private matchIdentity( - revision: CatalogRevision, - tool: ToolCandidate, - allowWeak: boolean, - platform: CatalogPlatform, - ): IdentityMatch | undefined { - const warnings: string[] = []; - let strong: IdentityMatch | undefined; - - if (tool.packageUrl !== undefined) { - const parsed = parsePurl(tool.packageUrl); - if (!parsed) { - throw new PolicyCatalogError('invalid-context', `ToolCandidate.packageUrl '${tool.packageUrl}' is not a valid package URL`); - } - for (const entry of revision.entries) { - const predicate = entry.identity.find(p => p.kind === 'purl' && parsePurl(p.value)?.key === parsed.key); - if (predicate) { - strong = { entry, predicate, warnings }; - break; - } - } - if (strong && strong.predicate.kind === 'purl' && strong.predicate.versionRange !== undefined) { - const evidence = tool.detectedVersion ?? parsed.version; - if (evidence !== undefined) { - const satisfied = satisfiesVersionRange(evidence, strong.predicate.versionRange); - if (satisfied === false) { - warnings.push(`detected version '${evidence}' is outside the reviewed range '${strong.predicate.versionRange}' for ${strong.entry.entryId}`); - } else if (satisfied === undefined) { - warnings.push(`detected version '${evidence}' could not be compared with the reviewed range '${strong.predicate.versionRange}' for ${strong.entry.entryId}`); - } - } - } - } - - const weakEntry = this.matchInvocationName(revision, tool.invocationName, platform); - if (strong) { - if (weakEntry && weakEntry.entry.entryId !== strong.entry.entryId) { - warnings.push(`invocation name '${tool.invocationName}' is listed by ${weakEntry.entry.entryId}; the package URL match ${strong.entry.entryId} takes precedence`); - } - return strong; - } - if (!allowWeak || !weakEntry) { - return undefined; - } - if (tool.packageUrl !== undefined) { - warnings.push(`package URL '${tool.packageUrl}' matched no entry; fell back to invocation-name identity`); - } - warnings.push(`${weakEntry.entry.entryId} matched only by invocation name '${tool.invocationName}' (weak identity)`); - return { ...weakEntry, warnings }; - } - - private matchInvocationName( - revision: CatalogRevision, - name: string, - platform: CatalogPlatform, - ): Omit | undefined { - // Windows and default macOS volumes resolve executables case-insensitively; - // Linux does not. Validation guarantees case-insensitive uniqueness across - // entries, so either comparison selects at most one entry. - const fold = platform === 'linux' ? (value: string) => value : (value: string) => value.toLowerCase(); - const key = fold(name); - for (const entry of revision.entries) { - const predicate = entry.identity.find(p => p.kind === 'invocation-name' && p.names.some(n => fold(n) === key)); - if (predicate) { - return { entry, predicate }; - } - } - return undefined; - } - - /** Caller symbol names are checked before matching so an invalid context is never reported as no-match. */ - private validateContextSymbols(ctx: ResolveContext): void { - const contract = this.store.contract; - if (ctx.symbols !== undefined) { - for (const name of Object.keys(ctx.symbols)) { - const definition = contract.symbols[name]; - if (!definition) { - throw new PolicyCatalogError('invalid-context', `ResolveContext.symbols.${name} is not a catalog symbol`); - } - if (definition.source === 'context') { - throw new PolicyCatalogError('invalid-context', `symbol '${name}' is supplied through ResolveContext.projectRoot, not symbols`); - } - } - } - } - - private resolveSymbols(names: string[], ctx: ResolveContext, platform: CatalogPlatform): Map | undefined { - const contract = this.store.contract; - const api = pathApi(platform); - const values = new Map(); - for (const name of new Set(names)) { - const definition = contract.symbols[name]; - let value: string | undefined; - if (definition.source === 'context') { - value = ctx.projectRoot; - } else { - value = ctx.symbols?.[name]; - // Host-derived symbols are only meaningful for the current host; they - // are never derived for an explicitly different target platform. - if (value === undefined && definition.source === 'host' && platform === this.host.platform()) { - value = this.host.symbol(name); - } - } - if (value === undefined) { - return undefined; - } - if (value.includes('${') || !api.isAbsolute(value)) { - throw new PolicyCatalogError('invalid-context', `symbol '${name}' must resolve to an absolute ${platform} path`); - } - values.set(name, value); - } - return values; - } - - private composePolicy( - policies: CatalogSandboxPolicy[], - symbols: ReadonlyMap, - platform: CatalogPlatform, - ): CatalogSandboxPolicy { - const classes: Partial> = {}; - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - const seen = new Set(); - const out: string[] = []; - for (const policy of policies) { - for (const template of policy.filesystem?.[field] ?? []) { - const resolved = normalizePath( - template.replace(/\$\{([a-z][a-z0-9_]*)\}/g, (_m, name: string) => symbols.get(name)!), - platform, - ); - const key = pathKeySegments(resolved, platform).join('\u0000'); - if (!seen.has(key)) { - seen.add(key); - out.push(resolved); - } - } - } - if (out.length > 0) { - classes[field] = out; - } - } - const overlap = findCrossClassOverlap(classes, platform); - if (overlap) { - // Symbol values can make distinct templates collide. The resolver never - // chooses an access class implicitly. - throw new PolicyCatalogError('composition-conflict', `resolved paths overlap across access classes: ${overlap}`); - } - - const root = policies[0]; - const result: CatalogSandboxPolicy = { version: root.version }; - if (root.filesystem !== undefined || Object.keys(classes).length > 0) { - result.filesystem = {}; - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - if (classes[field]) { - result.filesystem[field] = classes[field]; - } - } - } - // Composition validation guarantees these fields only exist on a - // single-entry closure, so they are copied, never merged. - if (root.network !== undefined) { - result.network = structuredClone(root.network); - } - if (root.ui !== undefined) { - result.ui = structuredClone(root.ui); - } - if (root.timeoutMs !== undefined) { - result.timeoutMs = root.timeoutMs; - } - return result; - } -} - -let defaultCatalog: PolicyCatalog | undefined; - -function bundled(): PolicyCatalog { - defaultCatalog ??= new PolicyCatalog(bundledCatalogStore()); - return defaultCatalog; -} - -/** Resolves one tool against the bundled catalog. See {@link PolicyCatalog.resolveCatalogEntry}. */ -export function resolveCatalogEntry(tool: ToolCandidate, ctx?: ResolveContext): ResolvedToolEntry | undefined { - return bundled().resolveCatalogEntry(tool, ctx); -} - -/** Lists bundled catalog entry metadata. See {@link PolicyCatalog.listCatalogEntries}. */ -export function listCatalogEntries(): CatalogEntryMetadata[] { - return bundled().listCatalogEntries(); -} - -/** Reports the bundled catalog schema version and revision. */ -export function getCatalogInfo(): CatalogInfo { - return bundled().getCatalogInfo(); -} diff --git a/policy-catalog/typescript/test/conformance.test.ts b/policy-catalog/typescript/test/conformance.test.ts deleted file mode 100644 index fccf70c60..000000000 --- a/policy-catalog/typescript/test/conformance.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Runs the language-neutral conformance fixtures (conformance/fixtures/*.json). -// Other language bindings run the same files with the same expectations. -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { bundledCatalog, catalogFor, errorCategory, fixtureFiles, fixturesDir, readJson } from './helpers.js'; - -for (const name of fixtureFiles()) { - const fixture = readJson(new URL(name, fixturesDir)); - describe(`conformance: ${name}`, () => { - const catalog = fixture.catalog === 'bundled' ? bundledCatalog() : catalogFor(fixture.catalog); - for (const testCase of fixture.cases) { - it(testCase.name, () => { - if (testCase.expectError !== undefined) { - assert.equal(errorCategory(() => catalog.resolveCatalogEntry(testCase.tool, testCase.context)), testCase.expectError); - return; - } - const actual = catalog.resolveCatalogEntry(testCase.tool, testCase.context); - if (testCase.expect === null) { - assert.equal(actual, undefined); - } else { - assert.deepEqual(actual, testCase.expect); - } - }); - } - }); -} diff --git a/policy-catalog/typescript/test/resolver.test.ts b/policy-catalog/typescript/test/resolver.test.ts deleted file mode 100644 index a8eea0fb2..000000000 --- a/policy-catalog/typescript/test/resolver.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { - PolicyCatalog, - getCatalogInfo, - listCatalogEntries, - resolveCatalogEntry, -} from '../dist/index.js'; -import { bundledCatalog, catalogFor, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; - -describe('runtime lookup', () => { - it('omitted context uses host platform/architecture, installed revision, and no weak fallback', () => { - const host = fixedHost('windows', 'arm64'); - const catalog = catalogFor(revisionWith([ - entry('tool:a', { - identity: [{ kind: 'purl', value: 'pkg:npm/a' }, { kind: 'invocation-name', names: ['a'] }], - platformVariants: [ - { when: { platform: 'windows', architecture: 'x64' }, sandboxPolicy: { version: '0.9.0-alpha', timeoutMs: 1 } }, - { when: { platform: 'windows', architecture: 'arm64' }, sandboxPolicy: { version: '0.9.0-alpha', timeoutMs: 2 } }, - ], - }), - ]), host); - assert.equal(catalog.resolveCatalogEntry({ invocationName: 'a' }), undefined, 'weak identity requires opt-in'); - const result = catalog.resolveCatalogEntry({ invocationName: 'a', packageUrl: 'pkg:npm/a' }); - assert.equal(result?.policy.timeoutMs, 2, 'host arm64 selected'); - assert.equal(result?.catalogRevision, '2000-01-01.1'); - const explicit = catalog.resolveCatalogEntry({ invocationName: 'a', packageUrl: 'pkg:npm/a' }, { architecture: 'x64' }); - assert.equal(explicit?.policy.timeoutMs, 1, 'explicit architecture wins'); - }); - - it('never fabricates projectRoot or caller symbols', () => { - const catalog = bundledCatalog(); - assert.equal(catalog.resolveCatalogEntry({ invocationName: 'git' }, { allowWeakIdentityFallback: true }), undefined); - }); - - it('derives host symbols only for the current host platform, and caller values override them', () => { - const revision = revisionWith([ - entry('tool:t', { - platformVariants: [ - { when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, - { when: { platform: 'macos' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, - ], - }), - ]); - const catalog = catalogFor(revision, fixedHost('linux', 'x64', { user_home: '/home/me' })); - const weak = { allowWeakIdentityFallback: true }; - assert.deepEqual(catalog.resolveCatalogEntry({ invocationName: 't' }, weak)?.policy.filesystem, { readonlyPaths: ['/home/me/.cfg'] }); - assert.equal(catalog.resolveCatalogEntry({ invocationName: 't' }, { ...weak, platform: 'macos' }), undefined); - assert.deepEqual( - catalog.resolveCatalogEntry({ invocationName: 't' }, { ...weak, symbols: { user_home: '/srv/u' } })?.policy.filesystem, - { readonlyPaths: ['/srv/u/.cfg'] }, - ); - }); - - it('rejects invalid context and inputs as library failures, not no-match', () => { - const catalog = bundledCatalog(); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: '' })), 'invalid-context'); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: '/usr/bin/git' })), 'invalid-context'); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'git' }, { platform: 'plan9' as any })), 'invalid-context'); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'git' }, { symbols: { nope: '/x' } })), 'invalid-context'); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'git' }, { symbols: { project_root: '/x' } })), 'invalid-context'); - assert.equal(errorCategory(() => catalog.resolveCatalogEntry({ invocationName: 'npm', packageUrl: 'npm' })), 'invalid-context'); - }); - - it('host detection failure is a library error, not a guessed selection', () => { - const host = { ...fixedHost(), architecture: (): never => { throw new Error('unknown machine'); } }; - const catalog = new PolicyCatalog(storeFor([revisionWith([entry('tool:t')])]), host); - assert.throws(() => catalog.resolveCatalogEntry({ invocationName: 't' }, { allowWeakIdentityFallback: true }), /unknown machine/); - }); - - it('is deterministic and returns caller-owned copies', () => { - const catalog = bundledCatalog(); - const ctx = { - platform: 'linux' as const, - architecture: 'x64' as const, - projectRoot: '/p', - symbols: { npm_prefix: '/n', npm_cache: '/c', node_prefix: '/n' }, - }; - const tool = { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }; - const first = catalog.resolveCatalogEntry(tool, ctx)!; - first.policy.filesystem!.readwritePaths!.push('/mutated'); - first.warnings.push('mutated'); - const second = catalog.resolveCatalogEntry(tool, ctx)!; - assert.deepEqual(second.policy.filesystem, { readonlyPaths: ['/n'], readwritePaths: ['/p', '/c'] }); - assert.deepEqual(second.warnings, []); - }); - - it('de-duplicates normalized paths within an access class using platform path rules', () => { - const catalog = catalogFor(revisionWith([ - entry('tool:w', { - platformVariants: [{ - when: { platform: 'windows' }, - sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}\\'] } }, - }], - }), - ])); - const result = catalog.resolveCatalogEntry( - { invocationName: 'w' }, - { platform: 'windows', architecture: 'x64', allowWeakIdentityFallback: true, symbols: { git_prefix: 'C:\\Tools', node_prefix: 'c:\\tools' } }, - ); - assert.deepEqual(result?.policy.filesystem, { readonlyPaths: ['C:\\Tools'] }); - }); - - it('dependency chain resolution composes each entry once, in declaration order', () => { - const variant = (deps: string[], path: string) => ({ - when: { platform: 'linux' }, - ...(deps.length ? { dependencies: deps.map(entryId => ({ entryId })) } : {}), - sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${project_root}/${path}`] } }, - }); - const catalog = catalogFor(revisionWith([ - entry('tool:top', { platformVariants: [variant(['tool:left', 'tool:right'], 'top')] }), - entry('tool:left', { platformVariants: [variant(['tool:leaf'], 'left')] }), - entry('tool:right', { platformVariants: [variant(['tool:leaf'], 'right')] }), - entry('tool:leaf', { platformVariants: [variant([], 'leaf')] }), - ])); - const result = catalog.resolveCatalogEntry({ invocationName: 'top' }, { allowWeakIdentityFallback: true, projectRoot: '/r' })!; - assert.deepEqual(result.resolvedDependencies.map(d => d.entryId), ['tool:left', 'tool:leaf', 'tool:right']); - assert.deepEqual(result.policy.filesystem?.readonlyPaths, ['/r/top', '/r/left', '/r/leaf', '/r/right']); - }); - - it('dependency without a variant for the host makes the entry invalid at validation time', () => { - const store = storeFor([revisionWith([ - entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:b' }], sandboxPolicy: { version: '0.9.0-alpha' } }] }), - entry('tool:b', { platformVariants: [{ when: { platform: 'windows' }, sandboxPolicy: { version: '0.9.0-alpha' } }] }), - ])]); - assert.throws(() => store.revision(), /unsupported-dependency/); - assert.equal(errorCategory(() => store.revision()), 'validation'); - }); -}); - -describe('setup and inspection', () => { - it('reports the bundled revision and matches the manifest default', () => { - assert.deepEqual(getCatalogInfo(), { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' }); - }); - - it('lists metadata ordered by entryId without any policy body', () => { - const entries = listCatalogEntries(); - assert.deepEqual(entries.map(e => e.entryId), ['tool:git', 'tool:node', 'tool:npm']); - const npm = entries.find(e => e.entryId === 'tool:npm')!; - assert.deepEqual(npm.platformVariants[0], { - platform: 'windows', - dependencyEntryIds: ['tool:node'], - sandboxPolicyVersion: '0.9.0-alpha', - }); - assert.ok(!JSON.stringify(entries).includes('readwritePaths')); - assert.ok(!JSON.stringify(entries).includes('${')); - }); - - it('module-level resolveCatalogEntry uses the bundled catalog', () => { - assert.equal(resolveCatalogEntry({ invocationName: 'definitely-unknown-tool' }, { allowWeakIdentityFallback: true }), undefined); - }); -}); diff --git a/policy-catalog/typescript/tsconfig.test.json b/policy-catalog/typescript/tsconfig.test.json deleted file mode 100644 index f9eff10b4..000000000 --- a/policy-catalog/typescript/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist-test", - "rootDir": "./test", - "declaration": false, - "sourceMap": false - }, - "include": ["test/**/*.ts"] -} From 149759298cc20833cd57aa2e706747a745ad5357 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 23:28:48 -0700 Subject: [PATCH 07/50] docs(policy-catalog): make docs/design.md a verbatim copy of the proposal Replace the link-rewritten copy with a byte-identical copy of the uncommitted docs/mxc-policy-store.md (base 34537f8 plus edits as of 2026-09-29), under a short provenance header that lists absolute URLs for the four MXC-relative links. The extractability check now requires each relative link in the verbatim body to have an absolute equivalent in that header instead of rewriting it, and no longer runs test:functional twice (check already includes it). --- policy-catalog/docs/design.md | 33 +++++++++++++------- policy-catalog/scripts/check-extractable.mjs | 23 ++++++++++++-- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/policy-catalog/docs/design.md b/policy-catalog/docs/design.md index 81cc6f2b5..7a7a10634 100644 --- a/policy-catalog/docs/design.md +++ b/policy-catalog/docs/design.md @@ -1,10 +1,21 @@ - + # Feature Spec: Known-tool Policy Floors **Status:** Proposed public preview catalog. This is not an approved, shipped, or @@ -39,8 +50,8 @@ revision, and inspection behavior. It reuses #779's model where possible and calls out differences directly. This document does not restate general MXC sandboxing concepts already covered -by [`docs/sandbox-policy/0.8.0/policy.md`](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/policy.md) or -[`docs/versioning.md`](https://github.com/microsoft/mxc/blob/main/docs/versioning.md). It covers only what a policy store adds. +by [`docs/sandbox-policy/0.8.0/policy.md`](sandbox-policy/0.8.0/policy.md) or +[`docs/versioning.md`](versioning.md). It covers only what a policy store adds. ### Non-goals @@ -59,7 +70,7 @@ by [`docs/sandbox-policy/0.8.0/policy.md`](https://github.com/microsoft/mxc/blob ### MXC feature impact and defaults This is a standalone catalog and library proposal. Following the feature-impact -checklist in [`docs/authoring-a-new-feature.md`](https://github.com/microsoft/mxc/blob/main/docs/authoring-a-new-feature.md): +checklist in [`docs/authoring-a-new-feature.md`](authoring-a-new-feature.md): - **Policy changes:** None. Catalog entries embed an existing, registered `SandboxPolicy`. @@ -656,7 +667,7 @@ not a parallel long-term policy platform. MXC's learning-mode capabilities (`learningModeLogging`, `permissiveLearningMode`, `captureDenials`; see -[`docs/learning-mode/capabilities.md`](https://github.com/microsoft/mxc/blob/main/docs/learning-mode/capabilities.md)) are the +[`docs/learning-mode/capabilities.md`](learning-mode/capabilities.md)) are the substrate a contributor can use to observe what a tool actually touches, the same way [#779 §5.1](https://github.com/microsoft/mxc/pull/779) describes for config floors. That observation workflow is unchanged by this document and @@ -803,7 +814,7 @@ Recommended answers are proposals for review, not decisions. - [`ChazGo/mxc#1`](https://github.com/ChazGo/mxc/pull/1) - draft SDK resolver and catalog prototype exercising lookup, dependency closure, and symbol resolution against an earlier version of this shape. -- [`docs/sandbox-policy/0.8.0/policy.md`](https://github.com/microsoft/mxc/blob/main/docs/sandbox-policy/0.8.0/policy.md) - +- [`docs/sandbox-policy/0.8.0/policy.md`](sandbox-policy/0.8.0/policy.md) - the `SandboxPolicy` contract every catalog entry embeds. -- [`docs/versioning.md`](https://github.com/microsoft/mxc/blob/main/docs/versioning.md) - the versioning model +- [`docs/versioning.md`](versioning.md) - the versioning model [§4.1](#41-versions) builds on. diff --git a/policy-catalog/scripts/check-extractable.mjs b/policy-catalog/scripts/check-extractable.mjs index 9ec7f8d62..687c2c641 100644 --- a/policy-catalog/scripts/check-extractable.mjs +++ b/policy-catalog/scripts/check-extractable.mjs @@ -49,11 +49,29 @@ try { // Relative Markdown links must resolve inside the extracted tree. const brokenLinks = []; + // docs/design.md is a verbatim copy of the MXC proposal. Its relative links + // point into the MXC repository and are deliberately not rewritten; each + // one must instead have an absolute equivalent listed in the file's header. + const VERBATIM = 'docs/design.md'; + const VERBATIM_MARKER = ''; for (const file of git(['ls-files', '*.md'], target).split('\n').filter(Boolean)) { - const text = readFileSync(join(target, file), 'utf8').replace(/```[\s\S]*?```/g, ''); + const raw = readFileSync(join(target, file), 'utf8'); + const markerAt = file === VERBATIM ? raw.indexOf(VERBATIM_MARKER) : -1; + if (file === VERBATIM && markerAt < 0) { + brokenLinks.push(`${file}: missing '${VERBATIM_MARKER}'`); + continue; + } + const header = markerAt >= 0 ? raw.slice(0, markerAt) : ''; + const text = raw.replace(/```[\s\S]*?```/g, ''); for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) { const link = match[1].split('#')[0]; if (!link || /^[a-z][a-z0-9+.-]*:/i.test(link)) continue; + if (markerAt >= 0) { + if (!header.includes(`- ${link} -> https://`)) { + brokenLinks.push(`${file}: MXC-relative link '${match[1]}' has no absolute equivalent in the header`); + } + continue; + } const resolved = resolve(dirname(join(target, file)), link); if (relative(target, resolved).startsWith('..')) { brokenLinks.push(`${file}: '${match[1]}' leaves the repository`); @@ -76,8 +94,9 @@ try { if (!npmCli) throw new Error('run this script through `npm run check:extract`'); const npm = args => execFileSync(process.execPath, [npmCli, ...args], { cwd: target, stdio: 'inherit' }); npm(['ci', '--no-audit', '--no-fund']); + // `check` includes typecheck, unit, functional (from the packed tarball), + // validation, pack contents, and the install smoke test. npm(['run', 'check']); - npm(['run', 'test:functional']); console.log('\nExtractability check OK: the directory builds, tests, validates, and packs as a standalone repository.'); } } finally { From 7fb006c5f9dac59b50870b3866e2c1453540e89f Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 23:29:06 -0700 Subject: [PATCH 08/50] feat(policy-catalog)!: align the CLI to resolve | inspect | validate and test the installed tarball CLI: 'inspect' replaces 'info' and 'list'; 'validate' replaces 'verify' and adds --base-ref for the published-revision immutability check. The old names are usage errors (one canonical flow). src/validate.ts holds the directory validation and base-ref check shared by the CLI and scripts/validate-catalog.mjs (which accepts --base-ref= or POLICY_CATALOG_BASE_REF). An unknown or option-like ref fails instead of passing silently. Tests: tests/unit and tests/functional each have their own tsconfig (compiled into /dist) and a run-tests.js using node --test --test-reporter spec --test-force-exit, mirroring MXC's sdk/node/tests/integration, behind the check-tests-present guard. The functional runner npm-packs the package, installs the tarball offline into a temp consumer outside the repo, and the tests load the library and bin from that install only. Docs, the smoke and pack scripts, and both workflows use the new command names. --- .github/workflows/PolicyCatalog.Prototype.yml | 3 +- policy-catalog/.github/workflows/ci.yml | 6 +- policy-catalog/.gitignore | 1 - policy-catalog/README.md | 28 +- policy-catalog/docs/architecture.md | 3 +- policy-catalog/docs/contributing.md | 35 ++- policy-catalog/package.json | 8 +- policy-catalog/scripts/check-pack.mjs | 3 +- policy-catalog/scripts/clean.mjs | 2 +- policy-catalog/scripts/package-smoke.mjs | 11 +- policy-catalog/scripts/validate-catalog.mjs | 38 +-- policy-catalog/src/cli.ts | 63 +++-- policy-catalog/src/tooling.ts | 6 + policy-catalog/src/validate.ts | 154 ++++++++++ policy-catalog/tests/functional/cli.test.ts | 265 ++++++------------ policy-catalog/tests/functional/helpers.ts | 150 ++++++++++ policy-catalog/tests/functional/run-tests.js | 69 +++++ policy-catalog/tests/functional/tsconfig.json | 7 +- policy-catalog/tests/unit/run-tests.js | 31 ++ policy-catalog/tests/unit/tsconfig.json | 7 +- 20 files changed, 621 insertions(+), 269 deletions(-) create mode 100644 policy-catalog/src/validate.ts create mode 100644 policy-catalog/tests/functional/helpers.ts create mode 100644 policy-catalog/tests/functional/run-tests.js create mode 100644 policy-catalog/tests/unit/run-tests.js diff --git a/.github/workflows/PolicyCatalog.Prototype.yml b/.github/workflows/PolicyCatalog.Prototype.yml index 178cbeb72..4f60d6512 100644 --- a/.github/workflows/PolicyCatalog.Prototype.yml +++ b/.github/workflows/PolicyCatalog.Prototype.yml @@ -48,9 +48,10 @@ jobs: - name: Published-revision immutability if: github.event_name == 'pull_request' shell: bash + # Passed through env, never interpolated into the script. env: POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} - run: npm run validate + run: npm run validate -- "--base-ref=$POLICY_CATALOG_BASE_REF" - name: Extractability (policy-catalog/ as a standalone repository) if: matrix.os_label == 'linux' diff --git a/policy-catalog/.github/workflows/ci.yml b/policy-catalog/.github/workflows/ci.yml index a64485cbe..3ceadf9a6 100644 --- a/policy-catalog/.github/workflows/ci.yml +++ b/policy-catalog/.github/workflows/ci.yml @@ -37,15 +37,17 @@ jobs: - run: npm ci - # typecheck, unit, functional, catalog validation, pack contents, install smoke + # typecheck, unit, functional (from the packed tarball), catalog validation, + # pack contents, install smoke - run: npm run check - name: Published-revision immutability if: github.event_name == 'pull_request' shell: bash + # Passed through env, never interpolated into the script. env: POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} - run: npm run validate + run: npm run validate -- "--base-ref=$POLICY_CATALOG_BASE_REF" - name: Extractability (HEAD tree as a standalone repository) if: matrix.os_label == 'linux' diff --git a/policy-catalog/.gitignore b/policy-catalog/.gitignore index e428b13a7..1ab415f57 100644 --- a/policy-catalog/.gitignore +++ b/policy-catalog/.gitignore @@ -1,4 +1,3 @@ node_modules/ dist/ -dist-tests/ *.tgz diff --git a/policy-catalog/README.md b/policy-catalog/README.md index fc9c31046..fa81773cd 100644 --- a/policy-catalog/README.md +++ b/policy-catalog/README.md @@ -22,7 +22,7 @@ git filter-repo --subdirectory-filter policy-catalog | Path | Contents | |---|---| -| `docs/design.md` | Copy of the design proposal (authoritative). | +| `docs/design.md` | Verbatim copy of the design proposal (authoritative). Its relative links point into the MXC repository; the header lists absolute equivalents. | | `docs/architecture.md` | How this prototype implements the design. | | `docs/contributing.md` | Catalog and library contribution flow, validation rules, and test layout. | | `catalog/contract.v1.json` | Catalog contract v1: registered `SandboxPolicy` versions and the symbol vocabulary. | @@ -31,8 +31,8 @@ git filter-repo --subdirectory-filter policy-catalog | `schema/` | JSON Schemas for revisions and the manifest. | | `conformance/fixtures/` | Language-neutral conformance cases that every language binding must pass. | | `src/` | TypeScript/JavaScript library and the `policy-catalog` CLI. | -| `tests/unit/` | Unit and conformance tests (`node:test`). | -| `tests/functional/` | End-to-end tests that drive the compiled CLI against the real catalog and tampered copies. | +| `tests/unit/` | Unit and conformance tests (`node:test`), with their own `tsconfig.json` and `run-tests.js`. | +| `tests/functional/` | End-to-end tests against the packed and installed tarball: the installed CLI and library, the real catalog, and tampered or synthetic catalogs. Own `tsconfig.json` and `run-tests.js`. | | `scripts/` | Validation, digest, package, and extractability checks. | | `.github/workflows/ci.yml` | CI for the standalone repository. | @@ -64,11 +64,23 @@ getCatalogInfo(); // { catalogSchemaVersion: '1', catalogRevision: '2026-09 listCatalogEntries(); // metadata only, never policy bodies ``` -The same operations are available from the command line: +The same operations are available from the command line. The CLI has +exactly three commands: + +| Command | Purpose | +|---|---| +| `policy-catalog resolve [options] ...` | `getSandboxConfig`, or `getSandboxConfigWithDiagnostics` with `--diagnostics`. Options: `--platform`, `--architecture`, `--revision`, `--project-root`, `--symbol name=value`, `--allow-weak`, and `--purl` / `--detected-version` for the next tool. | +| `policy-catalog inspect` | `getCatalogInfo()` and `listCatalogEntries()`: metadata only, never a policy body. | +| `policy-catalog validate [--base-ref REF]` | Integrity, the catalog contract (including dependency cycles), and entry-revision history. With `--base-ref`, also checks that every revision published at `REF` is unchanged. | + +Every command accepts `--catalog DIR` to use a catalog directory instead of the +bundled one. Output is JSON on stdout. Exit codes: `0` success (including "no +policy"), `1` library failure or failed validation, `2` usage error. ```text npx policy-catalog resolve --diagnostics --allow-weak --project-root /work/app \ --symbol git_prefix=/usr/bin git +npx policy-catalog validate --catalog ./catalog --base-ref origin/main ``` ### Results @@ -106,10 +118,10 @@ Requires Node.js 24 or later. ```text npm ci -npm run check # typecheck, unit, functional, catalog validation, pack contents, install smoke +npm run check # typecheck, unit, functional (installed tarball), catalog validation, pack contents, install smoke npm run test:unit -npm run test:functional -npm run validate # add POLICY_CATALOG_BASE_REF= to enforce published-revision immutability +npm run test:functional # packs, installs the tarball in a temp project, and tests the installed CLI and library +npm run validate # add -- --base-ref= (or POLICY_CATALOG_BASE_REF=) to enforce published-revision immutability npm run check:pack npm run check:extract # build and test HEAD's copy of this directory as a standalone repository ``` @@ -131,7 +143,7 @@ See [docs/contributing.md](docs/contributing.md) before changing catalog data. - v1 filesystem-only composition across inputs and dependencies - the policy-only and diagnostics APIs (single tool or array) - metadata inspection -- the CLI harness +- the CLI harness (`resolve | inspect | validate`) - the contribution validation pipeline - package inclusion and offline install checks - the extractability check diff --git a/policy-catalog/docs/architecture.md b/policy-catalog/docs/architecture.md index 0b0796f3d..c8951125b 100644 --- a/policy-catalog/docs/architecture.md +++ b/policy-catalog/docs/architecture.md @@ -35,7 +35,8 @@ layers and ceilings, approval, audit, and the final sandbox creation. | `src/host.ts` | Host platform, native system architecture, and approved host symbols. | | `src/resolver.ts` | `getSandboxConfig`, `getSandboxConfigWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries`. | | `src/history.ts` | `entryRevision` monotonicity and published-revision immutability checks. | -| `src/cli.ts` | `policy-catalog` command-line harness over the public API (functional tests, CI, contributors). | +| `src/validate.ts` | Catalog-directory validation and the `--base-ref` immutability check, shared by `policy-catalog validate` and `scripts/validate-catalog.mjs`. Tooling only; the runtime lookup path never runs git. | +| `src/cli.ts` | `policy-catalog resolve \| inspect \| validate` command-line harness over the public API (functional tests, CI, contributors). | | `src/tooling.ts` | `./tooling` subpath exports for validation scripts and tests. It is not part of the runtime API. | ## Resolution pipeline diff --git a/policy-catalog/docs/contributing.md b/policy-catalog/docs/contributing.md index c585ca345..6885faa05 100644 --- a/policy-catalog/docs/contributing.md +++ b/policy-catalog/docs/contributing.md @@ -31,7 +31,8 @@ includes corrections and security fixes: publish a new revision instead. ```text npm run check - POLICY_CATALOG_BASE_REF=origin/main npm run validate + npm run validate -- --base-ref=origin/main + npx policy-catalog validate --base-ref origin/main # same check through the CLI ``` ### Entry requirements @@ -70,7 +71,7 @@ enforce this; the repository's branch rules and CODEOWNERS must. - unsupported-field rejection - v1 composition limits within every dependency closure - `entryRevision` monotonicity across consecutive revisions -- published-revision immutability against `POLICY_CATALOG_BASE_REF` +- published-revision immutability against `--base-ref=` (or `POLICY_CATALOG_BASE_REF`); an unknown ref fails - deterministic, file-order-independent resolution for every selector - package inclusion - fixture coverage @@ -84,22 +85,36 @@ Changes to the library API or behavior must keep the shared contract intact: ([design §6.2](design.md#62-cross-language-consistency-and-support)). - Failure categories and absence semantics are part of the contract. Changing them is a breaking change. -- Run `npm run check`, `npm run test:functional`, and - `npm run check:extract -- --worktree` before opening a pull request. +- Run `npm run check` and `npm run check:extract -- --worktree` before + opening a pull request. ## Test layout | Suite | Location | Command | |---|---|---| | Unit and conformance | `tests/unit/*.test.ts` | `npm run test:unit` | -| Functional (CLI end-to-end) | `tests/functional/*.test.ts` | `npm run test:functional` | +| Functional (installed package, end to end) | `tests/functional/*.test.ts` | `npm run test:functional` | | Catalog validation | `scripts/validate-catalog.mjs` | `npm run validate` | | Package contents | `scripts/check-pack.mjs` | `npm run check:pack` | | Offline install smoke test | `scripts/package-smoke.mjs` | `npm run smoke:package` | | Standalone extraction | `scripts/check-extractable.mjs` | `npm run check:extract` | -Tests use Node's built-in `node:test` runner with the spec reporter, compiled -by `tsc` into `dist-tests/`. This mirrors the layout of the MXC TypeScript SDK -(`sdk/node/tests/unit`, `sdk/node/tests/integration`). Unit tests import the -package through its own name, so they exercise the same compiled `dist/` -output that ships. +The layout mirrors MXC's `sdk/node/tests/integration`. Each suite directory +has its own `tsconfig.json` that compiles `*.test.ts` into that directory's +`dist/`, and a `run-tests.js` that runs the compiled `*.test.js` files with +`node --test --test-reporter spec --test-force-exit`. Before either runner, +`scripts/check-tests-present.mjs` (after MXC's +`scripts/versioning/check-tests-present.js`) fails the run when the compiled +directory holds no test files. + +- Unit tests import the package by its own name, so they exercise the + compiled `dist/` output that ships. +- Functional tests never use the repository's `dist/` or `catalog/`. + `tests/functional/run-tests.js` runs `npm pack`, installs the tarball + offline into a new consumer project in a temporary directory outside the + repository, and passes that directory to the tests. The tests load the + library through the installed package's `exports` and run the installed + `bin`, in a separate process. `helpers.ts` refuses to run if the + installed package resolves inside the source tree. +- Functional tests that need a defective catalog write a synthetic or + tampered copy into a temporary directory and pass it with `--catalog`. diff --git a/policy-catalog/package.json b/policy-catalog/package.json index 8a1ad58bf..505efc30a 100644 --- a/policy-catalog/package.json +++ b/policy-catalog/package.json @@ -28,13 +28,13 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "build:test-unit": "cd tests/unit && tsc -p tsconfig.json", - "build:test-functional": "cd tests/functional && tsc -p tsconfig.json", + "build:test-unit": "tsc -p tests/unit/tsconfig.json", + "build:test-functional": "tsc -p tests/functional/tsconfig.json", "clean": "node scripts/clean.mjs", "typecheck": "npm run build && npm run build:test-unit && npm run build:test-functional", "test": "npm run test:unit", - "test:unit": "npm run build && npm run build:test-unit && node scripts/check-tests-present.mjs dist-tests/tests/unit && node --test --test-reporter spec \"dist-tests/tests/unit/*.test.js\"", - "test:functional": "npm run build && npm run build:test-functional && node scripts/check-tests-present.mjs dist-tests/tests/functional && node --test --test-reporter spec \"dist-tests/tests/functional/*.test.js\"", + "test:unit": "npm run build && npm run build:test-unit && node scripts/check-tests-present.mjs tests/unit/dist && node tests/unit/run-tests.js", + "test:functional": "npm run build && npm run build:test-functional && node scripts/check-tests-present.mjs tests/functional/dist && node tests/functional/run-tests.js", "validate": "npm run build && node scripts/validate-catalog.mjs", "digest": "node scripts/catalog-digest.mjs", "check:pack": "npm run build && node scripts/check-pack.mjs", diff --git a/policy-catalog/scripts/check-pack.mjs b/policy-catalog/scripts/check-pack.mjs index 0ff7e28b2..2b38f9f79 100644 --- a/policy-catalog/scripts/check-pack.mjs +++ b/policy-catalog/scripts/check-pack.mjs @@ -28,13 +28,14 @@ const required = [ 'dist/tooling.js', 'dist/tooling.d.ts', 'dist/cli.js', + 'dist/validate.js', 'catalog/contract.v1.json', 'catalog/manifest.json', ...manifest.revisions.map(revision => `catalog/${revision.file}`), 'schema/catalog.v1.schema.json', 'schema/manifest.v1.schema.json', ]; -const forbidden = [/^src\//, /^tests\//, /^dist-tests\//, /^conformance\//, /^scripts\//, /^docs\//, /^\.github\//, /^node_modules\//, /\.test\.js$/]; +const forbidden = [/^src\//, /^tests\//, /^conformance\//, /^scripts\//, /^docs\//, /^\.github\//, /^node_modules\//, /\.test\.js$/]; const errors = [ ...required.filter(file => !files.includes(file)).map(file => `missing ${file}`), diff --git a/policy-catalog/scripts/clean.mjs b/policy-catalog/scripts/clean.mjs index e99dbe467..fd17d3cdb 100644 --- a/policy-catalog/scripts/clean.mjs +++ b/policy-catalog/scripts/clean.mjs @@ -6,6 +6,6 @@ import { rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -for (const dir of ['dist', 'dist-tests']) { +for (const dir of ['dist', 'tests/unit/dist', 'tests/functional/dist']) { rmSync(fileURLToPath(new URL(`../${dir}/`, import.meta.url)), { recursive: true, force: true }); } diff --git a/policy-catalog/scripts/package-smoke.mjs b/policy-catalog/scripts/package-smoke.mjs index e84bb0f29..3f201a60a 100644 --- a/policy-catalog/scripts/package-smoke.mjs +++ b/policy-catalog/scripts/package-smoke.mjs @@ -44,13 +44,16 @@ try { assert.equal(r.diagnostics.tools[0].matches[0].entryId, 'tool:git'); assert.throws(() => getSandboxConfig('git', { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); // The packaged CLI runs from the installed tree. - const info = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'info'], { encoding: 'utf8' })); - assert.equal(info.catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); + const inspected = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'inspect'], { encoding: 'utf8' })); + assert.equal(inspected.info.catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); // On POSIX, npm installs the bin as a symlink; running through it must still work. if (process.platform !== 'win32') { - const viaBin = JSON.parse(execFileSync(process.execPath, ['node_modules/.bin/policy-catalog', 'info'], { encoding: 'utf8' })); - assert.equal(viaBin.catalogRevision, info.catalogRevision); + const viaBin = JSON.parse(execFileSync(process.execPath, ['node_modules/.bin/policy-catalog', 'inspect'], { encoding: 'utf8' })); + assert.deepEqual(viaBin, inspected); } + // validate checks the installed catalog's integrity and contract. + const validated = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'validate'], { encoding: 'utf8' })); + assert.equal(validated.ok, true); console.log('package smoke ok'); `); process.stdout.write(execFileSync(process.execPath, ['smoke.mjs'], { cwd: consumer, encoding: 'utf8' })); diff --git a/policy-catalog/scripts/validate-catalog.mjs b/policy-catalog/scripts/validate-catalog.mjs index bd48162c4..3e88f5ec7 100644 --- a/policy-catalog/scripts/validate-catalog.mjs +++ b/policy-catalog/scripts/validate-catalog.mjs @@ -13,14 +13,14 @@ // backend neutrality, and v1 composition limits. // 4. Entry-revision monotonicity across consecutive published revisions. // 5. Immutability of already-published revisions against a base git ref -// (POLICY_CATALOG_BASE_REF, e.g. origin/main), when provided. +// (--base-ref= or POLICY_CATALOG_BASE_REF, e.g. origin/main), when +// provided. Shares its implementation with `policy-catalog validate`. // 6. Deterministic resolution: every entry resolves identically twice for // every platform/architecture selector, and the whole-catalog lookup is // independent of input order and of catalog file order. // 7. Package inclusion: the bundled default revision equals the repository // manifest default. // 8. Every entry has a conformance fixture case. -import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import { createRequire } from 'node:module'; import { join, relative } from 'node:path'; @@ -30,9 +30,9 @@ import { ARCHITECTURES, PLATFORMS, canonicalSha256, - checkPublishedImmutability, checkStoreHistory, selectVariant, + checkAgainstBaseRef, } from '../dist/tooling.js'; const require = createRequire(import.meta.url); @@ -84,33 +84,17 @@ const store = loadCatalogDirectory(pathToFileURL(`${catalogDir}/`)); step('integrity, contract, and entry-revision history', () => checkStoreHistory(store)); step('published revision immutability', () => { - const baseRef = process.env.POLICY_CATALOG_BASE_REF; + // Same check as `policy-catalog validate --base-ref `. An unknown ref is + // an error, never a silent pass. + const baseRef = process.argv.find(arg => arg.startsWith('--base-ref='))?.slice('--base-ref='.length) + ?? process.env.POLICY_CATALOG_BASE_REF; if (!baseRef) { - console.log(' (skipped: set POLICY_CATALOG_BASE_REF to compare against a base ref)'); + console.log(' (skipped: pass --base-ref= or set POLICY_CATALOG_BASE_REF to compare against a base ref)'); return []; } - const repoRoot = execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); - const prefix = relative(repoRoot, catalogDir).replaceAll('\\', '/'); - const show = path => { - try { - return execFileSync('git', ['-C', repoRoot, 'show', `${baseRef}:${prefix}/${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); - } catch { - return undefined; - } - }; - const baseManifestText = show('manifest.json'); - if (baseManifestText === undefined) { - console.log(` (no catalog at ${baseRef}; nothing published yet)`); - return []; - } - const baseManifest = JSON.parse(baseManifestText); - const baseFiles = new Map(); - for (const r of baseManifest.revisions) { - const text = show(r.file); - if (text !== undefined) baseFiles.set(r.file, text); - } - const proposedFiles = new Map(manifest.revisions.map(r => [r.file, readFileSync(join(catalogDir, r.file), 'utf8')])); - return checkPublishedImmutability({ manifest: baseManifest, files: baseFiles }, { manifest, files: proposedFiles }); + const { comparedRevisions, errors } = checkAgainstBaseRef(catalogDir, manifest, baseRef); + console.log(` (base ref ${baseRef}: ${comparedRevisions} published revision(s) compared)`); + return errors; }); step('deterministic resolution', () => { diff --git a/policy-catalog/src/cli.ts b/policy-catalog/src/cli.ts index ab068e20f..65fad94f5 100644 --- a/policy-catalog/src/cli.ts +++ b/policy-catalog/src/cli.ts @@ -4,25 +4,36 @@ // Command-line harness over the public library API. It is intended for // contributors, CI, and functional tests; it is not a sandbox launcher. +// There are exactly three commands, one per use: // -// policy-catalog info -// policy-catalog list -// policy-catalog resolve [--diagnostics] [--platform P] [--architecture A] -// [--revision R] [--project-root PATH] [--symbol name=value]... -// [--allow-weak] [--purl URL] [--detected-version V] ... -// policy-catalog verify [--catalog DIR] +// policy-catalog resolve [--catalog DIR] [--diagnostics] [--platform P] +// [--architecture A] [--revision R] [--project-root PATH] +// [--symbol name=value]... [--allow-weak] +// [--purl URL] [--detected-version V] ... +// policy-catalog inspect [--catalog DIR] +// policy-catalog validate [--catalog DIR] [--base-ref REF] // +// resolve runs getSandboxConfig / getSandboxConfigWithDiagnostics. +// inspect prints getCatalogInfo() and listCatalogEntries() (metadata only). +// validate checks integrity, the catalog contract (including dependency +// cycles), entry-revision history, and, with --base-ref, that every +// revision published at REF is unchanged. +// +// --catalog defaults to the catalog bundled with this package. // Exit codes: 0 success (including "no policy" for resolve), 1 library -// failure (PolicyCatalogError), 2 usage error. Output is JSON on stdout. +// failure (PolicyCatalogError) or failed validation, 2 usage error. +// Output is JSON on stdout. import { realpathSync } from 'node:fs'; import { resolve as resolvePath } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { PolicyCatalogError } from './errors.js'; import { PolicyCatalog } from './resolver.js'; import { bundledCatalogStore, loadCatalogDirectory, type CatalogStore } from './store.js'; -import { checkStoreHistory } from './history.js'; +import { validateCatalogDirectory } from './validate.js'; import type { CatalogArchitecture, CatalogPlatform, ResolveContext, ToolCandidate } from './types.js'; +const USAGE = 'usage: policy-catalog [options]'; + class UsageError extends Error {} function takeValue(args: string[], index: number, flag: string): string { @@ -39,35 +50,49 @@ function storeFrom(catalogDir: string | undefined): CatalogStore { : loadCatalogDirectory(pathToFileURL(`${resolvePath(catalogDir)}/`)); } +function bundledCatalogDir(): string { + return fileURLToPath(new URL('../catalog/', import.meta.url)); +} + function print(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } +function rejectRest(args: string[], command: string): void { + if (args.length > 0) { + throw new UsageError(`${command}: unexpected argument '${args[0]}'`); + } +} + export function main(argv: string[]): number { const [command, ...rest] = argv; try { let catalogDir: string | undefined; + let baseRef: string | undefined; const args: string[] = []; for (let i = 0; i < rest.length; i += 1) { if (rest[i] === '--catalog') { catalogDir = takeValue(rest, i, '--catalog'); i += 1; + } else if (rest[i] === '--base-ref' && command === 'validate') { + baseRef = takeValue(rest, i, '--base-ref'); + i += 1; } else { args.push(rest[i]); } } switch (command) { - case 'info': - print(new PolicyCatalog(storeFrom(catalogDir)).getCatalogInfo()); + case 'inspect': { + rejectRest(args, command); + const catalog = new PolicyCatalog(storeFrom(catalogDir)); + print({ info: catalog.getCatalogInfo(), entries: catalog.listCatalogEntries() }); return 0; - case 'list': - print(new PolicyCatalog(storeFrom(catalogDir)).listCatalogEntries()); - return 0; - case 'verify': { - const store = storeFrom(catalogDir); - const errors = checkStoreHistory(store); - print({ ok: errors.length === 0, defaultRevision: store.defaultRevision, revisions: store.availableRevisions, errors }); - return errors.length === 0 ? 0 : 1; + } + case 'validate': { + rejectRest(args, command); + const report = validateCatalogDirectory(catalogDir ?? bundledCatalogDir(), baseRef === undefined ? {} : { baseRef }); + print(report); + return report.ok ? 0 : 1; } case 'resolve': return resolveCommand(args, catalogDir); @@ -76,7 +101,7 @@ export function main(argv: string[]): number { } } catch (error) { if (error instanceof UsageError) { - process.stderr.write(`policy-catalog: ${error.message}\nusage: policy-catalog [options]\n`); + process.stderr.write(`policy-catalog: ${error.message}\n${USAGE}\n`); return 2; } if (error instanceof PolicyCatalogError) { diff --git a/policy-catalog/src/tooling.ts b/policy-catalog/src/tooling.ts index 6c573c1e1..50c551ada 100644 --- a/policy-catalog/src/tooling.ts +++ b/policy-catalog/src/tooling.ts @@ -25,6 +25,12 @@ export { checkPublishedImmutability, type PublishedState, } from './history.js'; +export { + validateCatalogDirectory, + checkAgainstBaseRef, + readPublishedStateAtRef, + type CatalogValidationReport, +} from './validate.js'; export { isValidVersionRange, satisfiesVersionRange } from './version-range.js'; export { architectureFromMachine } from './host.js'; export { parsePurl } from './purl.js'; diff --git a/policy-catalog/src/validate.ts b/policy-catalog/src/validate.ts new file mode 100644 index 000000000..2b37e7e75 --- /dev/null +++ b/policy-catalog/src/validate.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Catalog-directory validation shared by `policy-catalog validate` and the +// contribution pipeline (scripts/validate-catalog.mjs). Tooling only: the +// runtime lookup API never imports this module or runs git. +import { execFileSync } from 'node:child_process'; +import { readFileSync, realpathSync } from 'node:fs'; +import { isAbsolute, relative, resolve as resolvePath } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PolicyCatalogError } from './errors.js'; +import { checkPublishedImmutability, checkStoreHistory, type PublishedState } from './history.js'; +import { loadCatalogDirectory, type CatalogStore } from './store.js'; + +export interface CatalogValidationReport { + /** True only when every requested check ran and passed. */ + ok: boolean; + catalogDir: string; + defaultRevision?: string; + revisions?: string[]; + /** Present only when a base ref was requested. */ + baseRef?: { + ref: string; + /** Published revisions at the base ref that were compared; 0 when the base has no catalog. */ + comparedRevisions: number; + }; + errors: string[]; +} + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); +} + +function gitSucceeds(args: string[], cwd: string): boolean { + try { + git(args, cwd); + return true; + } catch { + return false; + } +} + +/** + * Reads the published catalog state at `baseRef` for the git repository that + * contains `catalogDir`. Returns `undefined` when the base ref has no catalog + * at that path (nothing is published yet). Throws a `validation` error when + * the check cannot be performed, so a typo in the ref or a directory outside + * git never passes silently. + */ +export function readPublishedStateAtRef(catalogDir: string, baseRef: string): PublishedState | undefined { + // git receives the ref as an argument; one that starts with '-' would be + // parsed as an option, so it is rejected rather than passed through. + if (baseRef.length === 0 || baseRef.startsWith('-') || /[\s\0]/.test(baseRef)) { + throw new PolicyCatalogError('validation', `base-ref check: '${baseRef}' is not a valid git ref`); + } + // .native expands Windows 8.3 short names (e.g. RUNNER~1 in CI temp dirs), + // so the path compares correctly with git's long-form top-level path. + const dir = realpathSync.native(catalogDir); + let top: string; + try { + top = realpathSync.native(git(['rev-parse', '--show-toplevel'], dir).trim()); + } catch { + throw new PolicyCatalogError('validation', `base-ref check: '${catalogDir}' is not inside a git work tree`); + } + if (!gitSucceeds(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`], top)) { + throw new PolicyCatalogError('validation', `base-ref check: '${baseRef}' does not name a commit`); + } + const prefix = relative(top, dir).replaceAll('\\', '/'); + if (prefix.startsWith('..') || isAbsolute(prefix)) { + throw new PolicyCatalogError('validation', `base-ref check: '${catalogDir}' is outside its git work tree`); + } + const at = (file: string): string => `${baseRef}:${prefix === '' ? '' : `${prefix}/`}${file}`; + if (!gitSucceeds(['cat-file', '-e', at('manifest.json')], top)) { + return undefined; + } + const manifest = JSON.parse(git(['show', at('manifest.json')], top)) as PublishedState['manifest']; + const files = new Map(); + for (const revision of manifest.revisions ?? []) { + if (gitSucceeds(['cat-file', '-e', at(revision.file)], top)) { + files.set(revision.file, git(['show', at(revision.file)], top)); + } + } + return { manifest, files }; +} + +/** + * Compares a catalog directory's manifest and revision files with the + * revisions published at `baseRef` (design 10): published revisions keep + * their manifest entry, digest, and content; new revisions are only appended. + * Returns errors instead of throwing, including when the check cannot run. + */ +export function checkAgainstBaseRef( + catalogDir: string, + manifest: PublishedState['manifest'], + baseRef: string, +): { comparedRevisions: number; errors: string[] } { + const dir = resolvePath(catalogDir); + try { + const base = readPublishedStateAtRef(dir, baseRef); + if (!base) { + return { comparedRevisions: 0, errors: [] }; + } + const files = new Map(manifest.revisions.map(r => { + let text: string; + try { + text = readFileSync(resolvePath(dir, r.file), 'utf8'); + } catch { + // Reported as a modification: a published file may not disappear. + text = ''; + } + return [r.file, text] as const; + })); + return { + comparedRevisions: base.manifest.revisions.length, + errors: checkPublishedImmutability(base, { manifest, files }).map(message => `[immutability] ${message}`), + }; + } catch (error) { + return { comparedRevisions: 0, errors: [describeError(error)] }; + } +} + +function describeError(error: unknown): string { + return error instanceof PolicyCatalogError ? error.message : `[validation] ${(error as Error).message}`; +} + +/** + * Validates a catalog directory: manifest and contract validity, per-revision + * integrity against the published canonical digest, the full catalog contract + * (including dependency closure and cycle freedom), entry-revision history, + * and, when `baseRef` is given, immutability of every revision published at + * that ref. Never throws for catalog problems; they are reported in `errors`. + */ +export function validateCatalogDirectory(catalogDir: string, options: { baseRef?: string } = {}): CatalogValidationReport { + const dir = resolvePath(catalogDir); + // Field order is fixed so the JSON report is stable and readable. + const report: CatalogValidationReport = { ok: false, catalogDir: dir, defaultRevision: undefined, revisions: undefined, baseRef: undefined, errors: [] }; + let store: CatalogStore; + try { + store = loadCatalogDirectory(pathToFileURL(`${dir}/`)); + } catch (error) { + report.errors.push(describeError(error)); + return report; + } + report.defaultRevision = store.defaultRevision; + report.revisions = store.availableRevisions; + report.errors.push(...checkStoreHistory(store)); + if (options.baseRef !== undefined) { + const result = checkAgainstBaseRef(dir, store.manifest, options.baseRef); + report.baseRef = { ref: options.baseRef, comparedRevisions: result.comparedRevisions }; + report.errors.push(...result.errors); + } + report.ok = report.errors.length === 0; + return report; +} diff --git a/policy-catalog/tests/functional/cli.test.ts b/policy-catalog/tests/functional/cli.test.ts index e1bf8d99e..1613ad9e8 100644 --- a/policy-catalog/tests/functional/cli.test.ts +++ b/policy-catalog/tests/functional/cli.test.ts @@ -1,91 +1,78 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Functional (end-to-end) tests. They run the compiled CLI (`dist/cli.js`) as a -// separate process against the real bundled catalog and against tampered or -// invalid copies of it, then assert on exit codes and JSON output. Unlike the -// unit tests, they exercise the shipped entry point, file loading, integrity -// verification, and host detection exactly as a consumer would. Mirrors MXC's -// sdk/node/tests/integration layout (compiled node:test files, spec reporter). -import { describe, it, before, after } from 'node:test'; +// Functional tests for the installed `policy-catalog` CLI: the three commands +// (resolve, inspect, validate), exit codes, JSON output, and the bundled +// catalog shipped in the tarball. +import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { ARCHITECTURES, PLATFORMS, PLATFORM_CONTEXT, cli, cliViaBin, fullContext, installedManifest } from './helpers.js'; -// dist-tests/tests/functional/ -> package root -const packageRoot = fileURLToPath(new URL('../../../', import.meta.url)); -const cli = join(packageRoot, 'dist', 'cli.js'); -const catalogDir = join(packageRoot, 'catalog'); -const manifest = JSON.parse(readFileSync(join(catalogDir, 'manifest.json'), 'utf8')); - -interface CliResult { - status: number | null; - json: any; - stderr: string; -} - -function run(...args: string[]): CliResult { - const result = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' }); - let json: any; - try { - json = result.stdout.trim() === '' ? undefined : JSON.parse(result.stdout); - } catch { - json = result.stdout; - } - return { status: result.status, json, stderr: result.stderr }; -} - -const PLATFORM_CONTEXT = { - windows: { root: 'C:\\work\\app', prefix: 'C:\\tools', cache: 'C:\\cache\\npm' }, - linux: { root: '/work/app', prefix: '/opt/tools', cache: '/var/cache/npm' }, - macos: { root: '/Users/dev/app', prefix: '/opt/homebrew/bin', cache: '/Users/dev/.npm' }, -} as const; +describe('installed CLI: inspect and validate', () => { + it('inspect reports catalog info and metadata-only entries sorted by entryId', () => { + const result = cli('inspect'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json.info, { catalogSchemaVersion: '1', catalogRevision: installedManifest.defaultRevision }); + const ids = result.json.entries.map((e: any) => e.entryId); + assert.deepEqual(ids, ['tool:git', 'tool:node', 'tool:npm']); + assert.ok(!result.stdout.includes('readwritePaths'), 'inspect must never return a policy body'); + assert.ok(!result.stdout.includes('${'), 'inspect must never return unresolved policy templates'); + }); -function fullContext(platform: keyof typeof PLATFORM_CONTEXT, architecture: string): string[] { - const c = PLATFORM_CONTEXT[platform]; - return [ - '--platform', platform, - '--architecture', architecture, - '--allow-weak', - '--project-root', c.root, - '--symbol', `git_prefix=${c.prefix}`, - '--symbol', `node_prefix=${c.prefix}`, - '--symbol', `npm_prefix=${c.prefix}`, - '--symbol', `npm_cache=${c.cache}`, - ]; -} + it('validate checks integrity, contract, and history of every installed revision', () => { + const result = cli('validate'); + assert.equal(result.status, 0, result.stdout); + assert.equal(result.json.ok, true); + assert.deepEqual(result.json.errors, []); + assert.equal(result.json.defaultRevision, installedManifest.defaultRevision); + assert.deepEqual(result.json.revisions, installedManifest.revisions.map((r: any) => r.catalogRevision)); + assert.equal(result.json.baseRef, undefined); + }); -describe('policy-catalog CLI: bundled catalog', () => { - it('info reports the installed default revision', () => { - const result = run('info'); + it('the npm-installed bin shim runs the same CLI', () => { + const result = cliViaBin('inspect'); assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json, { catalogSchemaVersion: '1', catalogRevision: manifest.defaultRevision }); + assert.deepEqual(result.json, cli('inspect').json); }); - it('verify checks integrity and history of every installed revision', () => { - const result = run('verify'); - assert.equal(result.status, 0, JSON.stringify(result.json)); - assert.equal(result.json.ok, true); - assert.deepEqual(result.json.revisions, manifest.revisions.map((r: any) => r.catalogRevision)); + it('removed command names are usage errors, not aliases', () => { + for (const command of ['info', 'list', 'verify']) { + const result = cli(command); + assert.equal(result.status, 2, command); + assert.match(result.stderr, /unknown command/); + assert.match(result.stderr, /usage: policy-catalog /); + } }); - it('list returns metadata only, sorted by entryId', () => { - const result = run('list'); - assert.equal(result.status, 0); - const ids = result.json.map((e: any) => e.entryId); - assert.deepEqual(ids, [...ids].sort()); - assert.ok(!JSON.stringify(result.json).includes('readwritePaths')); + it('usage errors exit 2 without JSON on stdout', () => { + const cases = [ + [], + ['bogus'], + ['resolve', '--platform'], + ['resolve', '--symbol', 'noequals', 'git'], + ['resolve', '--purl', 'pkg:npm/npm'], + ['resolve', '--base-ref', 'HEAD', 'git'], + ['inspect', 'extra'], + ['validate', 'extra'], + ['validate', '--base-ref'], + ['resolve', '--catalog'], + ]; + for (const args of cases) { + const result = cli(...args); + assert.equal(result.status, 2, args.join(' ')); + assert.equal(result.json, undefined, args.join(' ')); + assert.match(result.stderr, /usage: policy-catalog/); + } }); +}); - for (const platform of ['windows', 'linux', 'macos'] as const) { - for (const architecture of ['x64', 'arm64']) { +describe('installed CLI: resolve against the bundled catalog', () => { + for (const platform of PLATFORMS) { + for (const architecture of ARCHITECTURES) { it(`resolves the full tool set on ${platform}/${architecture} deterministically`, () => { const c = PLATFORM_CONTEXT[platform]; - const first = run('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); - const second = run('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); + const first = cli('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); + const second = cli('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); assert.equal(first.status, 0, first.stderr); assert.deepEqual(first.json, second.json); const { policy, diagnostics } = first.json; @@ -101,129 +88,39 @@ describe('policy-catalog CLI: bundled catalog', () => { it('policy-only output equals the diagnostics policy', () => { const ctx = fullContext('linux', 'x64'); - const plain = run('resolve', ...ctx, 'npm'); - const diag = run('resolve', '--diagnostics', ...ctx, 'npm'); + const plain = cli('resolve', ...ctx, 'npm'); + const diag = cli('resolve', '--diagnostics', ...ctx, 'npm'); + assert.equal(plain.status, 0, plain.stderr); assert.deepEqual(plain.json, diag.json.policy); }); - it('strong package-URL identity does not need the weak-fallback opt-in', () => { - const c = PLATFORM_CONTEXT.linux; - const result = run('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--project-root', c.root, - '--symbol', `npm_prefix=${c.prefix}`, '--symbol', `npm_cache=${c.cache}`, '--symbol', `node_prefix=${c.prefix}`, - '--purl', 'pkg:npm/npm@10.9.0', 'npm'); - assert.equal(result.status, 0, result.stderr); - // Every satisfied predicate is attributed in declaration order (design §4.3); - // the strong one makes the entry eligible without the weak-fallback opt-in. - assert.deepEqual(result.json.diagnostics.tools[0].matches[0].matchedIdentities.map((i: any) => i.strength), ['strong', 'weak']); - assert.notEqual(result.json.policy, null); - }); - - it('omitted architecture uses the native system architecture and says so', () => { - const c = PLATFORM_CONTEXT.linux; - const result = run('resolve', '--diagnostics', '--platform', 'linux', '--allow-weak', '--project-root', c.root, '--symbol', `git_prefix=${c.prefix}`, 'git'); - assert.equal(result.status, 0, result.stderr); - assert.match(result.json.diagnostics.warnings.join('\n'), /native system architecture '(x64|arm64)'; the tool's architecture was not verified/); - }); - - it('unknown tools, weak-only matches without opt-in, and empty input produce null, never an empty policy', () => { - assert.equal(run('resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', 'cargo').json, null); - assert.equal(run('resolve', ...fullContext('linux', 'x64').filter(a => a !== '--allow-weak'), 'git').json, null); - const empty = run('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64'); - assert.equal(empty.status, 0); - assert.deepEqual(empty.json, { diagnostics: { catalogRevision: manifest.defaultRevision, tools: [], resolvedDependencies: [], warnings: [] } }); - }); - it('an unresolved required symbol yields null with an actionable warning', () => { - const result = run('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/usr/bin', 'git'); + const result = cli('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/usr/bin', 'git'); assert.equal(result.status, 0); assert.equal(result.json.policy, undefined); assert.match(result.json.diagnostics.warnings.join('\n'), /required symbol 'project_root'.*supply ResolveContext\.projectRoot/); }); - it('library failures exit 1 with a stable error category', () => { - const cases: Array<[string[], string]> = [ - [['resolve', '--revision', '1999-01-01.1', 'git'], 'revision-unavailable'], - [['resolve', '--platform', 'plan9', 'git'], 'invalid-context'], - [['resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'node_prefix=relative', 'node'], 'invalid-context'], - [['resolve', '--platform', 'linux', '--architecture', 'x64', '--symbol', 'unknown_symbol=/x', 'git'], 'invalid-context'], - [['resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], 'composition-conflict'], - ]; - for (const [args, category] of cases) { - const result = run(...args); - assert.equal(result.status, 1, `${args.join(' ')} -> ${result.stderr}`); - assert.equal(result.json.error.category, category, args.join(' ')); - } + it('empty input produces no policy, never an empty policy', () => { + assert.equal(cli('resolve', '--platform', 'linux', '--architecture', 'x64').json, null); + const empty = cli('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64'); + assert.equal(empty.status, 0); + assert.deepEqual(empty.json, { diagnostics: { catalogRevision: installedManifest.defaultRevision, tools: [], resolvedDependencies: [], warnings: [] } }); }); - it('usage errors exit 2 without JSON on stdout', () => { - for (const args of [[], ['bogus'], ['resolve', '--platform'], ['resolve', '--symbol', 'noequals', 'git'], ['resolve', '--purl', 'pkg:npm/npm']]) { - const result = run(...args); - assert.equal(result.status, 2, args.join(' ')); - assert.equal(result.json, undefined); - assert.match(result.stderr, /usage: policy-catalog/); + it('invalid caller input is an invalid-context library failure (exit 1)', () => { + const cases: string[][] = [ + ['resolve', '--platform', 'plan9', 'git'], + ['resolve', '--architecture', 'mips', 'git'], + ['resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'node_prefix=relative', 'node'], + ['resolve', '--platform', 'linux', '--architecture', 'x64', '--symbol', 'unknown_symbol=/x', 'git'], + ['resolve', '--platform', 'linux', '--architecture', 'x64', '--purl', 'not-a-purl', 'npm'], + ['resolve', '--platform', 'linux', '--architecture', 'x64', './bin/git'], + ]; + for (const args of cases) { + const result = cli(...args); + assert.equal(result.status, 1, `${args.join(' ')} -> ${result.stderr}`); + assert.equal(result.json.error.category, 'invalid-context', args.join(' ')); } }); }); - -describe('policy-catalog CLI: integrity failure modes on a copied catalog', () => { - let work: string; - const copy = (): string => { - const dir = mkdtempSync(join(work, 'catalog-')); - cpSync(catalogDir, dir, { recursive: true }); - return dir; - }; - const revisionFile = (dir: string) => join(dir, manifest.revisions.at(-1).file); - - before(() => { - work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-')); - }); - after(() => { - rmSync(work, { recursive: true, force: true }); - }); - - it('an untouched copy verifies and resolves like the bundled catalog', () => { - const dir = copy(); - assert.equal(run('verify', '--catalog', dir).status, 0); - const ctx = fullContext('linux', 'x64'); - assert.deepEqual(run('resolve', '--catalog', dir, ...ctx, 'git').json, run('resolve', ...ctx, 'git').json); - }); - - it('formatting-only edits keep the canonical digest valid', () => { - const dir = copy(); - const file = revisionFile(dir); - writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, 'utf8')))); - assert.equal(run('verify', '--catalog', dir).status, 0); - }); - - it('a semantic edit to a published revision fails integrity, never silently resolving', () => { - const dir = copy(); - const file = revisionFile(dir); - const data = JSON.parse(readFileSync(file, 'utf8')); - data.entries[0].platformVariants[0].sandboxPolicy.filesystem.readwritePaths.push('${user_home}'); - writeFileSync(file, JSON.stringify(data, null, 2)); - const verify = run('verify', '--catalog', dir); - assert.equal(verify.status, 1, verify.stderr); - assert.ok(verify.json?.errors, `verify produced no JSON report; stderr: ${verify.stderr}`); - assert.match(verify.json.errors.join('\n'), /\[integrity\].*does not match the published digest/); - const resolve = run('resolve', '--catalog', dir, ...fullContext('windows', 'x64'), 'git'); - assert.equal(resolve.status, 1); - assert.equal(resolve.json.error.category, 'integrity'); - }); - - it('a missing revision file is an integrity failure', () => { - const dir = copy(); - rmSync(revisionFile(dir)); - const result = run('info', '--catalog', dir); - assert.equal(result.status, 1); - assert.equal(result.json.error.category, 'integrity'); - }); - - it('a manifest whose default names an unlisted revision is a validation failure', () => { - const dir = copy(); - const file = join(dir, 'manifest.json'); - writeFileSync(file, JSON.stringify({ ...manifest, defaultRevision: '2099-01-01.1' })); - const result = run('info', '--catalog', dir); - assert.equal(result.status, 1); - assert.equal(result.json.error.category, 'validation'); - }); -}); diff --git a/policy-catalog/tests/functional/helpers.ts b/policy-catalog/tests/functional/helpers.ts new file mode 100644 index 000000000..a122b08b0 --- /dev/null +++ b/policy-catalog/tests/functional/helpers.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Shared helpers for the functional suite. Everything here resolves the +// library, the CLI, and the bundled catalog from the package that +// run-tests.js packed with `npm pack` and installed into a temporary consumer +// project outside the repository. Nothing reads the repository's dist/ or +// catalog/: the suite tests what a consumer would install. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +// Type-only imports are erased at compile time; the runtime modules below are +// loaded from the installed package. +import type * as Library from '@mxc-prototype/policy-catalog'; +import type * as Tooling from '@mxc-prototype/policy-catalog/tooling'; + +const consumerDir = process.env.POLICY_CATALOG_CONSUMER_DIR; +if (!consumerDir) { + throw new Error('POLICY_CATALOG_CONSUMER_DIR is not set; run the functional suite through tests/functional/run-tests.js'); +} + +/** The consumer project the tarball was installed into. */ +export const consumer = consumerDir; +/** The installed package directory inside the consumer's node_modules. */ +export const packageDir = join(consumer, 'node_modules', '@mxc-prototype', 'policy-catalog'); +const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); + +// Guard: the package under test must not be the source tree. +// tests/functional/dist/helpers.js -> package source root +const sourceRoot = realpathSync.native(fileURLToPath(new URL('../../../', import.meta.url))); +const installedRoot = realpathSync.native(packageDir); +const fromSource = relative(sourceRoot, installedRoot); +assert.ok(fromSource.startsWith('..') || isAbsolute(fromSource), `installed package ${installedRoot} is inside the source tree ${sourceRoot}`); + +/** The installed CLI entry point, as declared by the installed package's `bin`. */ +export const cliPath = join(packageDir, pkg.bin['policy-catalog']); +/** The catalog directory shipped in the installed package. */ +export const installedCatalogDir = join(packageDir, 'catalog'); +export const installedManifest = JSON.parse(readFileSync(join(installedCatalogDir, 'manifest.json'), 'utf8')); + +/** The installed library, loaded through the installed package's `exports`. */ +export const lib: typeof Library = await import(pathToFileURL(join(packageDir, pkg.exports['.'].import)).href); +/** The installed tooling subpath (digests for synthetic catalogs). */ +export const tooling: typeof Tooling = await import(pathToFileURL(join(packageDir, pkg.exports['./tooling'].import)).href); + +export interface CliResult { + status: number | null; + json: any; + stdout: string; + stderr: string; +} + +function parse(stdout: string): any { + try { + return stdout.trim() === '' ? undefined : JSON.parse(stdout); + } catch { + return stdout; + } +} + +/** Runs the installed CLI in a separate process from the consumer directory. */ +export function cli(...args: string[]): CliResult { + const result = spawnSync(process.execPath, [cliPath, ...args], { cwd: consumer, encoding: 'utf8' }); + return { status: result.status, json: parse(result.stdout), stdout: result.stdout, stderr: result.stderr }; +} + +/** Runs the CLI through the npm-installed bin shim (`node_modules/.bin`). */ +export function cliViaBin(...args: string[]): CliResult { + const bin = join(consumer, 'node_modules', '.bin', 'policy-catalog'); + const result = process.platform === 'win32' + // .cmd shims need a shell on Windows; arguments here are fixed literals. + ? spawnSync(`"${bin}.cmd" ${args.join(' ')}`, { cwd: consumer, encoding: 'utf8', shell: true }) + : spawnSync(bin, args, { cwd: consumer, encoding: 'utf8' }); + return { status: result.status, json: parse(result.stdout), stdout: result.stdout, stderr: result.stderr }; +} + +export const PLATFORM_CONTEXT = { + windows: { root: 'C:\\work\\app', prefix: 'C:\\tools', cache: 'C:\\cache\\npm' }, + linux: { root: '/work/app', prefix: '/opt/tools', cache: '/var/cache/npm' }, + macos: { root: '/Users/dev/app', prefix: '/opt/homebrew/bin', cache: '/Users/dev/.npm' }, +} as const; +export type Platform = keyof typeof PLATFORM_CONTEXT; +export const PLATFORMS = Object.keys(PLATFORM_CONTEXT) as Platform[]; +export const ARCHITECTURES = ['x64', 'arm64'] as const; + +/** Resolve flags with every bundled-catalog symbol supplied for `platform`. */ +export function symbolArgs(platform: Platform): string[] { + const c = PLATFORM_CONTEXT[platform]; + return [ + '--project-root', c.root, + '--symbol', `git_prefix=${c.prefix}`, + '--symbol', `node_prefix=${c.prefix}`, + '--symbol', `npm_prefix=${c.prefix}`, + '--symbol', `npm_cache=${c.cache}`, + ]; +} + +export function fullContext(platform: Platform, architecture: string): string[] { + return ['--platform', platform, '--architecture', architecture, '--allow-weak', ...symbolArgs(platform)]; +} + +/** Minimal valid entry for synthetic catalogs. */ +export function entry(entryId: string, overrides: Record = {}): any { + const name = entryId.split(':')[1]; + return { + entryId, + entryRevision: 1, + displayName: name, + identity: [{ kind: 'invocation-name', names: [name] }], + platformVariants: [ + { + when: { platform: 'linux' }, + sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${name}`] } }, + }, + ], + provenance: { method: 'functional-test', sourceRevision: 'functional-test' }, + ...overrides, + }; +} + +export function revision(entries: any[], catalogRevision = '2000-01-01.1'): any { + return { catalogSchemaVersion: '1', catalogRevision, entries }; +} + +/** + * Writes a complete catalog directory (contract from the installed package, + * manifest with correct canonical digests, revision files) so the only + * defect in it is the one a test introduces on purpose. + */ +export function writeCatalog(dir: string, revisions: any[], options: { defaultRevision?: string } = {}): string { + mkdirSync(join(dir, 'revisions'), { recursive: true }); + copyFileSync(join(installedCatalogDir, 'contract.v1.json'), join(dir, 'contract.v1.json')); + const manifest = { + catalogSchemaVersion: '1', + defaultRevision: options.defaultRevision ?? revisions[revisions.length - 1].catalogRevision, + revisions: revisions.map(r => { + const file = `revisions/${r.catalogRevision}.json`; + writeFileSync(join(dir, file), `${JSON.stringify(r, null, 2)}\n`); + return { catalogRevision: r.catalogRevision, file, sha256: tooling.canonicalSha256(r) }; + }), + }; + writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + return dir; +} + +export function warningsOf(result: CliResult): string { + return (result.json?.diagnostics?.warnings ?? []).join('\n'); +} diff --git a/policy-catalog/tests/functional/run-tests.js b/policy-catalog/tests/functional/run-tests.js new file mode 100644 index 000000000..688808c8a --- /dev/null +++ b/policy-catalog/tests/functional/run-tests.js @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Functional test runner. Tests the package a consumer would install, not the +// source tree: +// +// 1. `npm pack` the package (its dist/ must already be built). +// 2. Install the tarball, offline, into a fresh consumer project in a +// temporary directory outside the repository. +// 3. Run the compiled *.test.js files in this directory's dist/ with +// POLICY_CATALOG_CONSUMER_DIR pointing at that consumer. The tests load +// the library and CLI only from the consumer's node_modules. +// +// The final step mirrors MXC's sdk/node/tests/integration/run-tests.js +// (`node --test --test-reporter spec --test-force-exit`). +// +// npm run test:functional +import { readdirSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const dist = fileURLToPath(new URL('./dist/', import.meta.url)); +const packageDir = fileURLToPath(new URL('../../', import.meta.url)); + +const files = existsSync(dist) + ? readdirSync(dist, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.test.js')) + .map(entry => join(dist, entry.name)) + .sort() + : []; +if (!files.length) { + console.error(`No test files found in ${dist}`); + process.exit(1); +} + +// Run npm through its JS entry point so no shell is involved on any platform. +const npmCli = process.env.npm_execpath; +if (!npmCli) { + console.error('Run the functional suite through `npm run test:functional` so npm can pack and install the package.'); + process.exit(2); +} +const npm = (args, cwd) => execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); + +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-')); +let status = 0; +try { + const [packed] = JSON.parse(npm(['pack', '--json', '--pack-destination', work], packageDir)); + const tarball = join(work, packed.filename); + const consumer = join(work, 'consumer'); + mkdirSync(consumer); + writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({ name: 'policy-catalog-functional-consumer', private: true, type: 'module' }, null, 2)}\n`); + // The package has no runtime dependencies, so the install needs no network. + npm(['install', '--offline', '--no-audit', '--no-fund', '--ignore-scripts', tarball], consumer); + console.log(`Installed ${packed.name}@${packed.version} (${packed.entryCount} files) from ${packed.filename} into ${consumer}\n`); + + try { + execFileSync(process.execPath, ['--test', '--test-reporter', 'spec', '--test-force-exit', ...files], { + stdio: 'inherit', + env: { ...process.env, POLICY_CATALOG_CONSUMER_DIR: consumer }, + }); + } catch (error) { + status = typeof error.status === 'number' && error.status !== 0 ? error.status : 1; + } +} finally { + rmSync(work, { recursive: true, force: true }); +} +process.exit(status); diff --git a/policy-catalog/tests/functional/tsconfig.json b/policy-catalog/tests/functional/tsconfig.json index 58bf962e7..5cd65d3a0 100644 --- a/policy-catalog/tests/functional/tsconfig.json +++ b/policy-catalog/tests/functional/tsconfig.json @@ -4,8 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], - "outDir": "../../dist-tests", - "rootDir": "../..", + "outDir": "./dist", + "rootDir": ".", "strict": true, "noUnusedLocals": true, "skipLibCheck": true, @@ -13,5 +13,6 @@ "declaration": false, "types": ["node"] }, - "include": ["./**/*.ts"] + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/policy-catalog/tests/unit/run-tests.js b/policy-catalog/tests/unit/run-tests.js new file mode 100644 index 000000000..657f689e0 --- /dev/null +++ b/policy-catalog/tests/unit/run-tests.js @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit test runner. Mirrors MXC's sdk/node/tests/integration/run-tests.js: +// runs the compiled *.test.js files in this directory's dist/ with the spec +// reporter, and fails when there is nothing to run. +// +// npm run test:unit +import { readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const dist = fileURLToPath(new URL('./dist/', import.meta.url)); +const files = existsSync(dist) + ? readdirSync(dist, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.test.js')) + .map(entry => join(dist, entry.name)) + .sort() + : []; + +if (!files.length) { + console.error(`No test files found in ${dist}`); + process.exit(1); +} + +try { + execFileSync(process.execPath, ['--test', '--test-reporter', 'spec', '--test-force-exit', ...files], { stdio: 'inherit' }); +} catch (error) { + process.exit(typeof error.status === 'number' && error.status !== 0 ? error.status : 1); +} diff --git a/policy-catalog/tests/unit/tsconfig.json b/policy-catalog/tests/unit/tsconfig.json index 58bf962e7..5cd65d3a0 100644 --- a/policy-catalog/tests/unit/tsconfig.json +++ b/policy-catalog/tests/unit/tsconfig.json @@ -4,8 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], - "outDir": "../../dist-tests", - "rootDir": "../..", + "outDir": "./dist", + "rootDir": ".", "strict": true, "noUnusedLocals": true, "skipLibCheck": true, @@ -13,5 +13,6 @@ "declaration": false, "types": ["node"] }, - "include": ["./**/*.ts"] + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] } From 013e0882c646852cf106f1dc149b608395ee007b Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 29 Sep 2026 23:29:16 -0700 Subject: [PATCH 09/50] test(policy-catalog): add functional coverage for cycles, tamper, multi-match, and selection warnings Through the installed CLI and library: dependency cycles and a diamond on synthetic catalogs; digest tamper, missing revision, and invalid manifest on copied catalogs; --base-ref immutability, unknown refs, and empty bases; one input matching several entries with its warning; weak-identity opt-in on and off; unknown tools; composition conflicts (overlapping classes, network merge, mixed versions); unavailable revisions; exact vs neutral variants; and the arch-not-verified warning for windows/linux/macos x x64/arm64 via an injected host. Break-checked: disabling cycle detection fails the two cycle tests (exit 0 where 1 expected); disabling the digest check fails the tamper test. Both breaks were reverted. --- .../tests/functional/integrity.test.ts | 199 ++++++++++++ .../tests/functional/resolution.test.ts | 288 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 policy-catalog/tests/functional/integrity.test.ts create mode 100644 policy-catalog/tests/functional/resolution.test.ts diff --git a/policy-catalog/tests/functional/integrity.test.ts b/policy-catalog/tests/functional/integrity.test.ts new file mode 100644 index 000000000..e6d17506a --- /dev/null +++ b/policy-catalog/tests/functional/integrity.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Functional tests for catalog integrity and contract enforcement through the +// installed CLI: digest tampering, missing files, invalid manifests, +// dependency cycles, and the --base-ref immutability check. Every catalog +// here is a copy or a synthetic directory in a temporary folder; the +// installed package is never modified. +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { cli, entry, fullContext, installedCatalogDir, installedManifest, revision, writeCatalog } from './helpers.js'; + +let work: string; +before(() => { + work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-integrity-')); +}); +after(() => { + rmSync(work, { recursive: true, force: true }); +}); + +const copyInstalled = (): string => { + const dir = mkdtempSync(join(work, 'catalog-')); + cpSync(installedCatalogDir, dir, { recursive: true }); + return dir; +}; +const latestFile = (dir: string) => join(dir, installedManifest.revisions.at(-1).file); + +describe('integrity of a copied catalog', () => { + it('an untouched copy validates and resolves like the bundled catalog', () => { + const dir = copyInstalled(); + assert.equal(cli('validate', '--catalog', dir).status, 0); + const ctx = fullContext('linux', 'x64'); + assert.deepEqual(cli('resolve', '--catalog', dir, ...ctx, 'git').json, cli('resolve', ...ctx, 'git').json); + }); + + it('formatting-only edits keep the canonical digest valid', () => { + const dir = copyInstalled(); + const file = latestFile(dir); + writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, 'utf8'))).replaceAll(',', ',\r\n')); + const result = cli('validate', '--catalog', dir); + assert.equal(result.status, 0, result.stdout); + }); + + it('a semantic edit to a published revision fails integrity in validate, resolve, and inspect', () => { + const dir = copyInstalled(); + const file = latestFile(dir); + const data = JSON.parse(readFileSync(file, 'utf8')); + // Widen a requirement: the kind of tamper integrity checking exists to stop. + data.entries[0].platformVariants[0].sandboxPolicy.filesystem.readwritePaths.push('${user_home}'); + writeFileSync(file, JSON.stringify(data, null, 2)); + + const validate = cli('validate', '--catalog', dir); + assert.equal(validate.status, 1, validate.stderr); + assert.equal(validate.json?.ok, false, `validate produced no JSON report; stderr: ${validate.stderr}`); + assert.match(validate.json.errors.join('\n'), /\[integrity\] catalog revision '[^']+' digest [0-9a-f]{64} does not match the published digest/); + + const resolve = cli('resolve', '--catalog', dir, ...fullContext('windows', 'x64'), 'git'); + assert.equal(resolve.status, 1, resolve.stderr); + assert.equal(resolve.json.error.category, 'integrity'); + assert.equal(cli('inspect', '--catalog', dir).json.error.category, 'integrity'); + }); + + it('a missing revision file is an integrity failure', () => { + const dir = copyInstalled(); + rmSync(latestFile(dir)); + const result = cli('inspect', '--catalog', dir); + assert.equal(result.status, 1); + assert.equal(result.json.error.category, 'integrity'); + assert.match(cli('validate', '--catalog', dir).json.errors.join('\n'), /\[integrity\].*could not be read/); + }); + + it('a manifest whose default names an unlisted revision is a validation failure', () => { + const dir = copyInstalled(); + writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ ...installedManifest, defaultRevision: '2099-01-01.1' })); + const result = cli('inspect', '--catalog', dir); + assert.equal(result.status, 1); + assert.equal(result.json.error.category, 'validation'); + const validate = cli('validate', '--catalog', dir); + assert.equal(validate.status, 1); + assert.match(validate.json.errors.join('\n'), /\[validation\] manifest\.defaultRevision must name a listed revision/); + }); +}); + +describe('dependency cycles', () => { + const dependsOn = (target: string) => ({ + platformVariants: [{ + when: { platform: 'linux' }, + dependencies: [{ entryId: target }], + sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${target.split(':')[1]}-dep`] } }, + }], + }); + + it('validate rejects a correctly digested catalog whose entries form a cycle', () => { + // Digests are correct, so the only defect is the cycle a -> b -> c -> a. + const dir = writeCatalog(mkdtempSync(join(work, 'cycle-')), [revision([ + entry('tool:a', dependsOn('tool:b')), + entry('tool:b', dependsOn('tool:c')), + entry('tool:c', dependsOn('tool:a')), + ])]); + const result = cli('validate', '--catalog', dir); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.json?.ok, false, `validate produced no JSON report; stderr: ${result.stderr}`); + assert.match(result.json.errors.join('\n'), /\[validation\] 'tool:a' on linux\/x64: cycle \(tool:a -> tool:b -> tool:c -> tool:a\)/); + }); + + it('resolve never returns a policy from a cyclic catalog', () => { + const dir = writeCatalog(mkdtempSync(join(work, 'cycle-')), [revision([ + entry('tool:a', dependsOn('tool:b')), + entry('tool:b', dependsOn('tool:a')), + ])]); + const result = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.json?.error?.category, 'validation', `stderr: ${result.stderr}`); + assert.match(result.json.error.message, /cycle \(tool:a -> tool:b -> tool:a\)/); + }); + + it('a diamond (shared dependency, no cycle) is valid and contributes the shared entry once', () => { + const dir = writeCatalog(mkdtempSync(join(work, 'diamond-')), [revision([ + entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:b' }, { entryId: 'tool:c' }], sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/a'] } } }] }), + entry('tool:b', dependsOn('tool:d')), + entry('tool:c', dependsOn('tool:d')), + entry('tool:d'), + ])]); + assert.equal(cli('validate', '--catalog', dir).status, 0); + const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json.policy.filesystem.readonlyPaths, ['/opt/a', '/opt/d-dep', '/opt/d']); + assert.deepEqual(result.json.diagnostics.resolvedDependencies.map((d: any) => d.entryId), ['tool:b', 'tool:c', 'tool:d']); + }); +}); + +describe('validate --base-ref (published-revision immutability)', () => { + const git = (cwd: string, ...args: string[]) => + execFileSync('git', ['-c', 'user.name=functional-test', '-c', 'user.email=functional-test@invalid', '-c', 'core.autocrlf=false', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + + const publishedRepo = (): { repo: string; dir: string } => { + const repo = mkdtempSync(join(work, 'repo-')); + git(repo, 'init', '-q'); + const dir = join(repo, 'catalog'); + writeCatalog(dir, [revision([entry('tool:a')], '2000-01-01.1')]); + git(repo, 'add', '-A'); + git(repo, 'commit', '-q', '-m', 'publish 2000-01-01.1'); + return { repo, dir }; + }; + + it('an unchanged or appended catalog passes against the base ref', () => { + const { dir } = publishedRepo(); + const unchanged = cli('validate', '--catalog', dir, '--base-ref', 'HEAD'); + assert.equal(unchanged.status, 0, unchanged.stdout); + assert.deepEqual(unchanged.json.baseRef, { ref: 'HEAD', comparedRevisions: 1 }); + // Append a new revision, as the contribution flow requires. + writeCatalog(dir, [ + revision([entry('tool:a')], '2000-01-01.1'), + revision([entry('tool:a'), entry('tool:b')], '2000-01-02.1'), + ]); + const appended = cli('validate', '--catalog', dir, '--base-ref', 'HEAD'); + assert.equal(appended.status, 0, appended.stdout); + }); + + it('rewriting a published revision (with a recomputed digest) fails against the base ref', () => { + const { dir } = publishedRepo(); + // Consistent digests, so only the immutability check can catch it. + writeCatalog(dir, [revision([entry('tool:a', { entryRevision: 2, displayName: 'changed' })], '2000-01-01.1')]); + assert.equal(cli('validate', '--catalog', dir).status, 0, 'without --base-ref the rewrite is internally consistent'); + const result = cli('validate', '--catalog', dir, '--base-ref', 'HEAD'); + assert.equal(result.status, 1, result.stdout); + const errors = result.json.errors.join('\n'); + assert.match(errors, /\[immutability\] published revision '2000-01-01\.1' manifest entry was modified/); + assert.match(errors, /\[immutability\] published revision file 'revisions\/2000-01-01\.1\.json' was modified/); + }); + + it('an unknown or option-like base ref, or a directory outside git, is an error, never a silent pass', () => { + const { dir } = publishedRepo(); + const badRef = cli('validate', '--catalog', dir, '--base-ref', 'no-such-ref'); + assert.equal(badRef.status, 1); + assert.match(badRef.json.errors.join('\n'), /base-ref check: 'no-such-ref' does not name a commit/); + const optionLike = cli('validate', '--catalog', dir, '--base-ref', '-h'); + assert.equal(optionLike.status, 1); + assert.match(optionLike.json.errors.join('\n'), /base-ref check: '-h' is not a valid git ref/); + const outside = writeCatalog(mkdtempSync(join(work, 'nogit-')), [revision([entry('tool:a')])]); + const noGit = cli('validate', '--catalog', outside, '--base-ref', 'HEAD'); + assert.equal(noGit.status, 1); + assert.match(noGit.json.errors.join('\n'), /is not inside a git work tree/); + }); + + it('a base ref with no catalog at that path has nothing published yet', () => { + const { repo, dir } = publishedRepo(); + // A commit whose tree is empty: git mktree with no input writes the empty tree. + const emptyTree = git(repo, 'mktree').trim(); + const orphan = git(repo, 'commit-tree', emptyTree, '-m', 'empty').trim(); + const result = cli('validate', '--catalog', dir, '--base-ref', orphan); + assert.equal(result.status, 0, result.stdout); + assert.deepEqual(result.json.baseRef, { ref: orphan, comparedRevisions: 0 }); + }); +}); diff --git a/policy-catalog/tests/functional/resolution.test.ts b/policy-catalog/tests/functional/resolution.test.ts new file mode 100644 index 000000000..35ef98e6f --- /dev/null +++ b/policy-catalog/tests/functional/resolution.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Functional coverage of the resolution behaviors the design calls out +// (design 4.3, 4.4, 4.5, 5.1): additive multi-entry matching, weak-identity +// opt-in, unknown tools, composition conflicts, unavailable revisions, +// neutral-variant fallback, and the architecture-not-verified warning. Each +// case runs through the installed CLI and, where the injected host matters, +// through the installed library. +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + ARCHITECTURES, + PLATFORMS, + PLATFORM_CONTEXT, + cli, + entry, + fullContext, + lib, + revision, + symbolArgs, + warningsOf, + writeCatalog, + type Platform, +} from './helpers.js'; + +const NOT_VERIFIED = /architecture was not specified; variants were selected for the native system architecture '(x64|arm64)'; the tool's architecture was not verified/; + +let work: string; +before(() => { + work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-resolution-')); +}); +after(() => { + rmSync(work, { recursive: true, force: true }); +}); + +describe('one input matching several entries', () => { + // 'app' is claimed by two entries: tool:app (purl + name) and tool:app-plugin (name only). + const catalog = () => writeCatalog(mkdtempSync(join(work, 'multi-')), [revision([ + entry('tool:app', { + identity: [{ kind: 'purl', value: 'pkg:npm/app' }, { kind: 'invocation-name', names: ['app'] }], + }), + entry('tool:app-plugin', { + identity: [{ kind: 'invocation-name', names: ['app'] }], + }), + ])]); + + it('composes every eligible match and warns, naming the input and the entries', () => { + const dir = catalog(); + const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', + '--allow-weak', '--symbol', 'git_prefix=/opt', 'app'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json.diagnostics.tools, [{ + inputIndex: 0, + matches: [ + { entryId: 'tool:app', entryRevision: 1, matchedIdentities: [{ kind: 'invocation-name', strength: 'weak' }] }, + { entryId: 'tool:app-plugin', entryRevision: 1, matchedIdentities: [{ kind: 'invocation-name', strength: 'weak' }] }, + ], + }]); + assert.match(warningsOf(result), /input 0 \('app'\) matched 2 entries \(tool:app, tool:app-plugin\); all contribute/); + assert.deepEqual(result.json.policy.filesystem.readonlyPaths, ['/opt/app', '/opt/app-plugin']); + }); + + it('a strong match does not suppress the other entry\'s eligible weak match', () => { + const dir = catalog(); + const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', + '--allow-weak', '--symbol', 'git_prefix=/opt', '--purl', 'pkg:npm/app@1.0.0', 'app'); + assert.equal(result.status, 0, result.stderr); + const [app, plugin] = result.json.diagnostics.tools[0].matches; + assert.deepEqual(app.matchedIdentities.map((i: any) => i.strength), ['strong', 'weak']); + assert.equal(plugin.entryId, 'tool:app-plugin'); + assert.match(warningsOf(result), /matched 2 entries/); + }); + + it('without the weak opt-in only the strongly identified entry contributes and no multi-match warning is issued', () => { + const dir = catalog(); + const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', + '--symbol', 'git_prefix=/opt', '--purl', 'pkg:npm/app', 'app'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json.diagnostics.tools[0].matches.map((m: any) => m.entryId), ['tool:app']); + assert.doesNotMatch(warningsOf(result), /matched 2 entries/); + assert.deepEqual(result.json.policy.filesystem.readonlyPaths, ['/opt/app']); + }); +}); + +describe('weak-identity opt-in', () => { + const base = ['--diagnostics', '--platform', 'linux', '--architecture', 'x64', ...symbolArgs('linux')]; + + it('off (default): an invocation-name-only match yields no policy and says why', () => { + const result = cli('resolve', ...base, 'git'); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.policy, undefined); + assert.deepEqual(result.json.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); + assert.match(warningsOf(result), /input 0 \('git'\) matched no eligible catalog entry: tool:git matched only by invocation name and allowWeakIdentityFallback is not enabled/); + assert.equal(cli('resolve', '--platform', 'linux', '--architecture', 'x64', ...symbolArgs('linux'), 'git').json, null); + }); + + it('on: the same match contributes and is flagged as weak identity', () => { + const result = cli('resolve', ...base, '--allow-weak', 'git'); + assert.equal(result.status, 0, result.stderr); + assert.notEqual(result.json.policy, undefined); + assert.deepEqual(result.json.diagnostics.tools[0].matches[0].matchedIdentities, [{ kind: 'invocation-name', strength: 'weak' }]); + assert.match(warningsOf(result), /input 0 \('git'\) matched tool:git only by invocation name \(weak identity\)/); + }); + + it('a strong package-URL match does not need the opt-in and is not flagged as weak', () => { + const result = cli('resolve', ...base, '--purl', 'pkg:npm/npm@10.9.0', 'npm'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json.diagnostics.tools[0].matches[0].matchedIdentities.map((i: any) => i.strength), ['strong', 'weak']); + assert.notEqual(result.json.policy, undefined); + assert.doesNotMatch(warningsOf(result), /weak identity/); + }); + + it('string shorthand and object input obey the same option in the installed library', () => { + const catalog = new lib.PolicyCatalog(lib.bundledCatalogStore()); + const ctx = { platform: 'linux' as const, architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/usr/bin' } }; + assert.equal(catalog.getSandboxConfig('git', ctx), undefined); + assert.equal(catalog.getSandboxConfig({ invocationName: 'git' }, ctx), undefined); + const on = { ...ctx, allowWeakIdentityFallback: true }; + assert.deepEqual(catalog.getSandboxConfig('git', on), catalog.getSandboxConfig({ invocationName: 'git' }, on)); + assert.notEqual(catalog.getSandboxConfig('git', on), undefined); + }); +}); + +describe('unknown tools', () => { + it('an unknown tool yields no policy, never an empty policy, with a per-input warning', () => { + const result = cli('resolve', '--diagnostics', ...fullContext('linux', 'x64'), 'cargo'); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.policy, undefined); + assert.deepEqual(result.json.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); + assert.match(warningsOf(result), /input 0 \('cargo'\) matched no eligible catalog entry$/m); + assert.equal(cli('resolve', ...fullContext('linux', 'x64'), 'cargo').json, null); + }); + + it('known and unknown inputs compose the known requirements and report the unknown one', () => { + const mixed = cli('resolve', '--diagnostics', ...fullContext('linux', 'x64'), 'cargo', 'git'); + const known = cli('resolve', '--diagnostics', ...fullContext('linux', 'x64'), 'git'); + assert.equal(mixed.status, 0, mixed.stderr); + assert.deepEqual(mixed.json.policy, known.json.policy); + assert.deepEqual(mixed.json.diagnostics.tools.map((t: any) => [t.inputIndex, t.matches.length]), [[0, 0], [1, 1]]); + assert.match(warningsOf(mixed), /input 0 \('cargo'\) matched no eligible catalog entry/); + }); +}); + +describe('composition conflicts', () => { + it('overlapping resolved paths across access classes are a composition-conflict failure, not a choice', () => { + // project_root=/opt (read-write for git) contains node_prefix=/opt/node (read-only for node). + const result = cli('resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', + '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.json.error.category, 'composition-conflict'); + assert.match(result.json.error.message, /overlap across access classes/); + }); + + it('two independently matched entries that would need a network merge are rejected', () => { + const dir = writeCatalog(mkdtempSync(join(work, 'compose-')), [revision([ + entry('tool:a'), + entry('tool:b', { + platformVariants: [{ + when: { platform: 'linux' }, + sandboxPolicy: { version: '0.9.0-alpha', network: { egress: { default: 'deny' } } }, + }], + }), + ])]); + // Each entry alone is valid; the conflict exists only for this input set. + assert.equal(cli('validate', '--catalog', dir).status, 0); + const alone = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', 'b'); + assert.equal(alone.status, 0, alone.stderr); + assert.deepEqual(alone.json.network, { egress: { default: 'deny' } }); + const both = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a', 'b'); + assert.equal(both.status, 1, both.stderr); + assert.equal(both.json.error.category, 'composition-conflict'); + assert.match(both.json.error.message, /'tool:b' uses 'network', which has no v1 cross-entry composition rule/); + }); + + it('mixed sandboxPolicy versions across selected entries are rejected', () => { + const dir = writeCatalog(mkdtempSync(join(work, 'versions-')), [revision([ + entry('tool:a'), + entry('tool:b', { + platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.8.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/b'] } } }], + }), + ])]); + const both = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a', 'b'); + assert.equal(both.status, 1, both.stderr); + assert.equal(both.json.error.category, 'composition-conflict'); + assert.match(both.json.error.message, /mixed sandboxPolicy\.version values/); + }); +}); + +describe('catalog revisions', () => { + it('an unavailable explicit revision is a revision-unavailable failure, never a substitution', () => { + const result = cli('resolve', '--revision', '1999-01-01.1', ...fullContext('linux', 'x64'), 'git'); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.json.error.category, 'revision-unavailable'); + assert.match(result.json.error.message, /'1999-01-01\.1' is not installed/); + assert.throws( + () => lib.getSandboxConfig('git', { catalogRevision: '1999-01-01.1', platform: 'linux', architecture: 'x64' }), + (error: any) => error instanceof lib.PolicyCatalogError && error.category === 'revision-unavailable', + ); + }); + + it('an older installed revision stays addressable after a newer default is published', () => { + const r1 = revision([entry('tool:a')], '2000-01-01.1'); + const r2 = revision([entry('tool:a', { + entryRevision: 2, + platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/v2'] } } }], + })], '2000-01-02.1'); + const dir = writeCatalog(mkdtempSync(join(work, 'revisions-')), [r1, r2]); + assert.equal(cli('validate', '--catalog', dir).status, 0); + const args = ['--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a']; + const latest = cli('resolve', ...args); + const older = cli('resolve', '--revision', '2000-01-01.1', ...args); + assert.equal(latest.json.diagnostics.catalogRevision, '2000-01-02.1'); + assert.deepEqual(latest.json.policy.filesystem.readonlyPaths, ['/opt/v2']); + assert.equal(older.json.diagnostics.catalogRevision, '2000-01-01.1'); + assert.deepEqual(older.json.policy.filesystem.readonlyPaths, ['/opt/a']); + const missing = cli('resolve', '--revision', '2000-01-03.1', ...args); + assert.equal(missing.json.error.category, 'revision-unavailable'); + }); +}); + +describe('platform and architecture selection', () => { + for (const platform of PLATFORMS) { + for (const architecture of ARCHITECTURES) { + it(`${platform}/${architecture}: explicit architecture warns about neutral fallback but not about verification`, () => { + const result = cli('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git'); + assert.equal(result.status, 0, result.stderr); + assert.match(warningsOf(result), new RegExp(`tool:git uses its architecture-neutral ${platform} variant; no ${architecture}-specific variant exists`)); + assert.doesNotMatch(warningsOf(result), NOT_VERIFIED); + }); + + it(`${platform}/${architecture}: omitted architecture uses the injected native architecture and warns that the tool's was not verified`, () => { + // The installed library with a host whose native architecture is fixed, + // so every combination is exercised on any CI machine. + const host = { platform: (): Platform => platform, nativeArchitecture: () => architecture, symbol: () => undefined }; + const catalog = new lib.PolicyCatalog(lib.bundledCatalogStore(), host); + const c = PLATFORM_CONTEXT[platform]; + const ctx = { allowWeakIdentityFallback: true, projectRoot: c.root, symbols: { git_prefix: c.prefix } }; + const omitted = catalog.getSandboxConfigWithDiagnostics('git', ctx); + const warnings = omitted.diagnostics.warnings.join('\n'); + assert.match(warnings, NOT_VERIFIED); + assert.match(warnings, new RegExp(`native system architecture '${architecture}'`)); + assert.match(warnings, new RegExp(`no ${architecture}-specific variant exists`)); + const explicit = catalog.getSandboxConfigWithDiagnostics('git', { ...ctx, architecture, platform }); + assert.doesNotMatch(explicit.diagnostics.warnings.join('\n'), NOT_VERIFIED); + assert.deepEqual(explicit.policy, omitted.policy); + }); + } + + it(`${platform}: the installed CLI on this host warns when architecture is omitted`, () => { + const result = cli('resolve', '--diagnostics', '--platform', platform, '--allow-weak', ...symbolArgs(platform), 'git'); + assert.equal(result.status, 0, result.stderr); + assert.match(warningsOf(result), NOT_VERIFIED); + }); + } + + it('no warning about architecture is issued when nothing was selected', () => { + const result = cli('resolve', '--diagnostics', '--platform', 'linux', 'cargo'); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(warningsOf(result), /architecture/); + }); + + it('an exact-architecture variant wins and emits no neutral-fallback warning; another architecture is never a fallback', () => { + const dir = writeCatalog(mkdtempSync(join(work, 'arch-')), [revision([ + entry('tool:a', { + platformVariants: [ + { when: { platform: 'windows', architecture: 'arm64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}\\arm64'] } } }, + { when: { platform: 'windows', architecture: 'x64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}\\x64'] } } }, + { when: { platform: 'linux', architecture: 'x64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/x64'] } } }, + ], + }), + ])]); + const common = ['--catalog', dir, '--diagnostics', '--allow-weak']; + for (const architecture of ARCHITECTURES) { + const result = cli('resolve', ...common, '--platform', 'windows', '--architecture', architecture, '--symbol', 'git_prefix=C:\\t', 'a'); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.json.policy.filesystem.readonlyPaths, [`C:\\t\\${architecture}`]); + assert.doesNotMatch(warningsOf(result), /architecture-neutral/); + } + const noArm = cli('resolve', ...common, '--platform', 'linux', '--architecture', 'arm64', '--symbol', 'git_prefix=/t', 'a'); + assert.equal(noArm.status, 0, noArm.stderr); + assert.equal(noArm.json.policy, undefined); + assert.match(warningsOf(noArm), /tool:a has no variant for linux\/arm64/); + }); +}); From 48ae2ad764430bb84e6c9d6025202ac7add8d086 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 10:01:15 -0700 Subject: [PATCH 10/50] feat(policy-catalog)!: reuse MXC error codes and apply one per-OS casing rule Errors now carry an MXC MxcError code (policy_validation, malformed_request, unsupported_containment, backend_error) plus a stable details.reason. Warnings stay plain strings. Path comparison now folds case on macOS as well as Windows, matching invocation-name matching; Linux stays exact. Tests cover macOS path folding and Linux gh vs GH. --- policy-catalog/README.md | 18 ++++- .../conformance/fixtures/bundled-catalog.json | 8 +- .../fixtures/synthetic-catalog.json | 4 +- policy-catalog/docs/architecture.md | 75 ++++++++++++++----- policy-catalog/docs/contributing.md | 4 +- policy-catalog/scripts/validate-catalog.mjs | 2 +- policy-catalog/src/catalog.ts | 24 +++++- policy-catalog/src/cli.ts | 2 +- policy-catalog/src/errors.ts | 72 +++++++++++++----- policy-catalog/src/host.ts | 6 +- policy-catalog/src/index.ts | 7 +- policy-catalog/src/resolver.ts | 41 +++++----- policy-catalog/src/store.ts | 4 +- policy-catalog/src/validate.ts | 10 +-- policy-catalog/tests/functional/cli.test.ts | 4 +- .../tests/functional/integrity.test.ts | 18 ++--- .../tests/functional/resolution.test.ts | 16 ++-- policy-catalog/tests/unit/conformance.test.ts | 19 ++++- policy-catalog/tests/unit/helpers.ts | 8 +- policy-catalog/tests/unit/resolver.test.ts | 37 ++++++++- policy-catalog/tests/unit/store.test.ts | 20 ++--- 21 files changed, 276 insertions(+), 123 deletions(-) diff --git a/policy-catalog/README.md b/policy-catalog/README.md index fa81773cd..e9b4b6e6b 100644 --- a/policy-catalog/README.md +++ b/policy-catalog/README.md @@ -95,15 +95,25 @@ npx policy-catalog validate --catalog ./catalog --base-ref origin/main an empty input, when no input matched, or when a selected entry has an unresolved required symbol. `getSandboxConfigWithDiagnostics` returns `policy: undefined` with the diagnostics. -- Library failures throw `PolicyCatalogError` with a stable `category`. See - [architecture](docs/architecture.md#failure-categories). +- Library failures throw `PolicyCatalogError` with an MXC error `code` + (`policy_validation`, `malformed_request`, `unsupported_containment`, + `backend_error`) and a stable `details.reason`. Warnings are plain strings. + See [architecture](docs/architecture.md#failure-codes). ### Matching and selection - Matching is additive: every eligible matching entry contributes, and each contributes once. -- `purl` identity is strong. Invocation-name-only identity is weak and - participates only with `allowWeakIdentityFallback: true`. +- `purl` identity is strong. Invocation-name-only identity is weak and is + **off by default**; a caller opts in per call with + `allowWeakIdentityFallback: true` (CLI `--allow-weak`). +- The catalog records strong evidence (package URL, signer). **Verifying that + the binary about to run actually has that strong identity is the caller's + job**; the library cannot see the binary. +- Casing follows the target OS: Windows and macOS compare invocation names and + paths case-insensitively; Linux compares them exactly (`gh` is not `GH`). + Catalog validation always treats names case-insensitively when it checks for + duplicates. - An omitted `platform` uses the host. An omitted `architecture` uses the device's native system architecture, not the process architecture, and diagnostics warn that the tool's architecture was not verified. An omitted diff --git a/policy-catalog/conformance/fixtures/bundled-catalog.json b/policy-catalog/conformance/fixtures/bundled-catalog.json index d23d73e64..f7980a701 100644 --- a/policy-catalog/conformance/fixtures/bundled-catalog.json +++ b/policy-catalog/conformance/fixtures/bundled-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from getSandboxConfigWithDiagnostics(tools, context), and getSandboxConfig must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` names a PolicyCatalogError category. `tools` is a single input or an array.", + "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from getSandboxConfigWithDiagnostics(tools, context), and getSandboxConfig must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` is the failure's MXC error `code` and stable `details.reason`. `tools` is a single input or an array.", "catalog": "bundled", "cases": [ { @@ -199,19 +199,19 @@ "name": "unavailable explicit revision is an error, not a substitution", "tools": "git", "context": { "platform": "linux", "architecture": "x64", "catalogRevision": "2099-01-01.1" }, - "expectError": "revision-unavailable" + "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": "invalid-context" + "expectError": { "code": "malformed_request", "reason": "invalid_context" } }, { "name": "symbol values that make access classes overlap across tools are a composition conflict", "tools": ["git", "node"], "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/opt", "symbols": { "git_prefix": "/usr/bin", "node_prefix": "/opt/node" } }, - "expectError": "composition-conflict" + "expectError": { "code": "policy_validation", "reason": "composition_conflict" } } ] } diff --git a/policy-catalog/conformance/fixtures/synthetic-catalog.json b/policy-catalog/conformance/fixtures/synthetic-catalog.json index 7a8a9152a..ef6e8fbd2 100644 --- a/policy-catalog/conformance/fixtures/synthetic-catalog.json +++ b/policy-catalog/conformance/fixtures/synthetic-catalog.json @@ -271,13 +271,13 @@ "name": "network fields cannot compose across entries selected by different inputs", "tools": ["APP.cmd", "base"], "context": { "platform": "windows", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\p", "symbols": { "git_prefix": "C:\\g" } }, - "expectError": "composition-conflict" + "expectError": { "code": "policy_validation", "reason": "composition_conflict" } }, { "name": "symbol values that make access classes overlap are a composition conflict", "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/g", "symbols": { "git_prefix": "/g" } }, - "expectError": "composition-conflict" + "expectError": { "code": "policy_validation", "reason": "composition_conflict" } } ] } diff --git a/policy-catalog/docs/architecture.md b/policy-catalog/docs/architecture.md index c8951125b..364fd2f40 100644 --- a/policy-catalog/docs/architecture.md +++ b/policy-catalog/docs/architecture.md @@ -28,7 +28,7 @@ layers and ceilings, approval, audit, and the final sandbox creation. | Module | Responsibility | |---|---| | `src/types.ts` | Public types from design §5 (`ToolInput`, `ResolveContext`, `SandboxConfigResolution`, metadata). | -| `src/errors.ts` | `PolicyCatalogError` and its stable failure categories. | +| `src/errors.ts` | `PolicyCatalogError`: MXC error `code` plus stable `details.reason`. | | `src/canonical-json.ts` | Canonical JSON and SHA-256 used for revision integrity. | | `src/catalog.ts` | Contract and revision validation, variant selection, dependency closure, composition limits. | | `src/store.ts` | Manifest validation; lazy, digest-checked, deep-frozen revision loading. | @@ -44,18 +44,19 @@ layers and ceilings, approval, audit, and the final sandbox creation. One call resolves one input or an array of inputs in a single pass. A single input is treated exactly like a one-element array. -1. **Validate inputs and context.** Invalid input throws `invalid-context`. It +1. **Validate inputs and context.** Invalid input throws `malformed_request` + (`invalid_context`). It is never reported as absence. 2. **Load the revision.** Use the requested `catalogRevision`, or the - installed default. A missing revision is `revision-unavailable`, never a - substitution. A digest mismatch is `integrity`. + installed default. A missing revision is `backend_error` + (`revision_unavailable`), never a substitution. A digest mismatch is + `backend_error` (`integrity`). 3. **Match each input additively** (design §4.3). Entries are visited in `entryId` order, so catalog file order is irrelevant. For each entry, every satisfied predicate is collected in declaration order: - A `purl` match is strong. - An `invocation-name` match is weak and needs `allowWeakIdentityFallback`. - - Invocation names compare case-insensitively on Windows and macOS, and - case-sensitively on Linux. + - Invocation names follow the per-OS casing rule below. - A strong match never suppresses another entry's eligible weak match. 4. **Select a variant** (design §4.4). The exact architecture wins over the architecture-neutral variant, and another architecture is never used as a @@ -75,8 +76,33 @@ input is treated exactly like a one-element array. returns a partial policy. 8. **Compose.** Paths are substituted, normalized with the platform's path rules, and de-duplicated per access class in first-seen order. Equal or - ancestor/descendant paths in different classes throw - `composition-conflict`. The resolver never picks an access class. + ancestor/descendant paths in different classes throw `policy_validation` + (`composition_conflict`). The resolver never picks an access class. + +## Casing + +One rule per target platform (`foldsCase` in `src/catalog.ts`), used for both +invocation-name matching and path comparison (de-duplication and +cross-class overlap): + +- **Windows and macOS:** case-insensitive. Default NTFS and APFS/HFS+ + volumes resolve names this way. +- **Linux:** exact. `gh` and `GH` are different tools and different paths. + +Catalog validation intentionally differs: it treats invocation names +case-insensitively on every platform when it looks for a repeated identity +within an entry, so `["gh", "GH"]` is rejected even in a Linux-only entry. + +## Identity strength and the caller's verification duty + +Name-only (`invocation-name`) matching is **off by default**. A caller opts +in per call with `allowWeakIdentityFallback: true` (CLI `--allow-weak`). + +The catalog records *strong* evidence (package URL today; signer or package +ID where the design adds them). It cannot check that the binary about to run +actually carries that evidence. **Verifying that the executable really has +the strong identity passed in `packageUrl` is the caller's job.** A +`packageUrl` the caller did not verify is no stronger than a name. Diagnostics follow the design exactly: @@ -105,7 +131,7 @@ architecture ([design §4.4](design.md#44-platform-variants)). - **Linux:** `os.machine()`, the kernel machine type. Executables are invoked by absolute path. An unknown or unreadable value throws -`unsupported-host`. The resolver never guesses. The result is cached for the +`unsupported_containment` (`unsupported_host`). The resolver never guesses. The result is cached for the process. ## Integrity and immutability @@ -117,16 +143,27 @@ are deep-frozen. The validation pipeline compares proposed changes against a base git ref and rejects any change to a published revision's manifest entry or content ([design §10](design.md#10-immutable-revisions)). -## Failure categories - -| Category | Meaning | -|---|---| -| `integrity` | Catalog data cannot be read or does not match its published digest. | -| `validation` | Catalog, manifest, or contract data violates the catalog contract. | -| `revision-unavailable` | An explicitly requested revision is not installed. | -| `invalid-context` | The caller's inputs or `ResolveContext` are invalid. | -| `unsupported-host` | The host platform or native architecture cannot be mapped or determined. | -| `composition-conflict` | The selected entries cannot be composed under the v1 rules. | +## Failure codes + +Failures reuse MXC's existing `MxcError` codes (MXC `sdk/node/src/errors.ts`, +Rust `MxcErrorCode`) where the meaning fits; the library adds no new code. +A stable, snake_case sub-reason is carried in `details.reason`, following +MXC's convention of putting structured information in `details`. Conformance +fixtures and the cross-language check assert on both. Warnings are plain +strings, as they are everywhere in MXC. + +| `code` | `details.reason` | Meaning | +|---|---|---| +| `policy_validation` | `invalid_catalog` | Catalog, manifest, or contract data violates the catalog contract: shape, unknown symbol, dependency cycle, missing dependency, base-ref check. | +| `policy_validation` | `composition_conflict` | The selected entries, or their resolved paths, cannot be composed under the v1 rules. | +| `malformed_request` | `invalid_context` | The caller's inputs or `ResolveContext` are invalid. | +| `unsupported_containment` | `unsupported_host` | The host platform or native architecture cannot be mapped or determined. The resolver never guesses. | +| `backend_error` | `integrity` | Catalog data cannot be read, or does not match its published digest (tamper). | +| `backend_error` | `revision_unavailable` | An explicitly requested revision is not installed. | + +The CLI prints a failure as +`{"error": {"code": "...", "message": "...", "details": {"reason": "..."}}}` +and exits 1. Absence is not a failure. `getSandboxConfig` returns `undefined`, and `getSandboxConfigWithDiagnostics` returns `policy: undefined` with its diff --git a/policy-catalog/docs/contributing.md b/policy-catalog/docs/contributing.md index 6885faa05..9783a9cb5 100644 --- a/policy-catalog/docs/contributing.md +++ b/policy-catalog/docs/contributing.md @@ -83,8 +83,8 @@ Changes to the library API or behavior must keep the shared contract intact: - Any behavior visible across languages goes into `conformance/fixtures/`, and the change must describe what the Rust and C# bindings need to match ([design §6.2](design.md#62-cross-language-consistency-and-support)). -- Failure categories and absence semantics are part of the contract. Changing - them is a breaking change. +- Error codes, `details.reason` values, and absence semantics are part of the + contract. Changing them is a breaking change. - Run `npm run check` and `npm run check:extract -- --worktree` before opening a pull request. diff --git a/policy-catalog/scripts/validate-catalog.mjs b/policy-catalog/scripts/validate-catalog.mjs index 3e88f5ec7..5ed90e1dc 100644 --- a/policy-catalog/scripts/validate-catalog.mjs +++ b/policy-catalog/scripts/validate-catalog.mjs @@ -174,7 +174,7 @@ step('order independence', () => { try { return JSON.stringify(new PolicyCatalog(catalogStore).getSandboxConfig(tools, ctx)); } catch (error) { - return `error:${error.category ?? error.message}`; + return `error:${error.reason ?? error.message}`; } }; const a = run(store, names); diff --git a/policy-catalog/src/catalog.ts b/policy-catalog/src/catalog.ts index eeae99300..e4f8b9999 100644 --- a/policy-catalog/src/catalog.ts +++ b/policy-catalog/src/catalog.ts @@ -63,7 +63,7 @@ const SYMBOL_PATTERN = /\$\{([a-z][a-z0-9_]*)\}/g; const ANCHORED_SYMBOL = /^\$\{([a-z][a-z0-9_]*)\}(?:[\\/]|$)/; function fail(message: string): never { - throw new PolicyCatalogError('validation', message); + throw new PolicyCatalogError('invalid_catalog', message); } function isRecord(value: unknown): value is Record { @@ -181,12 +181,30 @@ export function policySymbols(policy: CatalogSandboxPolicy): string[] { return [...seen]; } -/** Splits a path into comparable segments using the platform's path rules. */ +/** + * The one casing rule for a target platform, shared by invocation-name + * matching and path comparison: Windows and macOS (default APFS/HFS+ volumes) + * resolve names case-insensitively; Linux resolves them exactly. + * + * Catalog validation deliberately does not use this rule. It folds invocation + * names on every platform, so a repeated name is caught even for an entry that + * is only ever served to a case-sensitive host. + */ +export function foldsCase(platform: CatalogPlatform): boolean { + return platform !== 'linux'; +} + +/** Applies {@link foldsCase} to one value (`toLowerCase` when the platform folds case). */ +export function caseKey(value: string, platform: CatalogPlatform): string { + return foldsCase(platform) ? value.toLowerCase() : value; +} + +/** Splits a path into comparable segments using the platform's path and casing rules. */ export function pathKeySegments(value: string, platform: CatalogPlatform): string[] { const separators = platform === 'windows' ? /[\\/]+/ : /\/+/; const segments = value.split(separators).filter((segment, index) => segment !== '.' && (segment !== '' || index === 0)); const normalized = segments.length > 1 && segments[segments.length - 1] === '' ? segments.slice(0, -1) : segments; - return platform === 'windows' ? normalized.map(segment => segment.toLowerCase()) : normalized; + return normalized.map(segment => caseKey(segment, platform)); } function isSameOrNested(left: string[], right: string[]): boolean { diff --git a/policy-catalog/src/cli.ts b/policy-catalog/src/cli.ts index 65fad94f5..c98490c0a 100644 --- a/policy-catalog/src/cli.ts +++ b/policy-catalog/src/cli.ts @@ -105,7 +105,7 @@ export function main(argv: string[]): number { return 2; } if (error instanceof PolicyCatalogError) { - print({ error: { category: error.category, message: error.message } }); + print({ error: { code: error.code, message: error.message, details: { reason: error.reason } } }); return 1; } throw error; diff --git a/policy-catalog/src/errors.ts b/policy-catalog/src/errors.ts index e0d766629..fb98d9d8f 100644 --- a/policy-catalog/src/errors.ts +++ b/policy-catalog/src/errors.ts @@ -2,30 +2,66 @@ // Licensed under the MIT License. /** - * Language-neutral library failure categories. Shared conformance fixtures - * assert on these values, so every language binding must preserve them. + * Error codes, reused from MXC's closed `MxcError` code set (MXC + * `sdk/node/src/errors.ts`, Rust `MxcErrorCode`) where the meaning fits. + * The library adds no code of its own. Warnings stay plain strings, as they + * do everywhere in MXC. + */ +export type PolicyCatalogErrorCode = + /** Catalog data or the selected composition violates the catalog contract. */ + | 'policy_validation' + /** The caller's ResolveContext or ToolCandidate is structurally invalid. */ + | 'malformed_request' + /** The host platform or native architecture has no catalog selector, or cannot be detected. */ + | 'unsupported_containment' + /** Installed catalog data is unavailable, unreadable, or fails integrity. */ + | 'backend_error'; + +/** + * Stable, language-neutral sub-reason carried in `details.reason`, following + * MXC's convention of putting structured information in `details`. Shared + * conformance fixtures and the cross-language check assert on + * `code` + `details.reason`, so every language binding must preserve both. * A library failure is never a "no match" result (design §5). */ -export type PolicyCatalogErrorCategory = - /** Bundled data does not match its published integrity digest. */ +export type PolicyCatalogErrorReason = + /** policy_validation: catalog, manifest, or contract data is invalid (including dependency cycles and unknown symbols). */ + | 'invalid_catalog' + /** policy_validation: selected entries or resolved paths cannot be composed. */ + | 'composition_conflict' + /** malformed_request: invalid ResolveContext or ToolCandidate. */ + | 'invalid_context' + /** unsupported_containment: host platform/architecture has no selector or cannot be detected. */ + | 'unsupported_host' + /** backend_error: bundled data does not match its published digest, or cannot be read. */ | 'integrity' - /** Catalog, manifest, or contract data violates the catalog contract. */ - | 'validation' - /** An explicitly requested catalog revision is not installed. */ - | 'revision-unavailable' - /** The caller supplied an invalid ResolveContext or ToolCandidate. */ - | 'invalid-context' - /** The host platform/architecture cannot be mapped to a catalog selector. */ - | 'unsupported-host' - /** Resolved paths would require choosing between access classes. */ - | 'composition-conflict'; + /** backend_error: an explicitly requested catalog revision is not installed. */ + | 'revision_unavailable'; + +/** The one code each reason maps to. */ +export const ERROR_CODE_FOR_REASON: Readonly> = { + invalid_catalog: 'policy_validation', + composition_conflict: 'policy_validation', + invalid_context: 'malformed_request', + unsupported_host: 'unsupported_containment', + integrity: 'backend_error', + revision_unavailable: 'backend_error', +}; export class PolicyCatalogError extends Error { - readonly category: PolicyCatalogErrorCategory; + readonly code: PolicyCatalogErrorCode; + readonly details: { readonly reason: PolicyCatalogErrorReason }; - constructor(category: PolicyCatalogErrorCategory, message: string) { - super(`[${category}] ${message}`); + constructor(reason: PolicyCatalogErrorReason, message: string) { + const code = ERROR_CODE_FOR_REASON[reason]; + super(`[${code}] ${message}`); this.name = 'PolicyCatalogError'; - this.category = category; + this.code = code; + this.details = Object.freeze({ reason }); + } + + /** The stable sub-reason; shorthand for `details.reason`. */ + get reason(): PolicyCatalogErrorReason { + return this.details.reason; } } diff --git a/policy-catalog/src/host.ts b/policy-catalog/src/host.ts index 77100de8b..d0f2e149d 100644 --- a/policy-catalog/src/host.ts +++ b/policy-catalog/src/host.ts @@ -29,7 +29,7 @@ function hostPlatform(): CatalogPlatform { case 'linux': return 'linux'; default: - throw new PolicyCatalogError('unsupported-host', `host platform '${os.platform()}' has no catalog selector`); + throw new PolicyCatalogError('unsupported_host', `host platform '${os.platform()}' has no catalog selector`); } } @@ -90,11 +90,11 @@ function detectNativeArchitecture(): CatalogArchitecture { reported = os.machine(); } } catch (error) { - throw new PolicyCatalogError('unsupported-host', `native system architecture could not be determined: ${(error as Error).message}`); + throw new PolicyCatalogError('unsupported_host', `native system architecture could not be determined: ${(error as Error).message}`); } const architecture = reported === undefined ? undefined : architectureFromMachine(reported); if (!architecture) { - throw new PolicyCatalogError('unsupported-host', `native system architecture '${reported ?? 'unknown'}' has no catalog selector`); + throw new PolicyCatalogError('unsupported_host', `native system architecture '${reported ?? 'unknown'}' has no catalog selector`); } return architecture; } diff --git a/policy-catalog/src/index.ts b/policy-catalog/src/index.ts index cd4f0e5aa..63149e68f 100644 --- a/policy-catalog/src/index.ts +++ b/policy-catalog/src/index.ts @@ -23,7 +23,12 @@ export { type ManifestRevision, } from './store.js'; -export { PolicyCatalogError, type PolicyCatalogErrorCategory } from './errors.js'; +export { + PolicyCatalogError, + ERROR_CODE_FOR_REASON, + type PolicyCatalogErrorCode, + type PolicyCatalogErrorReason, +} from './errors.js'; export type { CatalogArchitecture, diff --git a/policy-catalog/src/resolver.ts b/policy-catalog/src/resolver.ts index 92871720c..2b38f86f9 100644 --- a/policy-catalog/src/resolver.ts +++ b/policy-catalog/src/resolver.ts @@ -11,6 +11,7 @@ import { COMPOSABLE_FILESYSTEM_FIELDS, IDENTITY_STRENGTH, PLATFORMS, + caseKey, compositionViolation, dependencyClosure, findCrossClassOverlap, @@ -66,17 +67,17 @@ function compareStrings(left: string, right: string): number { function toCandidate(input: ToolInput, index: number): ToolCandidate { const candidate = typeof input === 'string' ? { invocationName: input } : input; if (candidate === null || typeof candidate !== 'object') { - throw new PolicyCatalogError('invalid-context', `tool input ${index} must be a string or a ToolCandidate`); + throw new PolicyCatalogError('invalid_context', `tool input ${index} must be a string or a ToolCandidate`); } if (typeof candidate.invocationName !== 'string' || candidate.invocationName.length === 0) { - throw new PolicyCatalogError('invalid-context', `tool input ${index}: invocationName must be a non-empty string`); + throw new PolicyCatalogError('invalid_context', `tool input ${index}: invocationName must be a non-empty string`); } if (/[\\/]/.test(candidate.invocationName)) { - throw new PolicyCatalogError('invalid-context', `tool input ${index}: invocationName must be a bare name, not a path`); + throw new PolicyCatalogError('invalid_context', `tool input ${index}: invocationName must be a bare name, not a path`); } for (const key of ['packageUrl', 'detectedVersion'] as const) { if (candidate[key] !== undefined && (typeof candidate[key] !== 'string' || candidate[key].length === 0)) { - throw new PolicyCatalogError('invalid-context', `tool input ${index}: ${key} must be a non-empty string when present`); + throw new PolicyCatalogError('invalid_context', `tool input ${index}: ${key} must be a non-empty string when present`); } } return candidate; @@ -198,7 +199,7 @@ export class PolicyCatalog { const closure = dependencyClosure(match.entry, match.selection, byId, platform, effectiveArchitecture()); if (!closure.ok) { // A validated revision cannot reach this; treat it as corrupt data. - throw new PolicyCatalogError('validation', `dependency resolution failed: ${closure.reason} (${closure.detail})`); + throw new PolicyCatalogError('invalid_catalog', `dependency resolution failed: ${closure.reason} (${closure.detail})`); } for (const node of closure.nodes) { if (!selected.has(node.entry.entryId)) { @@ -230,7 +231,7 @@ export class PolicyCatalog { const violation = compositionViolation(nodes); if (violation) { - throw new PolicyCatalogError('composition-conflict', `selected entries cannot be composed: ${violation}`); + throw new PolicyCatalogError('composition_conflict', `selected entries cannot be composed: ${violation}`); } const symbols = this.resolveSymbols(nodes, ctx, platform, warnings); @@ -258,20 +259,18 @@ export class PolicyCatalog { if (tool.packageUrl !== undefined) { purl = parsePurl(tool.packageUrl); if (!purl) { - throw new PolicyCatalogError('invalid-context', `${describeInput(inputIndex, tool)}: '${tool.packageUrl}' is not a valid package URL`); + throw new PolicyCatalogError('invalid_context', `${describeInput(inputIndex, tool)}: '${tool.packageUrl}' is not a valid package URL`); } } - // Windows and default macOS volumes resolve executables case-insensitively; - // Linux does not. - const fold = platform === 'linux' ? (value: string) => value : (value: string) => value.toLowerCase(); - const invocation = fold(tool.invocationName); + // The same casing rule as path comparison (catalog.ts `foldsCase`). + const invocation = caseKey(tool.invocationName, platform); const matches: EntryMatch[] = []; const skipped: string[] = []; for (const entry of ordered) { const satisfied = entry.identity.filter(predicate => (predicate.kind === 'purl' ? purl !== undefined && parsePurl(predicate.value)?.key === purl.key - : predicate.names.some(name => fold(name) === invocation))); + : predicate.names.some(name => caseKey(name, platform) === invocation))); if (satisfied.length === 0) { continue; } @@ -311,28 +310,28 @@ export class PolicyCatalog { private validateContext(ctx: ResolveContext): void { if (ctx === null || typeof ctx !== 'object') { - throw new PolicyCatalogError('invalid-context', 'ResolveContext must be an object'); + throw new PolicyCatalogError('invalid_context', 'ResolveContext must be an object'); } if (ctx.platform !== undefined && !PLATFORMS.includes(ctx.platform)) { - throw new PolicyCatalogError('invalid-context', `ResolveContext.platform '${String(ctx.platform)}' is unsupported`); + throw new PolicyCatalogError('invalid_context', `ResolveContext.platform '${String(ctx.platform)}' is unsupported`); } if (ctx.architecture !== undefined && !ARCHITECTURES.includes(ctx.architecture)) { - throw new PolicyCatalogError('invalid-context', `ResolveContext.architecture '${String(ctx.architecture)}' is unsupported`); + throw new PolicyCatalogError('invalid_context', `ResolveContext.architecture '${String(ctx.architecture)}' is unsupported`); } if (ctx.projectRoot !== undefined && (typeof ctx.projectRoot !== 'string' || ctx.projectRoot.length === 0)) { - throw new PolicyCatalogError('invalid-context', 'ResolveContext.projectRoot must be a non-empty string when present'); + throw new PolicyCatalogError('invalid_context', 'ResolveContext.projectRoot must be a non-empty string when present'); } const contract = this.store.contract; for (const [name, value] of Object.entries(ctx.symbols ?? {})) { const definition = contract.symbols[name]; if (!definition) { - throw new PolicyCatalogError('invalid-context', `ResolveContext.symbols.${name} is not a catalog symbol`); + throw new PolicyCatalogError('invalid_context', `ResolveContext.symbols.${name} is not a catalog symbol`); } if (definition.source === 'context') { - throw new PolicyCatalogError('invalid-context', `symbol '${name}' is supplied through ResolveContext.projectRoot, not symbols`); + throw new PolicyCatalogError('invalid_context', `symbol '${name}' is supplied through ResolveContext.projectRoot, not symbols`); } if (typeof value !== 'string') { - throw new PolicyCatalogError('invalid-context', `ResolveContext.symbols.${name} must be a string`); + throw new PolicyCatalogError('invalid_context', `ResolveContext.symbols.${name} must be a string`); } } } @@ -374,7 +373,7 @@ export class PolicyCatalog { continue; } if (value.includes('${') || !api.isAbsolute(value)) { - throw new PolicyCatalogError('invalid-context', `symbol '${name}' must resolve to an absolute ${platform} path`); + throw new PolicyCatalogError('invalid_context', `symbol '${name}' must resolve to an absolute ${platform} path`); } values.set(name, value); } @@ -441,7 +440,7 @@ function composePolicy( if (overlap) { // Symbol values can make distinct templates collide. The resolver never // chooses an access class implicitly. - throw new PolicyCatalogError('composition-conflict', `resolved paths overlap across access classes: ${overlap}`); + throw new PolicyCatalogError('composition_conflict', `resolved paths overlap across access classes: ${overlap}`); } const root = nodes[0].variant.sandboxPolicy; diff --git a/policy-catalog/src/store.ts b/policy-catalog/src/store.ts index 81ecc636d..dfaddf3aa 100644 --- a/policy-catalog/src/store.ts +++ b/policy-catalog/src/store.ts @@ -40,7 +40,7 @@ function isRecord(value: unknown): value is Record { } function invalid(message: string): never { - throw new PolicyCatalogError('validation', message); + throw new PolicyCatalogError('invalid_catalog', message); } function deepFreeze(value: T): T { @@ -141,7 +141,7 @@ export class CatalogStore { } const listed = this.manifest.revisions.find(revision => revision.catalogRevision === id); if (!listed) { - throw new PolicyCatalogError('revision-unavailable', `catalog revision '${id}' is not installed`); + throw new PolicyCatalogError('revision_unavailable', `catalog revision '${id}' is not installed`); } let raw: unknown; try { diff --git a/policy-catalog/src/validate.ts b/policy-catalog/src/validate.ts index 2b37e7e75..48ffe61c2 100644 --- a/policy-catalog/src/validate.ts +++ b/policy-catalog/src/validate.ts @@ -51,7 +51,7 @@ export function readPublishedStateAtRef(catalogDir: string, baseRef: string): Pu // git receives the ref as an argument; one that starts with '-' would be // parsed as an option, so it is rejected rather than passed through. if (baseRef.length === 0 || baseRef.startsWith('-') || /[\s\0]/.test(baseRef)) { - throw new PolicyCatalogError('validation', `base-ref check: '${baseRef}' is not a valid git ref`); + throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${baseRef}' is not a valid git ref`); } // .native expands Windows 8.3 short names (e.g. RUNNER~1 in CI temp dirs), // so the path compares correctly with git's long-form top-level path. @@ -60,14 +60,14 @@ export function readPublishedStateAtRef(catalogDir: string, baseRef: string): Pu try { top = realpathSync.native(git(['rev-parse', '--show-toplevel'], dir).trim()); } catch { - throw new PolicyCatalogError('validation', `base-ref check: '${catalogDir}' is not inside a git work tree`); + throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${catalogDir}' is not inside a git work tree`); } if (!gitSucceeds(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`], top)) { - throw new PolicyCatalogError('validation', `base-ref check: '${baseRef}' does not name a commit`); + throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${baseRef}' does not name a commit`); } const prefix = relative(top, dir).replaceAll('\\', '/'); if (prefix.startsWith('..') || isAbsolute(prefix)) { - throw new PolicyCatalogError('validation', `base-ref check: '${catalogDir}' is outside its git work tree`); + throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${catalogDir}' is outside its git work tree`); } const at = (file: string): string => `${baseRef}:${prefix === '' ? '' : `${prefix}/`}${file}`; if (!gitSucceeds(['cat-file', '-e', at('manifest.json')], top)) { @@ -120,7 +120,7 @@ export function checkAgainstBaseRef( } function describeError(error: unknown): string { - return error instanceof PolicyCatalogError ? error.message : `[validation] ${(error as Error).message}`; + return error instanceof PolicyCatalogError ? error.message : `[policy_validation] ${(error as Error).message}`; } /** diff --git a/policy-catalog/tests/functional/cli.test.ts b/policy-catalog/tests/functional/cli.test.ts index 1613ad9e8..8474d656e 100644 --- a/policy-catalog/tests/functional/cli.test.ts +++ b/policy-catalog/tests/functional/cli.test.ts @@ -108,7 +108,7 @@ describe('installed CLI: resolve against the bundled catalog', () => { assert.deepEqual(empty.json, { diagnostics: { catalogRevision: installedManifest.defaultRevision, tools: [], resolvedDependencies: [], warnings: [] } }); }); - it('invalid caller input is an invalid-context library failure (exit 1)', () => { + it('invalid caller input is a malformed_request (invalid_context) library failure (exit 1)', () => { const cases: string[][] = [ ['resolve', '--platform', 'plan9', 'git'], ['resolve', '--architecture', 'mips', 'git'], @@ -120,7 +120,7 @@ describe('installed CLI: resolve against the bundled catalog', () => { for (const args of cases) { const result = cli(...args); assert.equal(result.status, 1, `${args.join(' ')} -> ${result.stderr}`); - assert.equal(result.json.error.category, 'invalid-context', args.join(' ')); + assert.equal(result.json.error.details.reason, 'invalid_context', args.join(' ')); } }); }); diff --git a/policy-catalog/tests/functional/integrity.test.ts b/policy-catalog/tests/functional/integrity.test.ts index e6d17506a..bad340910 100644 --- a/policy-catalog/tests/functional/integrity.test.ts +++ b/policy-catalog/tests/functional/integrity.test.ts @@ -56,12 +56,12 @@ describe('integrity of a copied catalog', () => { const validate = cli('validate', '--catalog', dir); assert.equal(validate.status, 1, validate.stderr); assert.equal(validate.json?.ok, false, `validate produced no JSON report; stderr: ${validate.stderr}`); - assert.match(validate.json.errors.join('\n'), /\[integrity\] catalog revision '[^']+' digest [0-9a-f]{64} does not match the published digest/); + assert.match(validate.json.errors.join('\n'), /\[backend_error\] catalog revision '[^']+' digest [0-9a-f]{64} does not match the published digest/); const resolve = cli('resolve', '--catalog', dir, ...fullContext('windows', 'x64'), 'git'); assert.equal(resolve.status, 1, resolve.stderr); - assert.equal(resolve.json.error.category, 'integrity'); - assert.equal(cli('inspect', '--catalog', dir).json.error.category, 'integrity'); + assert.equal(resolve.json.error.details.reason, 'integrity'); + assert.equal(cli('inspect', '--catalog', dir).json.error.details.reason, 'integrity'); }); it('a missing revision file is an integrity failure', () => { @@ -69,8 +69,8 @@ describe('integrity of a copied catalog', () => { rmSync(latestFile(dir)); const result = cli('inspect', '--catalog', dir); assert.equal(result.status, 1); - assert.equal(result.json.error.category, 'integrity'); - assert.match(cli('validate', '--catalog', dir).json.errors.join('\n'), /\[integrity\].*could not be read/); + assert.equal(result.json.error.details.reason, 'integrity'); + assert.match(cli('validate', '--catalog', dir).json.errors.join('\n'), /\[backend_error\].*could not be read/); }); it('a manifest whose default names an unlisted revision is a validation failure', () => { @@ -78,10 +78,10 @@ describe('integrity of a copied catalog', () => { writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ ...installedManifest, defaultRevision: '2099-01-01.1' })); const result = cli('inspect', '--catalog', dir); assert.equal(result.status, 1); - assert.equal(result.json.error.category, 'validation'); + assert.equal(result.json.error.details.reason, 'invalid_catalog'); const validate = cli('validate', '--catalog', dir); assert.equal(validate.status, 1); - assert.match(validate.json.errors.join('\n'), /\[validation\] manifest\.defaultRevision must name a listed revision/); + assert.match(validate.json.errors.join('\n'), /\[policy_validation\] manifest\.defaultRevision must name a listed revision/); }); }); @@ -104,7 +104,7 @@ describe('dependency cycles', () => { const result = cli('validate', '--catalog', dir); assert.equal(result.status, 1, result.stderr); assert.equal(result.json?.ok, false, `validate produced no JSON report; stderr: ${result.stderr}`); - assert.match(result.json.errors.join('\n'), /\[validation\] 'tool:a' on linux\/x64: cycle \(tool:a -> tool:b -> tool:c -> tool:a\)/); + assert.match(result.json.errors.join('\n'), /\[policy_validation\] 'tool:a' on linux\/x64: cycle \(tool:a -> tool:b -> tool:c -> tool:a\)/); }); it('resolve never returns a policy from a cyclic catalog', () => { @@ -114,7 +114,7 @@ describe('dependency cycles', () => { ])]); const result = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'); assert.equal(result.status, 1, result.stderr); - assert.equal(result.json?.error?.category, 'validation', `stderr: ${result.stderr}`); + assert.equal(result.json?.error?.details?.reason, 'invalid_catalog', `stderr: ${result.stderr}`); assert.match(result.json.error.message, /cycle \(tool:a -> tool:b -> tool:a\)/); }); diff --git a/policy-catalog/tests/functional/resolution.test.ts b/policy-catalog/tests/functional/resolution.test.ts index 35ef98e6f..bc41b151e 100644 --- a/policy-catalog/tests/functional/resolution.test.ts +++ b/policy-catalog/tests/functional/resolution.test.ts @@ -146,12 +146,12 @@ describe('unknown tools', () => { }); describe('composition conflicts', () => { - it('overlapping resolved paths across access classes are a composition-conflict failure, not a choice', () => { + it('overlapping resolved paths across access classes are a policy_validation (composition_conflict) failure, not a choice', () => { // project_root=/opt (read-write for git) contains node_prefix=/opt/node (read-only for node). const result = cli('resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'); assert.equal(result.status, 1, result.stderr); - assert.equal(result.json.error.category, 'composition-conflict'); + assert.equal(result.json.error.details.reason, 'composition_conflict'); assert.match(result.json.error.message, /overlap across access classes/); }); @@ -172,7 +172,7 @@ describe('composition conflicts', () => { assert.deepEqual(alone.json.network, { egress: { default: 'deny' } }); const both = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a', 'b'); assert.equal(both.status, 1, both.stderr); - assert.equal(both.json.error.category, 'composition-conflict'); + assert.equal(both.json.error.details.reason, 'composition_conflict'); assert.match(both.json.error.message, /'tool:b' uses 'network', which has no v1 cross-entry composition rule/); }); @@ -185,20 +185,20 @@ describe('composition conflicts', () => { ])]); const both = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a', 'b'); assert.equal(both.status, 1, both.stderr); - assert.equal(both.json.error.category, 'composition-conflict'); + assert.equal(both.json.error.details.reason, 'composition_conflict'); assert.match(both.json.error.message, /mixed sandboxPolicy\.version values/); }); }); describe('catalog revisions', () => { - it('an unavailable explicit revision is a revision-unavailable failure, never a substitution', () => { + it('an unavailable explicit revision is a backend_error (revision_unavailable) failure, never a substitution', () => { const result = cli('resolve', '--revision', '1999-01-01.1', ...fullContext('linux', 'x64'), 'git'); assert.equal(result.status, 1, result.stderr); - assert.equal(result.json.error.category, 'revision-unavailable'); + assert.equal(result.json.error.details.reason, 'revision_unavailable'); assert.match(result.json.error.message, /'1999-01-01\.1' is not installed/); assert.throws( () => lib.getSandboxConfig('git', { catalogRevision: '1999-01-01.1', platform: 'linux', architecture: 'x64' }), - (error: any) => error instanceof lib.PolicyCatalogError && error.category === 'revision-unavailable', + (error: any) => error instanceof lib.PolicyCatalogError && error.reason === 'revision_unavailable', ); }); @@ -218,7 +218,7 @@ describe('catalog revisions', () => { assert.equal(older.json.diagnostics.catalogRevision, '2000-01-01.1'); assert.deepEqual(older.json.policy.filesystem.readonlyPaths, ['/opt/a']); const missing = cli('resolve', '--revision', '2000-01-03.1', ...args); - assert.equal(missing.json.error.category, 'revision-unavailable'); + assert.equal(missing.json.error.details.reason, 'revision_unavailable'); }); }); diff --git a/policy-catalog/tests/unit/conformance.test.ts b/policy-catalog/tests/unit/conformance.test.ts index 37ba0177f..091414e4a 100644 --- a/policy-catalog/tests/unit/conformance.test.ts +++ b/policy-catalog/tests/unit/conformance.test.ts @@ -5,7 +5,20 @@ // Other language bindings must run the same files with the same expectations. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { bundledCatalog, catalogFor, errorCategory, fixedHost, fixtureFiles, fixturesDir, readJson } from './helpers.js'; +import { PolicyCatalogError } from '@mxc-prototype/policy-catalog'; +import { bundledCatalog, catalogFor, fixedHost, fixtureFiles, fixturesDir, readJson } from './helpers.js'; + +function failure(fn: () => unknown): { code: string; reason: string } | undefined { + try { + fn(); + } catch (error) { + if (error instanceof PolicyCatalogError) { + return { code: error.code, reason: error.details.reason }; + } + throw error; + } + return undefined; +} function toExpected(expect: any): any { return { ...expect, policy: expect.policy ?? undefined }; @@ -19,8 +32,8 @@ for (const name of fixtureFiles()) { const host = fixedHost(testCase.host?.platform ?? 'linux', testCase.host?.nativeArchitecture ?? 'x64'); const catalog = fixture.catalog === 'bundled' ? bundledCatalog(host) : catalogFor(fixture.catalog, host); if (testCase.expectError !== undefined) { - assert.equal(errorCategory(() => catalog.getSandboxConfigWithDiagnostics(testCase.tools, testCase.context)), testCase.expectError); - assert.equal(errorCategory(() => catalog.getSandboxConfig(testCase.tools, testCase.context)), testCase.expectError); + assert.deepEqual(failure(() => catalog.getSandboxConfigWithDiagnostics(testCase.tools, testCase.context)), testCase.expectError); + assert.deepEqual(failure(() => catalog.getSandboxConfig(testCase.tools, testCase.context)), testCase.expectError); return; } const expected = toExpected(testCase.expect); diff --git a/policy-catalog/tests/unit/helpers.ts b/policy-catalog/tests/unit/helpers.ts index 539c6c2ff..724339e12 100644 --- a/policy-catalog/tests/unit/helpers.ts +++ b/policy-catalog/tests/unit/helpers.ts @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { CatalogStore, + ERROR_CODE_FOR_REASON, PolicyCatalog, PolicyCatalogError, bundledCatalogStore, @@ -68,12 +70,14 @@ export function fixtureFiles(): string[] { return readdirSync(fixturesDir).filter(name => name.endsWith('.json')).sort(); } -export function errorCategory(fn: () => unknown): string | undefined { +/** The failure's stable `details.reason`, after checking that `code` is the reason's MXC code. */ +export function errorReason(fn: () => unknown): string | undefined { try { fn(); } catch (error) { if (error instanceof PolicyCatalogError) { - return error.category; + assert.equal(error.code, ERROR_CODE_FOR_REASON[error.reason]); + return error.reason; } throw error; } diff --git a/policy-catalog/tests/unit/resolver.test.ts b/policy-catalog/tests/unit/resolver.test.ts index 80e65d43e..4dc9e342b 100644 --- a/policy-catalog/tests/unit/resolver.test.ts +++ b/policy-catalog/tests/unit/resolver.test.ts @@ -11,7 +11,7 @@ import { listCatalogEntries, } from '@mxc-prototype/policy-catalog'; import { architectureFromMachine } from '@mxc-prototype/policy-catalog/tooling'; -import { bundledCatalog, catalogFor, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; +import { bundledCatalog, catalogFor, entry, errorReason, fixedHost, revisionWith, storeFor } from './helpers.js'; const v = '0.9.0-alpha'; const weak = { allowWeakIdentityFallback: true } as const; @@ -124,7 +124,7 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { ['git', { projectRoot: '' }], ]; for (const [tools, ctx] of cases) { - assert.equal(errorCategory(() => catalog.getSandboxConfig(tools as any, ctx as any)), 'invalid-context', JSON.stringify([tools, ctx])); + assert.equal(errorReason(() => catalog.getSandboxConfig(tools as any, ctx as any)), 'invalid_context', JSON.stringify([tools, ctx])); } }); @@ -157,6 +157,37 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { assert.deepEqual(linux.getSandboxConfig('l', { ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } })?.filesystem, { readonlyPaths: ['/Tools', '/tools'] }); }); + it('macOS folds path case like Windows (one casing rule per OS)', () => { + const mac = catalogFor(revisionWith([ + entry('tool:m', { platformVariants: [{ when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}/'] } } }] }), + ])); + const ctx = { platform: 'macos', architecture: 'arm64', ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } } as const; + assert.deepEqual(mac.getSandboxConfig('m', ctx)?.filesystem, { readonlyPaths: ['/Tools'] }); + // Overlap across access classes is also detected case-insensitively on macOS. + const overlap = catalogFor(revisionWith([ + entry('tool:m', { platformVariants: [{ when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}'], readwritePaths: ['${project_root}'] } } }] }), + ])); + assert.equal(errorReason(() => overlap.getSandboxConfig('m', { ...ctx, projectRoot: '/tools/work' })), 'composition_conflict'); + // The same values on Linux are distinct paths, so there is no overlap. + const linux = catalogFor(revisionWith([ + entry('tool:m', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}'], readwritePaths: ['${project_root}'] } } }] }), + ])); + assert.deepEqual(linux.getSandboxConfig('m', { ...weak, platform: 'linux', architecture: 'x64', projectRoot: '/tools/work', symbols: { git_prefix: '/Tools' } })?.filesystem, + { readonlyPaths: ['/Tools'], readwritePaths: ['/tools/work'] }); + }); + + it('invocation names: Linux is exact (gh is not GH); Windows and macOS fold case', () => { + const catalog = catalogFor(revisionWith([ + entry('tool:gh', { platformVariants: ['linux', 'macos', 'windows'].map(platform => ({ when: { platform }, sandboxPolicy: { version: v } })) }), + ])); + const on = (platform: 'linux' | 'macos' | 'windows', name: string) => + catalog.getSandboxConfigWithDiagnostics(name, { ...weak, platform, architecture: 'x64' }).diagnostics.tools[0].matches.map(m => m.entryId); + assert.deepEqual(on('linux', 'gh'), ['tool:gh']); + assert.deepEqual(on('linux', 'GH'), []); + assert.deepEqual(on('macos', 'GH'), ['tool:gh']); + assert.deepEqual(on('windows', 'Gh'), ['tool:gh']); + }); + it('dependency chain resolution composes each entry once, in deterministic order', () => { const variant = (deps: string[], path: string) => ({ when: { platform: 'linux' }, @@ -205,7 +236,7 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.8.0-alpha' } }] }), entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), ])); - assert.equal(errorCategory(() => catalog.getSandboxConfig(['a', 'b'], weak)), 'composition-conflict'); + assert.equal(errorReason(() => catalog.getSandboxConfig(['a', 'b'], weak)), 'composition_conflict'); // Each tool on its own is fine. assert.deepEqual(catalog.getSandboxConfig('a', weak), { version: '0.8.0-alpha' }); }); diff --git a/policy-catalog/tests/unit/store.test.ts b/policy-catalog/tests/unit/store.test.ts index 9d47a5610..669b1d8ab 100644 --- a/policy-catalog/tests/unit/store.test.ts +++ b/policy-catalog/tests/unit/store.test.ts @@ -13,7 +13,7 @@ import { validateContract, validateCatalogRevision, } from '@mxc-prototype/policy-catalog/tooling'; -import { contract, entry, errorCategory, fixedHost, revisionWith, storeFor } from './helpers.js'; +import { contract, entry, errorReason, fixedHost, revisionWith, storeFor } from './helpers.js'; const parsedContract = validateContract(contract); @@ -33,12 +33,12 @@ describe('integrity', () => { it('a tampered revision fails with an integrity error, never a no-match', () => { const revision = revisionWith([entry('tool:a')]); const store = storeFor([revision], { digests: { '2000-01-01.1': '0'.repeat(64) } }); - assert.equal(errorCategory(() => store.revision()), 'integrity'); + assert.equal(errorReason(() => store.revision()), 'integrity'); const catalog = new PolicyCatalog(store, fixedHost()); - assert.equal(errorCategory(() => catalog.getSandboxConfig('a', { allowWeakIdentityFallback: true })), 'integrity'); - assert.equal(errorCategory(() => catalog.getSandboxConfigWithDiagnostics('a', { allowWeakIdentityFallback: true })), 'integrity'); - assert.equal(errorCategory(() => catalog.listCatalogEntries()), 'integrity'); - assert.equal(errorCategory(() => catalog.getCatalogInfo()), 'integrity'); + assert.equal(errorReason(() => catalog.getSandboxConfig('a', { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorReason(() => catalog.getSandboxConfigWithDiagnostics('a', { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorReason(() => catalog.listCatalogEntries()), 'integrity'); + assert.equal(errorReason(() => catalog.getCatalogInfo()), 'integrity'); }); it('a revision file that declares a different revision id fails integrity', () => { @@ -46,13 +46,13 @@ describe('integrity', () => { const digest = canonicalSha256(revision); const store = storeFor([{ ...revision, catalogRevision: '2000-01-01.1' }], { digests: { '2000-01-01.1': digest } }); // The digest was computed for other content, so this is caught as tampering first. - assert.equal(errorCategory(() => store.revision()), 'integrity'); + assert.equal(errorReason(() => store.revision()), 'integrity'); }); it('invalid manifest data is a validation error', () => { const revision = revisionWith([entry('tool:a')]); - assert.equal(errorCategory(() => storeFor([revision], { defaultRevision: '2001-01-01.1' })), 'validation'); - assert.equal(errorCategory(() => storeFor([revisionWith([entry('tool:a')], '2000-01-02.1'), revision])), 'validation'); + assert.equal(errorReason(() => storeFor([revision], { defaultRevision: '2001-01-01.1' })), 'invalid_catalog'); + assert.equal(errorReason(() => storeFor([revisionWith([entry('tool:a')], '2000-01-02.1'), revision])), 'invalid_catalog'); }); }); @@ -69,7 +69,7 @@ describe('versioning and immutable revisions', () => { }; assert.deepEqual(revisionOf(), ['2000-01-02.1', 2]); assert.deepEqual(revisionOf({ catalogRevision: '2000-01-01.1' }), ['2000-01-01.1', 1]); - assert.equal(errorCategory(() => catalog.getSandboxConfig('a', { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision-unavailable'); + assert.equal(errorReason(() => catalog.getSandboxConfig('a', { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision_unavailable'); }); it('loaded revisions are deeply frozen (read-only)', () => { From 188765cf1fc6533091d91d11c83439759f2dbac1 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 10:23:01 -0700 Subject: [PATCH 11/50] test(policy-catalog): pin path and canonical-JSON behavior as shared cross-language vectors Adds conformance/vectors (generated by scripts/generate-vectors.mjs) and a unit suite that checks TypeScript against them. Also hardens symbol lookups against prototype keys and makes a malformed percent-encoded package URL invalid instead of throwing URIError. --- .../conformance/vectors/canonical-json.json | 50 ++ policy-catalog/conformance/vectors/paths.json | 475 ++++++++++++++++++ policy-catalog/scripts/generate-vectors.mjs | 55 ++ policy-catalog/src/catalog.ts | 21 +- policy-catalog/src/cli.ts | 3 +- policy-catalog/src/purl.ts | 8 +- policy-catalog/src/resolver.ts | 21 +- policy-catalog/src/tooling.ts | 4 + policy-catalog/tests/unit/vectors.test.ts | 47 ++ 9 files changed, 665 insertions(+), 19 deletions(-) create mode 100644 policy-catalog/conformance/vectors/canonical-json.json create mode 100644 policy-catalog/conformance/vectors/paths.json create mode 100644 policy-catalog/scripts/generate-vectors.mjs create mode 100644 policy-catalog/tests/unit/vectors.test.ts diff --git a/policy-catalog/conformance/vectors/canonical-json.json b/policy-catalog/conformance/vectors/canonical-json.json new file mode 100644 index 000000000..070c20519 --- /dev/null +++ b/policy-catalog/conformance/vectors/canonical-json.json @@ -0,0 +1,50 @@ +{ + "$comment": "Generated by scripts/generate-vectors.mjs. Canonical JSON: object keys sorted by UTF-16 code units, arrays in order, no whitespace, JSON.stringify string escaping, and ECMAScript Number::toString for every number. sha256 is lower-case hex over the UTF-8 canonical text. Parse `json`, canonicalize, and compare both fields.", + "cases": [ + { + "json": "{\"b\":1,\"a\":[3,2,{\"d\":null,\"c\":true}]}", + "canonical": "{\"a\":[3,2,{\"c\":true,\"d\":null}],\"b\":1}", + "sha256": "e507298c9bbce4feaa4f44b2c7294c0b9e11f4941cab71ba4ee91b42c26639d4" + }, + { + "json": "{\"\\u00e9\":1,\"z\":2,\"Z\":3,\"\\ue000\":4,\"\\ud83d\\ude00\":5,\"\":6}", + "canonical": "{\"\":6,\"Z\":3,\"z\":2,\"é\":1,\"😀\":5,\"\":4}", + "sha256": "ce29d5e391d3137cee4bdd579370a200608300049e8faa13f74af2fa9b697ff3" + }, + { + "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 é 😀\",\"/\",\"
\"]", + "sha256": "814d3e90acdbabd93908ab69f5537ec84652f7f72ae47d3d0b1d09ae22699d23" + }, + { + "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]", + "sha256": "f7dba9ebc15ae29db49f82357f7defe2c6e0e170c4776295d5eece73ec43f5ae" + }, + { + "json": "{\"nested\":{\"k\":[],\"o\":{}}}", + "canonical": "{\"nested\":{\"k\":[],\"o\":{}}}", + "sha256": "c94ffb132d7a743e86a5152622b085cab59d4133fd98fa9fa429f3607eb7772b" + }, + { + "json": "\"plain\"", + "canonical": "\"plain\"", + "sha256": "945603a8f587786b463c3f94fce115c0fae88fac2728cc96ddf5981cf7f61741" + }, + { + "json": "42", + "canonical": "42", + "sha256": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049" + }, + { + "json": "false", + "canonical": "false", + "sha256": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa" + }, + { + "json": "null", + "canonical": "null", + "sha256": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + } + ] +} diff --git a/policy-catalog/conformance/vectors/paths.json b/policy-catalog/conformance/vectors/paths.json new file mode 100644 index 000000000..9c9088127 --- /dev/null +++ b/policy-catalog/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/policy-catalog/scripts/generate-vectors.mjs b/policy-catalog/scripts/generate-vectors.mjs new file mode 100644 index 000000000..b6edb921f --- /dev/null +++ b/policy-catalog/scripts/generate-vectors.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Regenerates conformance/vectors/*.json from the TypeScript reference +// implementation (run `npm run build` first). The Rust and C# bindings test +// their path normalization and canonical JSON against these files, so the +// three languages cannot drift apart silently. +// +// npm run build && node scripts/generate-vectors.mjs +// +// Non-ASCII inputs are written with \u escapes so this file stays ASCII. +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { isAbsolutePath, normalizePath, pathKeySegments } from '../dist/catalog.js'; +import { canonicalJson, canonicalSha256 } from '../dist/canonical-json.js'; + +const out = name => fileURLToPath(new URL(`../conformance/vectors/${name}`, import.meta.url)); + +const windows = [ + 'C:\\Tools', 'c:\\tools\\', 'C:\\a\\.\\b\\..\\c', 'C:/a//b/', 'C:\\', 'C:\\\\', 'C:\\..\\x', + '\\\\server\\share\\x\\', '\\\\server\\share', 'D:\\src', 'C:\\Program Files\\Git\\cmd', + 'C:\\a\\..\\..\\b', 'C:\\x\\y\\.', '/usr/bin', '\\rooted\\', 'C:\\\u00c9t\u00e9', 'C:rel', 'rel\\x', '', +]; +const posix = ['/usr/bin', '/usr//bin/', '/a/./b/../c', '/', '//', '///x', '/Users/dev/app/', '/a/../../b', '/x/y/.', '/Tools', '/..', '/\u00c9t\u00e9', 'rel/x', './x', '']; +const row = (input, platform) => ({ + input, + absolute: isAbsolutePath(input, platform), + normalized: input !== '' && isAbsolutePath(input, platform) ? normalizePath(input, platform) : null, + keySegments: pathKeySegments(input, platform), +}); +writeFileSync(out('paths.json'), `${JSON.stringify({ + $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: windows.map(v => row(v, 'windows')), + linux: posix.map(v => row(v, 'linux')), + macos: posix.map(v => row(v, 'macos')), +}, null, 2)}\n`); + +// Each case is raw JSON text so a parser sees the original tokens. +const texts = [ + '{"b":1,"a":[3,2,{"d":null,"c":true}]}', + '{"\\u00e9":1,"z":2,"Z":3,"\\ue000":4,"\\ud83d\\ude00":5,"":6}', + '["tab\\t","nl\\n","q\\"","bs\\\\","ctl\\u0001\\u001f\\u007f","uni \\u00e9 \\ud83d\\ude00","/","\\u2028"]', + '[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]', + '{"nested":{"k":[],"o":{}}}', + '"plain"', + '42', + 'false', + 'null', +]; +writeFileSync(out('canonical-json.json'), `${JSON.stringify({ + $comment: 'Generated by scripts/generate-vectors.mjs. Canonical JSON: object keys sorted by UTF-16 code units, arrays in order, no whitespace, JSON.stringify string escaping, and ECMAScript Number::toString for every number. sha256 is lower-case hex over the UTF-8 canonical text. Parse `json`, canonicalize, and compare both fields.', + cases: texts.map(json => ({ json, canonical: canonicalJson(JSON.parse(json)), sha256: canonicalSha256(JSON.parse(json)) })), +}, null, 2)}\n`); +console.log('wrote conformance/vectors/paths.json and conformance/vectors/canonical-json.json'); diff --git a/policy-catalog/src/catalog.ts b/policy-catalog/src/catalog.ts index e4f8b9999..5aa3e90c0 100644 --- a/policy-catalog/src/catalog.ts +++ b/policy-catalog/src/catalog.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import * as path from 'node:path'; import { PolicyCatalogError } from './errors.js'; import { isValidVersionRange } from './version-range.js'; import { parsePurl } from './purl.js'; @@ -154,7 +155,7 @@ function validateTemplatePath(value: string, at: string, contract: CatalogContra fail(`'${at}' contains malformed symbol syntax`); } for (const match of value.matchAll(SYMBOL_PATTERN)) { - if (!(match[1] in contract.symbols)) { + if (!Object.hasOwn(contract.symbols, match[1])) { fail(`'${at}' references unknown symbol '${match[1]}'`); } } @@ -199,6 +200,24 @@ export function caseKey(value: string, platform: CatalogPlatform): string { return foldsCase(platform) ? value.toLowerCase() : value; } +/** + * Normalizes an absolute resolved path with the platform's path rules (Node + * `path.win32` / `path.posix` `normalize`), then removes trailing separators + * unless the path is only its root. Other language bindings reproduce this + * exactly; `conformance/vectors/paths.json` pins the behavior. + */ +export function normalizePath(value: string, platform: CatalogPlatform): string { + const api = platform === 'windows' ? path.win32 : path.posix; + const normalized = api.normalize(value); + const root = api.parse(normalized).root; + return normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized; +} + +/** Whether `value` is absolute under the platform's path rules (Node `isAbsolute`). */ +export function isAbsolutePath(value: string, platform: CatalogPlatform): boolean { + return (platform === 'windows' ? path.win32 : path.posix).isAbsolute(value); +} + /** Splits a path into comparable segments using the platform's path and casing rules. */ export function pathKeySegments(value: string, platform: CatalogPlatform): string[] { const separators = platform === 'windows' ? /[\\/]+/ : /\/+/; diff --git a/policy-catalog/src/cli.ts b/policy-catalog/src/cli.ts index c98490c0a..b8bed0bba 100644 --- a/policy-catalog/src/cli.ts +++ b/policy-catalog/src/cli.ts @@ -114,7 +114,8 @@ export function main(argv: string[]): number { function resolveCommand(args: string[], catalogDir: string | undefined): number { const ctx: ResolveContext = {}; - const symbols: Record = {}; + // Null prototype: a '__proto__' name must stay an ordinary (rejected) key. + const symbols: Record = Object.create(null); const tools: Array = []; let diagnostics = false; let purl: string | undefined; diff --git a/policy-catalog/src/purl.ts b/policy-catalog/src/purl.ts index 70f56512c..8947c7ca8 100644 --- a/policy-catalog/src/purl.ts +++ b/policy-catalog/src/purl.ts @@ -22,7 +22,13 @@ export function parsePurl(value: string): ParsedPurl | undefined { const at = rest.lastIndexOf('@'); let version: string | undefined; if (at > lastSlash) { - version = decodeURIComponent(rest.slice(at + 1)); + // Malformed percent-encoding makes the package URL invalid; it must not + // escape as a URIError instead of a library result. + try { + version = decodeURIComponent(rest.slice(at + 1)); + } catch { + return undefined; + } rest = rest.slice(0, at); } const segments = rest.split('/'); diff --git a/policy-catalog/src/resolver.ts b/policy-catalog/src/resolver.ts index 2b38f86f9..eb227d921 100644 --- a/policy-catalog/src/resolver.ts +++ b/policy-catalog/src/resolver.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import * as path from 'node:path'; import { PolicyCatalogError } from './errors.js'; import { nodeHostEnvironment, type HostEnvironment } from './host.js'; import { parsePurl, type ParsedPurl } from './purl.js'; @@ -15,6 +14,8 @@ import { compositionViolation, dependencyClosure, findCrossClassOverlap, + isAbsolutePath, + normalizePath, pathKeySegments, policySymbols, selectVariant, @@ -49,17 +50,6 @@ interface EntryMatch { const SYMBOL_PATTERN = /\$\{([a-z][a-z0-9_]*)\}/g; -function pathApi(platform: CatalogPlatform): path.PlatformPath { - return platform === 'windows' ? path.win32 : path.posix; -} - -function normalizePath(value: string, platform: CatalogPlatform): string { - const api = pathApi(platform); - const normalized = api.normalize(value); - const root = api.parse(normalized).root; - return normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized; -} - function compareStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } @@ -323,7 +313,7 @@ export class PolicyCatalog { } const contract = this.store.contract; for (const [name, value] of Object.entries(ctx.symbols ?? {})) { - const definition = contract.symbols[name]; + const definition = Object.hasOwn(contract.symbols, name) ? contract.symbols[name] : undefined; if (!definition) { throw new PolicyCatalogError('invalid_context', `ResolveContext.symbols.${name} is not a catalog symbol`); } @@ -348,7 +338,6 @@ export class PolicyCatalog { warnings: string[], ): Map | undefined { const contract = this.store.contract; - const api = pathApi(platform); const values = new Map(); const missing = new Map(); for (const node of nodes) { @@ -361,7 +350,7 @@ export class PolicyCatalog { if (definition.source === 'context') { value = ctx.projectRoot; } else { - value = ctx.symbols?.[name]; + value = ctx.symbols !== undefined && Object.hasOwn(ctx.symbols, name) ? ctx.symbols[name] : undefined; // Host-derived symbols describe the current host only; they are // never derived for an explicitly different target platform. if (value === undefined && definition.source === 'host' && platform === this.host.platform()) { @@ -372,7 +361,7 @@ export class PolicyCatalog { missing.set(name, [...(missing.get(name) ?? []), node.entry.entryId]); continue; } - if (value.includes('${') || !api.isAbsolute(value)) { + if (value.includes('${') || !isAbsolutePath(value, platform)) { throw new PolicyCatalogError('invalid_context', `symbol '${name}' must resolve to an absolute ${platform} path`); } values.set(name, value); diff --git a/policy-catalog/src/tooling.ts b/policy-catalog/src/tooling.ts index 50c551ada..d23840b81 100644 --- a/policy-catalog/src/tooling.ts +++ b/policy-catalog/src/tooling.ts @@ -10,6 +10,10 @@ export { validateContract, compareCatalogRevisions, selectVariant, + normalizePath, + isAbsolutePath, + pathKeySegments, + foldsCase, PLATFORMS, ARCHITECTURES, type CatalogContract, diff --git a/policy-catalog/tests/unit/vectors.test.ts b/policy-catalog/tests/unit/vectors.test.ts new file mode 100644 index 000000000..4e34c00bd --- /dev/null +++ b/policy-catalog/tests/unit/vectors.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// The shared vectors in conformance/vectors/ are generated from this +// implementation (scripts/generate-vectors.mjs) and consumed by the Rust and +// C# bindings. This suite fails when the TypeScript behavior drifts from the +// committed vectors, so a behavior change must regenerate them deliberately. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { canonicalJson, canonicalSha256, isAbsolutePath, normalizePath, parsePurl, pathKeySegments } from '@mxc-prototype/policy-catalog/tooling'; +import { readJson } from './helpers.js'; + +const vectorsDir = new URL('../../../conformance/vectors/', import.meta.url); + +describe('shared vectors: canonical JSON', () => { + const { cases } = readJson(new URL('canonical-json.json', vectorsDir)); + for (const [index, c] of cases.entries()) { + it(`case ${index}: ${c.json.slice(0, 40)}`, () => { + const value = JSON.parse(c.json); + assert.equal(canonicalJson(value), c.canonical); + assert.equal(canonicalSha256(value), c.sha256); + }); + } +}); + +describe('shared vectors: paths', () => { + const vectors = readJson(new URL('paths.json', vectorsDir)); + for (const platform of ['windows', 'linux', 'macos'] as const) { + it(`${platform}: isAbsolute, normalize, and case-folded key segments`, () => { + assert.ok(vectors[platform].length > 5); + for (const v of vectors[platform]) { + assert.equal(isAbsolutePath(v.input, platform), v.absolute, v.input); + if (v.normalized !== null) { + assert.equal(normalizePath(v.input, platform), v.normalized, v.input); + } + assert.deepEqual(pathKeySegments(v.input, platform), v.keySegments, v.input); + } + }); + } +}); + +describe('package URLs', () => { + it('malformed percent-encoding is an invalid package URL, not an exception', () => { + assert.equal(parsePurl('pkg:npm/npm@%E0%A4%A'), undefined); + assert.deepEqual(parsePurl('pkg:npm/npm@10.9.0'), { key: 'npm/npm', version: '10.9.0' }); + }); +}); From a7104d32e698eb6efd8d2734bc8323e902dcbfdc Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 12:30:48 -0700 Subject: [PATCH 12/50] feat(policy-catalog): add native Rust crate mxc-policy-catalog Standalone Cargo workspace under policy-catalog/rust/ with get_sandbox_config, get_sandbox_config_with_diagnostics, catalog inspection, validation tooling, and a policy-catalog CLI (resolve | inspect | validate) matching the TypeScript CLI. Embeds the bundled catalog, passes the shared conformance fixtures and vectors, and adds functional tests that consume the packaged .crate from a consumer outside the repository. --- policy-catalog/.gitignore | 11 + policy-catalog/rust/.gitignore | 7 + policy-catalog/rust/Cargo.lock | 93 ++ policy-catalog/rust/Cargo.toml | 43 + policy-catalog/rust/README.md | 52 + policy-catalog/rust/build.rs | 96 ++ policy-catalog/rust/functional/Cargo.toml.in | 17 + policy-catalog/rust/functional/src/lib.rs | 191 +++ .../rust/functional/tests/functional.rs | 441 +++++++ policy-catalog/rust/rust-toolchain.toml | 6 + policy-catalog/rust/rustfmt.toml | 4 + policy-catalog/rust/scripts/sync-catalog.mjs | 27 + policy-catalog/rust/src/bin/policy-catalog.rs | 22 + policy-catalog/rust/src/catalog.rs | 1042 ++++++++++++++++ policy-catalog/rust/src/cli.rs | 230 ++++ policy-catalog/rust/src/errors.rs | 158 +++ policy-catalog/rust/src/history.rs | 177 +++ policy-catalog/rust/src/host.rs | 322 +++++ policy-catalog/rust/src/json.rs | 793 +++++++++++++ policy-catalog/rust/src/lib.rs | 70 ++ policy-catalog/rust/src/model.rs | 573 +++++++++ policy-catalog/rust/src/paths.rs | 517 ++++++++ policy-catalog/rust/src/purl.rs | 100 ++ policy-catalog/rust/src/resolver.rs | 644 ++++++++++ policy-catalog/rust/src/store.rs | 414 +++++++ policy-catalog/rust/src/text.rs | 95 ++ policy-catalog/rust/src/validate.rs | 293 +++++ policy-catalog/rust/src/version_range.rs | 198 ++++ policy-catalog/rust/tests/common/mod.rs | 148 +++ policy-catalog/rust/tests/conformance.rs | 96 ++ policy-catalog/rust/tests/library.rs | 1048 +++++++++++++++++ policy-catalog/rust/tests/vectors.rs | 117 ++ policy-catalog/scripts/rust-functional.mjs | 123 ++ 33 files changed, 8168 insertions(+) create mode 100644 policy-catalog/rust/.gitignore create mode 100644 policy-catalog/rust/Cargo.lock create mode 100644 policy-catalog/rust/Cargo.toml create mode 100644 policy-catalog/rust/README.md create mode 100644 policy-catalog/rust/build.rs create mode 100644 policy-catalog/rust/functional/Cargo.toml.in create mode 100644 policy-catalog/rust/functional/src/lib.rs create mode 100644 policy-catalog/rust/functional/tests/functional.rs create mode 100644 policy-catalog/rust/rust-toolchain.toml create mode 100644 policy-catalog/rust/rustfmt.toml create mode 100644 policy-catalog/rust/scripts/sync-catalog.mjs create mode 100644 policy-catalog/rust/src/bin/policy-catalog.rs create mode 100644 policy-catalog/rust/src/catalog.rs create mode 100644 policy-catalog/rust/src/cli.rs create mode 100644 policy-catalog/rust/src/errors.rs create mode 100644 policy-catalog/rust/src/history.rs create mode 100644 policy-catalog/rust/src/host.rs create mode 100644 policy-catalog/rust/src/json.rs create mode 100644 policy-catalog/rust/src/lib.rs create mode 100644 policy-catalog/rust/src/model.rs create mode 100644 policy-catalog/rust/src/paths.rs create mode 100644 policy-catalog/rust/src/purl.rs create mode 100644 policy-catalog/rust/src/resolver.rs create mode 100644 policy-catalog/rust/src/store.rs create mode 100644 policy-catalog/rust/src/text.rs create mode 100644 policy-catalog/rust/src/validate.rs create mode 100644 policy-catalog/rust/src/version_range.rs create mode 100644 policy-catalog/rust/tests/common/mod.rs create mode 100644 policy-catalog/rust/tests/conformance.rs create mode 100644 policy-catalog/rust/tests/library.rs create mode 100644 policy-catalog/rust/tests/vectors.rs create mode 100644 policy-catalog/scripts/rust-functional.mjs diff --git a/policy-catalog/.gitignore b/policy-catalog/.gitignore index 1ab415f57..40a10b247 100644 --- a/policy-catalog/.gitignore +++ b/policy-catalog/.gitignore @@ -1,3 +1,14 @@ node_modules/ dist/ *.tgz +# Rust build output and packages (see also rust/.gitignore) +/rust/target/ +*.crate +# .NET build output and packages (see also dotnet/.gitignore; bin/ and obj/ +# are scoped there so rust/src/bin/ stays tracked) +*.nupkg +*.snupkg +TestResults/ +# The enclosing MXC repository ignores every `[Bb]in/`; Cargo binary targets +# live in rust/src/bin/, so re-include it (no effect once extracted). +!/rust/src/bin/ diff --git a/policy-catalog/rust/.gitignore b/policy-catalog/rust/.gitignore new file mode 100644 index 000000000..9f6bb2c28 --- /dev/null +++ b/policy-catalog/rust/.gitignore @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +target/ +# Copy of ../catalog made by scripts/sync-catalog.mjs for `cargo package`. +/catalog/ +*.crate diff --git a/policy-catalog/rust/Cargo.lock b/policy-catalog/rust/Cargo.lock new file mode 100644 index 000000000..6ba014eb7 --- /dev/null +++ b/policy-catalog/rust/Cargo.lock @@ -0,0 +1,93 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "mxc-policy-catalog" +version = "0.0.0-prototype" +dependencies = [ + "libc", + "sha2", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" diff --git a/policy-catalog/rust/Cargo.toml b/policy-catalog/rust/Cargo.toml new file mode 100644 index 000000000..86577115f --- /dev/null +++ b/policy-catalog/rust/Cargo.toml @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "mxc-policy-catalog" +version = "0.0.0-prototype" +edition = "2021" +rust-version = "1.93" +license = "MIT" +publish = false +description = "PROTOTYPE of the known-tool policy catalog library (native Rust). Not published; the crate name is a placeholder until a dedicated repository exists." +build = "build.rs" +# Only these files ship in the .crate. `catalog/` is git-ignored (a copy of +# ../catalog made by scripts/sync-catalog.mjs) and must be listed here. +include = [ + "Cargo.toml", + "Cargo.lock", + "build.rs", + "README.md", + "rust-toolchain.toml", + "src/**/*.rs", + "catalog/contract.v1.json", + "catalog/manifest.json", + "catalog/revisions/*.json", +] + +# A standalone workspace: never part of an enclosing Cargo workspace. +[workspace] + +[lib] +name = "mxc_policy_catalog" +path = "src/lib.rs" + +[[bin]] +name = "policy-catalog" +path = "src/bin/policy-catalog.rs" + +[dependencies] +sha2 = "0.11" + +[target.'cfg(unix)'.dependencies] +# uname(2) for the kernel machine type (Node `os.machine()`), Linux/macOS only. +libc = "0.2" diff --git a/policy-catalog/rust/README.md b/policy-catalog/rust/README.md new file mode 100644 index 000000000..e88d5e629 --- /dev/null +++ b/policy-catalog/rust/README.md @@ -0,0 +1,52 @@ +# mxc-policy-catalog (Rust) — PROTOTYPE + +A native Rust implementation of the policy catalog library. It behaves the same +as the TypeScript library in `../src`. It is not a wrapper, and it needs no Node +at runtime. The catalog is compiled into the crate. + +- Library: `mxc_policy_catalog` provides `get_sandbox_config`, `get_sandbox_config_with_diagnostics`, + `get_catalog_info`, `list_catalog_entries`, and `PolicyCatalog` (a store plus an injectable + `HostEnvironment`). Contribution and CI rules are in `mxc_policy_catalog::tooling`. +- Binary: `policy-catalog resolve | inspect | validate`. Its output matches `node dist/cli.js`. + +Run every command below from this `rust/` directory. The toolchain is pinned in +`rust-toolchain.toml` (1.93, with clippy and rustfmt). + +## Build and test + +```sh +cargo build +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test # unit + ../conformance fixtures + ../conformance/vectors +``` + +## Bundled catalog + +The source of truth is `../catalog`. `build.rs` embeds it with `include_str!` +whenever it is present. Inside a packaged `.crate` (detected by +`Cargo.toml.orig`), `build.rs` embeds the crate-local `catalog/` instead. +`catalog/` is a git-ignored copy that `scripts/sync-catalog.mjs` writes. It is +listed in `Cargo.toml` `include`, so it ships in the `.crate`. A test checks +that the embedded data equals `../catalog` (canonical JSON). + +## Package + +```sh +node scripts/sync-catalog.mjs +cargo package # builds and verifies the packaged crate in isolation +``` + +## Functional tests (packaged crate) + +```sh +node ../scripts/rust-functional.mjs +``` + +The driver syncs the catalog and runs `cargo package`. It then extracts the +`.crate` into a temporary directory outside the repository and builds the +packaged `policy-catalog` binary from the extracted crate. Next, it creates a +consumer crate from `functional/` (`Cargo.toml.in`, `src/`, `tests/`) that +depends on the extracted crate by path. Finally, it runs the consumer's tests, +which use both the library API and the packaged CLI. Every build uses a +`CARGO_TARGET_DIR` inside the temporary directory. diff --git a/policy-catalog/rust/build.rs b/policy-catalog/rust/build.rs new file mode 100644 index 000000000..9e035aee3 --- /dev/null +++ b/policy-catalog/rust/build.rs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Embeds the catalog into the crate. +//! +//! Source of truth: `../catalog` (the repository's `policy-catalog/catalog`). +//! It is used when present and this is not a packaged crate. A packaged +//! crate (identified by the `Cargo.toml.orig` cargo writes into every +//! `.crate`) always uses its crate-local `catalog/`, which +//! `scripts/sync-catalog.mjs` copies from `../catalog` before +//! `cargo package`; that directory is git-ignored and listed in +//! `Cargo.toml` `include` so it ships in the `.crate`. + +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo_catalog = manifest_dir.join("..").join("catalog"); + let local_catalog = manifest_dir.join("catalog"); + let packaged = manifest_dir.join("Cargo.toml.orig").exists(); + + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", local_catalog.display()); + println!("cargo:rerun-if-changed={}", repo_catalog.display()); + + let source = if !packaged && repo_catalog.join("manifest.json").is_file() { + repo_catalog + } else if local_catalog.join("manifest.json").is_file() { + local_catalog + } else { + panic!( + "no catalog to embed: expected {} (repository) or {} (run `node scripts/sync-catalog.mjs` before `cargo package`)", + repo_catalog.display(), + local_catalog.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("bundled_catalog.rs"); + fs::write(out, code).expect("write bundled_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/policy-catalog/rust/functional/Cargo.toml.in b/policy-catalog/rust/functional/Cargo.toml.in new file mode 100644 index 000000000..8114730dc --- /dev/null +++ b/policy-catalog/rust/functional/Cargo.toml.in @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Template for the temporary consumer crate built by scripts/rust-functional.mjs. +# @CRATE_PATH@ is replaced with the extracted .crate directory (outside the repository). +[package] +name = "policy-catalog-functional-consumer" +version = "0.0.0" +edition = "2021" +license = "MIT" +publish = false + +# Standalone: never part of an enclosing workspace. +[workspace] + +[dependencies] +mxc-policy-catalog = { path = "@CRATE_PATH@" } diff --git a/policy-catalog/rust/functional/src/lib.rs b/policy-catalog/rust/functional/src/lib.rs new file mode 100644 index 000000000..e78595839 --- /dev/null +++ b/policy-catalog/rust/functional/src/lib.rs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Helpers for the packaged-crate functional tests. Everything resolves the +//! library from the extracted `.crate` (a `path` dependency outside the +//! repository) and the CLI from the binary built from that same crate +//! (`POLICY_CATALOG_BIN`). Nothing reads the repository. + +use mxc_policy_catalog::tooling::{bundled_catalog_files, canonical_sha256, Json, JsonObject}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Result of one CLI run. +#[derive(Debug)] +pub struct CliResult { + pub status: i32, + pub json: Option, + pub stdout: String, + pub stderr: String, +} + +impl CliResult { + pub fn warnings(&self) -> String { + self.json + .as_ref() + .and_then(|j| j.get("diagnostics")) + .and_then(|d| d.get("warnings")) + .and_then(Json::as_array) + .map(|w| w.iter().filter_map(Json::as_str).collect::>().join("\n")) + .unwrap_or_default() + } + + pub fn error_reason(&self) -> Option { + self.json + .as_ref()? + .get("error")? + .get("details")? + .get("reason")? + .as_str() + .map(str::to_string) + } + + pub fn errors(&self) -> String { + self.json + .as_ref() + .and_then(|j| j.get("errors")) + .and_then(Json::as_array) + .map(|e| e.iter().filter_map(Json::as_str).collect::>().join("\n")) + .unwrap_or_default() + } +} + +/// The packaged `policy-catalog` binary. +pub fn cli_path() -> PathBuf { + PathBuf::from(std::env::var("POLICY_CATALOG_BIN").expect("POLICY_CATALOG_BIN is set by scripts/rust-functional.mjs")) +} + +/// Runs the packaged CLI in a separate process. +pub fn cli(args: &[&str]) -> CliResult { + let output = Command::new(cli_path()).args(args).output().expect("run policy-catalog"); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let json = if stdout.trim().is_empty() { None } else { Json::parse(&stdout).ok() }; + CliResult { + status: output.status.code().unwrap_or(-1), + json, + stdout, + stderr, + } +} + +static COUNTER: AtomicUsize = AtomicUsize::new(0); + +/// A fresh directory under the driver's work directory (outside the repository). +pub fn work_dir(prefix: &str) -> PathBuf { + let base = std::env::var("POLICY_CATALOG_WORK").map_or_else(|_| std::env::temp_dir(), PathBuf::from); + let dir = base.join(format!( + "{prefix}-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +pub fn j(text: &str) -> Json { + Json::parse(text).unwrap_or_else(|e| panic!("{e}: {text}")) +} + +pub fn s(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// Writes the catalog shipped inside the packaged crate, byte for byte. +pub fn copy_bundled(dir: &Path) -> PathBuf { + for (name, text) in bundled_catalog_files() { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + dir.to_path_buf() +} + +/// Writes a complete catalog directory (packaged contract, correct digests), +/// so the only defect is the one a test introduces. +pub fn write_catalog(dir: &Path, revisions: &[Json]) -> PathBuf { + std::fs::create_dir_all(dir.join("revisions")).unwrap(); + let contract = bundled_catalog_files().into_iter().find(|(n, _)| *n == "contract.v1.json").unwrap().1; + std::fs::write(dir.join("contract.v1.json"), contract).unwrap(); + let mut listed = Vec::new(); + for revision in revisions { + let id = revision.get("catalogRevision").and_then(Json::as_str).unwrap(); + let file = format!("revisions/{id}.json"); + std::fs::write(dir.join(&file), format!("{}\n", revision.to_pretty_string())).unwrap(); + listed.push(j(&format!( + r#"{{"catalogRevision":"{id}","file":"{file}","sha256":"{}"}}"#, + canonical_sha256(revision) + ))); + } + let mut manifest = JsonObject::new(); + manifest.insert("catalogSchemaVersion", "1".into()); + let last = revisions.last().unwrap().get("catalogRevision").unwrap().clone(); + manifest.insert("defaultRevision", last); + manifest.insert("revisions", Json::Array(listed)); + std::fs::write(dir.join("manifest.json"), Json::Object(manifest).to_pretty_string()).unwrap(); + dir.to_path_buf() +} + +/// Minimal valid entry (linux, readonly `${git_prefix}/`) with 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}", + "identity":[{{"kind":"invocation-name","names":["{name}"]}}], + "platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readonlyPaths":["${{git_prefix}}/{name}"]}}}}}}], + "provenance":{{"method":"functional-test","sourceRevision":"functional-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 revision(entries: Vec, id: &str) -> Json { + let mut o = JsonObject::new(); + o.insert("catalogSchemaVersion", "1".into()); + o.insert("catalogRevision", id.into()); + o.insert("entries", Json::Array(entries)); + Json::Object(o) +} + +/// Resolve flags with every bundled symbol for `platform`. +pub fn full_context(platform: &str, architecture: &str) -> Vec { + let (root, prefix, cache) = match platform { + "windows" => ("C:\\work\\app", "C:\\tools", "C:\\cache\\npm"), + "macos" => ("/Users/dev/app", "/opt/homebrew/bin", "/Users/dev/.npm"), + _ => ("/work/app", "/opt/tools", "/var/cache/npm"), + }; + let mut args: Vec = vec!["--platform".into(), platform.into()]; + if !architecture.is_empty() { + args.extend(["--architecture".into(), architecture.into()]); + } + args.extend( + [ + "--allow-weak", + "--project-root", + root, + "--symbol", + &format!("git_prefix={prefix}"), + "--symbol", + &format!("node_prefix={prefix}"), + "--symbol", + &format!("npm_prefix={prefix}"), + "--symbol", + &format!("npm_cache={cache}"), + ] + .map(str::to_string), + ); + args +} + +pub fn args<'a>(parts: &'a [&'a str], extra: &'a [String]) -> Vec<&'a str> { + let mut all: Vec<&str> = parts.to_vec(); + all.extend(extra.iter().map(String::as_str)); + all +} diff --git a/policy-catalog/rust/functional/tests/functional.rs b/policy-catalog/rust/functional/tests/functional.rs new file mode 100644 index 000000000..035c0bd65 --- /dev/null +++ b/policy-catalog/rust/functional/tests/functional.rs @@ -0,0 +1,441 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Functional tests of the PACKAGED crate: the library comes from the +//! extracted `.crate` and the CLI is the binary built from it. Every catalog +//! here is a synthetic directory or a copy in a temporary folder. + +use mxc_policy_catalog::tooling::Json; +use mxc_policy_catalog::{ + load_catalog_directory, Architecture, ErrorReason, FixedHost, Platform, PolicyCatalog, ResolveContext, + ToolCandidate, +}; +use policy_catalog_functional_consumer::*; +use std::sync::Arc; + +fn catalog_at(dir: &std::path::Path, platform: Platform, architecture: Architecture) -> PolicyCatalog { + PolicyCatalog::with_host( + Arc::new(load_catalog_directory(dir).unwrap()), + Arc::new(FixedHost::new(platform, architecture)), + ) +} + +fn depends_on(target: &str) -> String { + let name = target.split(':').nth(1).unwrap(); + format!( + r#"{{"platformVariants":[{{"when":{{"platform":"linux"}},"dependencies":[{{"entryId":"{target}"}}],"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readonlyPaths":["${{git_prefix}}/{name}-dep"]}}}}}}]}}"# + ) +} + +// --------------------------------------------------------------------------- +// Unknown tool +// --------------------------------------------------------------------------- + +#[test] +fn unknown_tool_is_no_policy_with_warning() { + let ctx = full_context("linux", "x64"); + let result = cli(&args(&["resolve", "--diagnostics"], &ctx).into_iter().chain(["cargo"]).collect::>()); + assert_eq!(result.status, 0, "{}", result.stderr); + let json = result.json.as_ref().unwrap(); + assert!(json.get("policy").is_none(), "policy key must be omitted"); + assert_eq!(json.get("diagnostics").unwrap().get("tools").unwrap(), &j(r#"[{"inputIndex":0,"matches":[]}]"#)); + assert!(result.warnings().lines().any(|l| l == "input 0 ('cargo') matched no eligible catalog entry")); + let plain = cli(&args(&["resolve"], &ctx).into_iter().chain(["cargo"]).collect::>()); + assert_eq!(plain.stdout, "null\n"); + // Library API over the bundled catalog. + let r = mxc_policy_catalog::get_sandbox_config_with_diagnostics( + "cargo", + &ResolveContext::new().platform(Platform::Linux).architecture(Architecture::X64).allow_weak(true), + ) + .unwrap(); + assert_eq!(r.policy, None); +} + +// --------------------------------------------------------------------------- +// Dependency cycle +// --------------------------------------------------------------------------- + +#[test] +fn dependency_cycle_fails_validate_and_resolve() { + let dir = write_catalog( + &work_dir("cycle"), + &[revision( + vec![ + entry("tool:a", &depends_on("tool:b")), + entry("tool:b", &depends_on("tool:c")), + entry("tool:c", &depends_on("tool:a")), + ], + "2000-01-01.1", + )], + ); + let validate = cli(&["validate", "--catalog", &s(&dir)]); + assert_eq!(validate.status, 1, "{}", validate.stdout); + assert_eq!(validate.json.as_ref().unwrap().get("ok"), Some(&Json::Bool(false))); + assert!( + validate + .errors() + .contains("[policy_validation] 'tool:a' on linux/x64: cycle (tool:a -> tool:b -> tool:c -> tool:a)"), + "{}", + validate.errors() + ); + let resolve = cli(&[ + "resolve", "--catalog", &s(&dir), "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", + "git_prefix=/opt", "a", + ]); + assert_eq!(resolve.status, 1, "{}", resolve.stdout); + let error = resolve.json.as_ref().unwrap().get("error").unwrap(); + assert_eq!(error.get("code").and_then(Json::as_str), Some("policy_validation")); + assert_eq!(resolve.error_reason().as_deref(), Some("invalid_catalog")); + // Library API. + let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); + let err = catalog + .get_sandbox_config("a", &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) + .unwrap_err(); + assert_eq!(err.reason(), ErrorReason::InvalidCatalog); + assert!(err.message().contains("cycle ("), "{}", err.message()); +} + +#[test] +fn diamond_is_valid_and_contributes_shared_entry_once() { + let dir = write_catalog( + &work_dir("diamond"), + &[revision( + vec![ + entry( + "tool:a", + r#"{"platformVariants":[{"when":{"platform":"linux"},"dependencies":[{"entryId":"tool:b"},{"entryId":"tool:c"}],"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}/a"]}}}]}"#, + ), + entry("tool:b", &depends_on("tool:d")), + entry("tool:c", &depends_on("tool:d")), + entry("tool:d", ""), + ], + "2000-01-01.1", + )], + ); + assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); + let r = cli(&[ + "resolve", "--catalog", &s(&dir), "--diagnostics", "--platform", "linux", "--architecture", "x64", + "--allow-weak", "--symbol", "git_prefix=/opt", "a", + ]); + assert_eq!(r.status, 0, "{}", r.stderr); + let json = r.json.unwrap(); + assert_eq!( + json.get("policy").unwrap().get("filesystem").unwrap().get("readonlyPaths").unwrap(), + &j(r#"["/opt/a","/opt/d-dep","/opt/d"]"#) + ); +} + +// --------------------------------------------------------------------------- +// Integrity tamper +// --------------------------------------------------------------------------- + +#[test] +fn integrity_tamper_fails_resolve_inspect_and_validate() { + let dir = copy_bundled(&work_dir("tamper")); + let untouched = cli(&["validate", "--catalog", &s(&dir)]); + assert_eq!(untouched.status, 0, "{}", untouched.stdout); + let manifest = j(&std::fs::read_to_string(dir.join("manifest.json")).unwrap()); + let file = manifest.get("revisions").unwrap().as_array().unwrap().last().unwrap().get("file").unwrap(); + let path = dir.join(file.as_str().unwrap()); + // Formatting-only change keeps the digest valid. + let original = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, j(&original).to_compact_string().replace(',', ",\r\n")).unwrap(); + assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); + // Semantic widening: the tamper integrity checking exists to stop. + let widened = original.replacen("\"readwritePaths\": [\"${project_root}\"]", "\"readwritePaths\": [\"${project_root}\", \"${user_home}\"]", 1); + assert_ne!(widened, original, "tamper edit applied"); + std::fs::write(&path, widened).unwrap(); + + let validate = cli(&["validate", "--catalog", &s(&dir)]); + assert_eq!(validate.status, 1, "{}", validate.stdout); + assert_eq!(validate.json.as_ref().unwrap().get("ok"), Some(&Json::Bool(false))); + assert!(validate.errors().contains("does not match the published digest"), "{}", validate.errors()); + let ctx = full_context("windows", "x64"); + let resolve = cli(&args(&["resolve", "--catalog", &s(&dir)], &ctx).into_iter().chain(["git"]).collect::>()); + assert_eq!(resolve.status, 1, "{}", resolve.stdout); + assert_eq!(resolve.error_reason().as_deref(), Some("integrity")); + let error = resolve.json.as_ref().unwrap().get("error").unwrap(); + assert_eq!(error.get("code").and_then(Json::as_str), Some("backend_error")); + let inspect = cli(&["inspect", "--catalog", &s(&dir)]); + assert_eq!(inspect.status, 1); + assert_eq!(inspect.error_reason().as_deref(), Some("integrity")); + // Library API. + let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); + assert_eq!(catalog.list_catalog_entries().unwrap_err().reason(), ErrorReason::Integrity); +} + +#[test] +fn missing_revision_file_is_integrity() { + let dir = copy_bundled(&work_dir("missing")); + for entry in std::fs::read_dir(dir.join("revisions")).unwrap() { + std::fs::remove_file(entry.unwrap().path()).unwrap(); + } + let inspect = cli(&["inspect", "--catalog", &s(&dir)]); + assert_eq!(inspect.status, 1); + assert_eq!(inspect.error_reason().as_deref(), Some("integrity")); + assert!(cli(&["validate", "--catalog", &s(&dir)]).errors().contains("could not be read")); +} + +// --------------------------------------------------------------------------- +// Unavailable revision +// --------------------------------------------------------------------------- + +#[test] +fn unavailable_revision_is_an_error_never_a_substitution() { + let ctx = full_context("linux", "x64"); + let r = cli(&args(&["resolve", "--revision", "2099-01-01.1"], &ctx).into_iter().chain(["git"]).collect::>()); + assert_eq!(r.status, 1); + assert_eq!(r.error_reason().as_deref(), Some("revision_unavailable")); + let message = r.json.as_ref().unwrap().get("error").unwrap().get("message").and_then(Json::as_str).unwrap(); + assert_eq!(message, "[backend_error] catalog revision '2099-01-01.1' is not installed"); + let err = mxc_policy_catalog::get_sandbox_config("git", &ResolveContext::new().catalog_revision("1999-01-01.1")).unwrap_err(); + assert_eq!(err.reason(), ErrorReason::RevisionUnavailable); + + // An older installed revision stays addressable. + let r1 = revision(vec![entry("tool:a", "")], "2000-01-01.1"); + let r2 = revision( + vec![entry( + "tool:a", + r#"{"entryRevision":2,"platformVariants":[{"when":{"platform":"linux"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}/v2"]}}}]}"#, + )], + "2000-01-02.1", + ); + let dir = write_catalog(&work_dir("revisions"), &[r1, r2]); + assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); + let base = ["resolve", "--catalog", &s(&dir), "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt"]; + let latest = cli(&[&base[..], &["a"]].concat()); + assert_eq!(latest.json.unwrap().get("filesystem").unwrap(), &j(r#"{"readonlyPaths":["/opt/v2"]}"#)); + let older = cli(&[&base[..], &["--revision", "2000-01-01.1", "a"]].concat()); + assert_eq!(older.json.unwrap().get("filesystem").unwrap(), &j(r#"{"readonlyPaths":["/opt/a"]}"#)); + let missing = cli(&[&base[..], &["--revision", "2000-01-03.1", "a"]].concat()); + assert_eq!(missing.error_reason().as_deref(), Some("revision_unavailable")); +} + +// --------------------------------------------------------------------------- +// Weak identity opt-in +// --------------------------------------------------------------------------- + +#[test] +fn weak_identity_requires_opt_in() { + let symbols = [ + "--platform", "linux", "--architecture", "x64", "--project-root", "/w", "--symbol", "git_prefix=/usr/bin", + ]; + let off = cli(&[&["resolve", "--diagnostics"], &symbols[..], &["git"]].concat()); + assert_eq!(off.status, 0); + assert!(off.json.as_ref().unwrap().get("policy").is_none()); + assert!(off.warnings().contains( + "input 0 ('git') matched no eligible catalog entry: tool:git matched only by invocation name and allowWeakIdentityFallback is not enabled" + )); + assert_eq!(cli(&[&["resolve"], &symbols[..], &["git"]].concat()).stdout, "null\n"); + let on = cli(&[&["resolve", "--diagnostics", "--allow-weak"], &symbols[..], &["git"]].concat()); + assert_eq!(on.status, 0); + assert!(on.json.as_ref().unwrap().get("policy").is_some()); + assert!(on.warnings().contains("input 0 ('git') matched tool:git only by invocation name (weak identity)")); + // Strong purl needs no opt-in and is range-checked. + let strong = cli(&[ + "resolve", "--diagnostics", "--platform", "linux", "--architecture", "x64", "--project-root", "/w", "--symbol", + "npm_prefix=/n", "--symbol", "npm_cache=/c", "--symbol", "node_prefix=/n", "--purl", "pkg:npm/npm@11.0.0", "npm", + ]); + assert_eq!(strong.status, 0, "{}", strong.stderr); + assert!(strong.json.as_ref().unwrap().get("policy").is_some()); + assert!(!strong.warnings().contains("weak identity")); + // Library API: string and object inputs obey the option identically. + let catalog = PolicyCatalog::new(mxc_policy_catalog::bundled_catalog_store().unwrap()); + let ctx = ResolveContext::new() + .platform(Platform::Linux) + .architecture(Architecture::X64) + .project_root("/p") + .symbol("git_prefix", "/usr/bin"); + assert_eq!(catalog.get_sandbox_config("git", &ctx).unwrap(), None); + assert_eq!(catalog.get_sandbox_config(ToolCandidate::new("git"), &ctx).unwrap(), None); + let on = ctx.allow_weak(true); + assert!(catalog.get_sandbox_config("git", &on).unwrap().is_some()); +} + +// --------------------------------------------------------------------------- +// Architecture selection +// --------------------------------------------------------------------------- + +#[test] +fn arch_mismatch_is_no_match_with_skipped_reason() { + let dir = write_catalog( + &work_dir("arch"), + &[revision( + vec![entry( + "tool:a", + r#"{"platformVariants":[ + {"when":{"platform":"windows","architecture":"arm64"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}\\arm64"]}}}, + {"when":{"platform":"windows","architecture":"x64"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}\\x64"]}}}, + {"when":{"platform":"linux","architecture":"x64"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}/x64"]}}}]}"#, + )], + "2000-01-01.1", + )], + ); + for arch in ["x64", "arm64"] { + let r = cli(&[ + "resolve", "--catalog", &s(&dir), "--diagnostics", "--allow-weak", "--platform", "windows", + "--architecture", arch, "--symbol", "git_prefix=C:\\t", "a", + ]); + assert_eq!(r.status, 0, "{}", r.stderr); + let paths = r.json.as_ref().unwrap().get("policy").unwrap().get("filesystem").unwrap().get("readonlyPaths").unwrap().clone(); + assert_eq!(paths, Json::Array(vec![Json::from(format!("C:\\t\\{arch}"))])); + assert!(!r.warnings().contains("architecture-neutral")); + } + let no_arm = cli(&[ + "resolve", "--catalog", &s(&dir), "--diagnostics", "--allow-weak", "--platform", "linux", "--architecture", + "arm64", "--symbol", "git_prefix=/t", "a", + ]); + assert_eq!(no_arm.status, 0); + assert!(no_arm.json.as_ref().unwrap().get("policy").is_none()); + assert!(no_arm.warnings().contains("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/arm64")); +} + +#[test] +fn host_default_architecture_and_neutral_fallback_warnings() { + for platform in [Platform::Windows, Platform::Linux, Platform::Macos] { + for arch in [Architecture::X64, Architecture::Arm64] { + // Library with an injected native architecture. + let catalog = PolicyCatalog::with_host( + mxc_policy_catalog::bundled_catalog_store().unwrap(), + Arc::new(FixedHost::new(platform, arch)), + ); + let root = if platform == Platform::Windows { "C:\\w" } else { "/w" }; + let prefix = if platform == Platform::Windows { "C:\\g" } else { "/g" }; + let ctx = ResolveContext::new().allow_weak(true).project_root(root).symbol("git_prefix", prefix); + let omitted = catalog.get_sandbox_config_with_diagnostics("git", &ctx).unwrap(); + let w = omitted.diagnostics.warnings.join("\n"); + assert!(w.contains(&format!( + "architecture was not specified; variants were selected for the native system architecture '{arch}'; the tool's architecture was not verified" + )), "{w}"); + assert!(w.contains(&format!("tool:git uses its architecture-neutral {platform} variant; no {arch}-specific variant exists"))); + let explicit = catalog + .get_sandbox_config_with_diagnostics("git", &ctx.clone().platform(platform).architecture(arch)) + .unwrap(); + assert!(!explicit.diagnostics.warnings.join("\n").contains("was not verified")); + assert_eq!(explicit.policy, omitted.policy); + + // Packaged CLI: explicit architecture warns about neutral fallback only. + let r = cli(&args(&["resolve", "--diagnostics"], &full_context(platform.as_str(), arch.as_str())) + .into_iter() + .chain(["git"]) + .collect::>()); + assert!(r.warnings().contains(&format!("no {arch}-specific variant exists"))); + assert!(!r.warnings().contains("was not verified")); + } + // Packaged CLI on this real host: native detection is used and reported. + let r = cli(&args(&["resolve", "--diagnostics"], &full_context(platform.as_str(), "")).into_iter().chain(["git"]).collect::>()); + assert_eq!(r.status, 0, "{}", r.stdout); + assert!(r.warnings().contains("the tool's architecture was not verified"), "{}", r.warnings()); + } + let nothing = cli(&["resolve", "--diagnostics", "--platform", "linux", "cargo"]); + assert!(!nothing.warnings().contains("architecture")); +} + +// --------------------------------------------------------------------------- +// Multi-match +// --------------------------------------------------------------------------- + +#[test] +fn one_input_matching_several_entries_warns_and_composes_all() { + let dir = write_catalog( + &work_dir("multi"), + &[revision( + vec![ + entry("tool:app", r#"{"identity":[{"kind":"purl","value":"pkg:npm/app"},{"kind":"invocation-name","names":["app"]}]}"#), + entry("tool:app-plugin", r#"{"identity":[{"kind":"invocation-name","names":["app"]}]}"#), + ], + "2000-01-01.1", + )], + ); + let base = ["resolve", "--catalog", &s(&dir), "--diagnostics", "--platform", "linux", "--architecture", "x64", "--symbol", "git_prefix=/opt"]; + let r = cli(&[&base[..], &["--allow-weak", "app"]].concat()); + assert_eq!(r.status, 0, "{}", r.stderr); + assert!(r.warnings().contains("input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute")); + assert_eq!( + r.json.as_ref().unwrap().get("policy").unwrap().get("filesystem").unwrap().get("readonlyPaths").unwrap(), + &j(r#"["/opt/app","/opt/app-plugin"]"#) + ); + let strong_only = cli(&[&base[..], &["--purl", "pkg:npm/app", "app"]].concat()); + assert!(!strong_only.warnings().contains("matched 2 entries")); + // Library API. + let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); + let res = catalog + .get_sandbox_config_with_diagnostics("app", &ResolveContext::new().allow_weak(true).architecture(Architecture::X64).symbol("git_prefix", "/opt")) + .unwrap(); + assert_eq!(res.diagnostics.tools[0].matches.len(), 2); +} + +// --------------------------------------------------------------------------- +// Composition conflict +// --------------------------------------------------------------------------- + +#[test] +fn composition_conflicts_are_errors_not_choices() { + let r = cli(&[ + "resolve", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--project-root", "/opt", "--symbol", + "git_prefix=/usr/bin", "--symbol", "node_prefix=/opt/node", "git", "node", + ]); + assert_eq!(r.status, 1, "{}", r.stdout); + assert_eq!(r.error_reason().as_deref(), Some("composition_conflict")); + let message = r.json.as_ref().unwrap().get("error").unwrap().get("message").and_then(Json::as_str).unwrap().to_string(); + assert!(message.contains("overlap across access classes"), "{message}"); + + let dir = write_catalog( + &work_dir("compose"), + &[revision( + vec![ + entry("tool:a", ""), + entry( + "tool:b", + r#"{"platformVariants":[{"when":{"platform":"linux"},"sandboxPolicy":{"version":"0.9.0-alpha","network":{"egress":{"default":"deny"}}}}]}"#, + ), + ], + "2000-01-01.1", + )], + ); + assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); + let both = cli(&[ + "resolve", "--catalog", &s(&dir), "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", + "git_prefix=/opt", "a", "b", + ]); + assert_eq!(both.status, 1); + assert_eq!(both.error_reason().as_deref(), Some("composition_conflict")); + // Library API. + let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); + let err = catalog + .get_sandbox_config(vec!["a", "b"], &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) + .unwrap_err(); + assert_eq!(err.reason(), ErrorReason::CompositionConflict); +} + +// --------------------------------------------------------------------------- +// CLI surface +// --------------------------------------------------------------------------- + +#[test] +fn cli_usage_errors_and_inspect() { + for (argv, message) in [ + (vec![], "missing command"), + (vec!["frob"], "unknown command 'frob'"), + (vec!["inspect", "x"], "inspect: unexpected argument 'x'"), + (vec!["resolve", "--base-ref", "HEAD"], "unknown option '--base-ref'"), + (vec!["resolve", "--symbol", "=x", "a"], "--symbol expects name=value"), + (vec!["resolve", "--platform", "--allow-weak"], "--platform requires a value"), + (vec!["resolve", "--purl", "pkg:npm/x"], "--purl/--detected-version must precede a tool name"), + ] { + let r = cli(&argv); + assert_eq!(r.status, 2, "{argv:?}"); + assert_eq!(r.stdout, ""); + assert_eq!( + r.stderr, + format!("policy-catalog: {message}\nusage: policy-catalog [options]\n") + ); + } + let inspect = cli(&["inspect"]); + assert_eq!(inspect.status, 0); + let json = inspect.json.unwrap(); + assert_eq!(json.get("info").unwrap(), &mxc_policy_catalog::get_catalog_info().unwrap().to_json()); + assert_eq!(json.get("entries").unwrap().as_array().unwrap().len(), mxc_policy_catalog::list_catalog_entries().unwrap().len()); + let validate = cli(&["validate"]); + assert_eq!(validate.status, 0, "{}", validate.stdout); +} diff --git a/policy-catalog/rust/rust-toolchain.toml b/policy-catalog/rust/rust-toolchain.toml new file mode 100644 index 000000000..d71ec23cc --- /dev/null +++ b/policy-catalog/rust/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[toolchain] +channel = "1.93" +components = ["clippy", "rustfmt"] diff --git a/policy-catalog/rust/rustfmt.toml b/policy-catalog/rust/rustfmt.toml new file mode 100644 index 000000000..c497f14c2 --- /dev/null +++ b/policy-catalog/rust/rustfmt.toml @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +max_width = 120 diff --git a/policy-catalog/rust/scripts/sync-catalog.mjs b/policy-catalog/rust/scripts/sync-catalog.mjs new file mode 100644 index 000000000..479765ffb --- /dev/null +++ b/policy-catalog/rust/scripts/sync-catalog.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Copies the catalog source of truth (../../catalog, i.e. policy-catalog/catalog) +// into the crate-local rust/catalog/ so `cargo package` can ship it inside +// the .crate. rust/catalog/ is git-ignored and listed in Cargo.toml `include`. +// The repository build never reads it: build.rs embeds ../catalog directly +// whenever it is present and the crate is not a packaged copy. +// +// node scripts/sync-catalog.mjs +import { cpSync, existsSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const source = fileURLToPath(new URL('../../catalog/', import.meta.url)); +const target = fileURLToPath(new URL('../catalog/', import.meta.url)); + +if (!existsSync(`${source}manifest.json`)) { + console.error(`sync-catalog: ${source} has no manifest.json`); + process.exit(1); +} +rmSync(target, { recursive: true, force: true }); +cpSync(source, target, { + recursive: true, + filter: path => !/\.(md|txt)$/i.test(path), +}); +console.log(`sync-catalog: copied ${source} -> ${target}`); diff --git a/policy-catalog/rust/src/bin/policy-catalog.rs b/policy-catalog/rust/src/bin/policy-catalog.rs new file mode 100644 index 000000000..4fbfc8ee0 --- /dev/null +++ b/policy-catalog/rust/src/bin/policy-catalog.rs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `policy-catalog` command-line harness (PROTOTYPE). See `mxc_policy_catalog::cli`. + +use std::io::Write; +use std::process::ExitCode; + +fn main() -> ExitCode { + let argv: Vec = std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + let output = mxc_policy_catalog::cli::run(&argv); + let mut stdout = std::io::stdout().lock(); + let _ = stdout.write_all(output.stdout.as_bytes()); + let _ = stdout.flush(); + let mut stderr = std::io::stderr().lock(); + let _ = stderr.write_all(output.stderr.as_bytes()); + let _ = stderr.flush(); + ExitCode::from(output.exit_code as u8) +} diff --git a/policy-catalog/rust/src/catalog.rs b/policy-catalog/rust/src/catalog.rs new file mode 100644 index 000000000..7993ad80d --- /dev/null +++ b/policy-catalog/rust/src/catalog.rs @@ -0,0 +1,1042 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Contract and revision validation, variant selection, dependency closure, +//! and composition limits (TypeScript `src/catalog.ts`). Error messages are +//! identical to TypeScript. + +use crate::errors::{invalid_catalog, Result}; +use crate::json::{cmp_utf16, js_number_to_string, js_to_string, Json, JsonObject}; +use crate::model::{Architecture, IdentityStrength, Platform}; +use crate::paths::path_key_segments; +use crate::purl::parse_purl; +use crate::text::{is_symbol_name, js_to_lower, replace_symbols, symbol_matches}; +use crate::version_range::is_valid_version_range; +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, +} + +/// Validated catalog contract. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogContract { + pub catalog_schema_version: String, + pub sandbox_policy_versions: Vec, + 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)) + } +} + +/// One identity predicate of an entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IdentityPredicate { + Purl { + value: String, + version_range: Option, + }, + 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, + pub version_range: Option, +} + +/// An embedded, validated `SandboxPolicy`. 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 version(&self) -> &str { + self.raw.get("version").and_then(Json::as_str).unwrap_or_default() + } + + 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) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PlatformVariant { + pub platform: Platform, + pub architecture: Option, + pub dependencies: Option>, + pub sandbox_policy: CatalogPolicy, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatalogEntry { + pub entry_id: String, + pub entry_revision: f64, + pub display_name: String, + pub identity: Vec, + pub platform_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, + pub entries: Vec, +} + +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", "sandboxPolicyVersions", "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 sandbox_policy_versions = string_array(raw.get("sandboxPolicyVersions"), "contract.sandboxPolicyVersions", 1)?; + 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"], + &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"), + )?; + symbols.push((name.to_string(), SymbolDefinition { source, description })); + } + Ok(CatalogContract { + catalog_schema_version: CATALOG_SCHEMA_VERSION.to_string(), + sandbox_policy_versions, + symbols, + }) +} + +// --------------------------------------------------------------------------- +// Paths and symbols +// --------------------------------------------------------------------------- + +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(()) +} + +/// The symbols a policy references, in first-seen order. +pub fn policy_symbols(policy: &CatalogPolicy) -> Vec { + let mut seen: Vec = Vec::new(); + for field in COMPOSABLE_FILESYSTEM_FIELDS { + for value in policy.filesystem_field(field) { + for (_, _, name) in symbol_matches(value) { + if !seen.iter().any(|s| s == name) { + seen.push(name.to_string()); + } + } + } + } + seen +} + +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) +} + +/// Finds the first equal or ancestor/descendant pair of paths in different +/// access classes. `classes` is in [`COMPOSABLE_FILESYSTEM_FIELDS`] order. +pub fn find_cross_class_overlap(classes: &[(&str, Vec)], platform: Platform) -> Option { + let fields: Vec<&(&str, Vec)> = classes.iter().filter(|(_, values)| !values.is_empty()).collect(); + for i in 0..fields.len() { + for j in i + 1..fields.len() { + for left in &fields[i].1 { + for right in &fields[j].1 { + if is_same_or_nested(&path_key_segments(left, platform), &path_key_segments(right, platform)) { + return Some(format!( + "'{left}' ({}) overlaps '{right}' ({})", + fields[i].0, fields[j].0 + )); + } + } + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Embedded SandboxPolicy +// --------------------------------------------------------------------------- + +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"))?; + 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") { + string_array(Some(except), &format!("{peer_at}.except"), 0)?; + } + } + } + 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")); + } + } + 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(()) +} + +fn validate_sandbox_policy(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" + )); + } + } + only_fields(raw, &["version", "filesystem", "network", "ui", "timeoutMs"], at)?; + let version = non_empty_string(raw.get("version"), &format!("{at}.version"))?; + if !contract.sandbox_policy_versions.contains(&version) { + return fail(format!( + "'{at}.version' '{version}' is not a SandboxPolicy version registered in the catalog contract" + )); + } + 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")); + }; + 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, + &["allowWindows", "clipboard", "allowInputInjection"], + &format!("{at}.ui"), + )?; + for key in ["allowWindows", "allowInputInjection"] { + if let Some(value) = ui.get(key) { + if value.as_bool().is_none() { + return fail(format!("'{at}.ui.{key}' 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") { + if !timeout.as_f64().is_some_and(|n| is_integer(n) && n >= 1.0) { + return fail(format!("'{at}.timeoutMs' must be a positive 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", "versionRange"], &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() { + return fail(format!("'{item_at}.value' must not pin a version; use 'versionRange'")); + } + let mut version_range = None; + if let Some(range) = item.get("versionRange") { + let range = non_empty_string(Some(range), &format!("{item_at}.versionRange"))?; + if !is_valid_version_range(&range) { + return fail(format!("'{item_at}.versionRange' is not a valid version range")); + } + version_range = Some(range); + } + predicates.push(IdentityPredicate::Purl { value, version_range }); + } + 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()) { + return fail(format!("'{at}' repeats identity '{key}'")); + } + } + Ok(predicates) +} + +fn validate_variants(raw: Option<&Json>, at: &str, contract: &CatalogContract) -> 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 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")); + }; + only_fields(item, &["when", "dependencies", "sandboxPolicy"], &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}'") + }); + } + let mut dependencies = None; + if let Some(raw_dependencies) = item.get("dependencies") { + let Some(raw_dependencies) = raw_dependencies.as_array() else { + return fail(format!("'{item_at}.dependencies' must be an array")); + }; + let mut seen = HashSet::new(); + let mut list = Vec::new(); + for (dep_index, dependency) in raw_dependencies.iter().enumerate() { + let dep_at = format!("{item_at}.dependencies[{dep_index}]"); + let Some(dependency) = dependency.as_object() else { + return fail(format!("'{dep_at}' must be an object")); + }; + only_fields(dependency, &["entryId", "versionRange"], &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 !is_valid_version_range(&range) { + return fail(format!("'{dep_at}.versionRange' is not a valid version range")); + } + version_range = Some(range); + } + list.push(Dependency { + entry_id, + version_range, + }); + } + dependencies = Some(list); + } + let sandbox_policy = + validate_sandbox_policy(item.get("sandboxPolicy"), &format!("{item_at}.sandboxPolicy"), contract)?; + variants.push(PlatformVariant { + platform, + architecture, + dependencies, + sandbox_policy, + }); + } + 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", + "identity", + "platformVariants", + "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 identity = validate_identity(object.get("identity"), &format!("{at}.identity"))?; + let platform_variants = validate_variants( + object.get("platformVariants"), + &format!("{at}.platformVariants"), + 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, + identity, + platform_variants, + provenance_method, + provenance_source_revision, + raw: raw.clone(), + }) +} + +// --------------------------------------------------------------------------- +// Variant selection and dependency closure +// --------------------------------------------------------------------------- + +/// A selected variant; `exact` is false for the architecture-neutral fallback. +#[derive(Clone, Copy, Debug)] +pub struct VariantSelection<'a> { + pub variant: &'a PlatformVariant, + pub exact: bool, +} + +/// Selects the exact architecture first, then the platform's neutral variant. +/// Another architecture's variant is never a fallback. +pub fn select_variant( + entry: &CatalogEntry, + platform: Platform, + architecture: Architecture, +) -> Option> { + let for_platform = || entry.platform_variants.iter().filter(move |v| v.platform == platform); + if let Some(variant) = for_platform().find(|v| v.architecture == Some(architecture)) { + return Some(VariantSelection { variant, exact: true }); + } + for_platform() + .find(|v| v.architecture.is_none()) + .map(|variant| VariantSelection { variant, exact: false }) +} + +#[derive(Clone, Copy, Debug)] +pub struct ClosureNode<'a> { + pub entry: &'a CatalogEntry, + pub variant: &'a PlatformVariant, + pub exact: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClosureFailure { + /// `cycle`, `missing-entry`, or `unsupported-dependency`. + pub reason: &'static str, + pub detail: String, +} + +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() +} + +struct Closure<'a, 'm> { + by_id: &'m EntryIndex<'a>, + platform: Platform, + architecture: Architecture, + nodes: Vec>, + done: HashSet<&'a str>, + stack: Vec<&'a str>, +} + +impl<'a> Closure<'a, '_> { + fn visit( + &mut self, + entry: &'a CatalogEntry, + selection: VariantSelection<'a>, + ) -> std::result::Result<(), ClosureFailure> { + if self.stack.contains(&entry.entry_id.as_str()) { + let mut path: Vec<&str> = self.stack.clone(); + path.push(&entry.entry_id); + return Err(ClosureFailure { + reason: "cycle", + detail: path.join(" -> "), + }); + } + if self.done.contains(entry.entry_id.as_str()) { + return Ok(()); + } + self.stack.push(&entry.entry_id); + self.nodes.push(ClosureNode { + entry, + variant: selection.variant, + exact: selection.exact, + }); + for dependency in selection.variant.dependencies.iter().flatten() { + let Some(target) = self.by_id.get(dependency.entry_id.as_str()).copied() else { + return Err(ClosureFailure { + reason: "missing-entry", + detail: format!("{} -> {}", entry.entry_id, dependency.entry_id), + }); + }; + let Some(selected) = select_variant(target, self.platform, self.architecture) else { + return Err(ClosureFailure { + reason: "unsupported-dependency", + detail: format!( + "{} -> {} has no {}/{} variant", + entry.entry_id, dependency.entry_id, self.platform, self.architecture + ), + }); + }; + self.visit(target, selected)?; + } + self.stack.pop(); + self.done.insert(&entry.entry_id); + Ok(()) + } +} + +/// Deterministic depth-first dependency closure; the root first, each +/// dependency in declaration order and once. Cycles are reported. +pub fn dependency_closure<'a>( + root: &'a CatalogEntry, + root_selection: VariantSelection<'a>, + by_id: &EntryIndex<'a>, + platform: Platform, + architecture: Architecture, +) -> std::result::Result>, ClosureFailure> { + let mut closure = Closure { + by_id, + platform, + architecture, + nodes: Vec::new(), + done: HashSet::new(), + stack: Vec::new(), + }; + closure.visit(root, root_selection)?; + Ok(closure.nodes) +} + +/// Composition limits for the v1 vocabulary (design §4.5). +pub fn composition_violation(nodes: &[ClosureNode<'_>]) -> Option { + let mut versions: Vec<&str> = Vec::new(); + for node in nodes { + let version = node.variant.sandbox_policy.version(); + if !versions.contains(&version) { + versions.push(version); + } + } + if versions.len() > 1 { + versions.sort_by(|a, b| cmp_utf16(a, b)); + return Some(format!("mixed sandboxPolicy.version values ({})", versions.join(", "))); + } + if nodes.len() < 2 { + return None; + } + for node in nodes { + for key in node.variant.sandbox_policy.raw().keys() { + if key != "version" && key != "filesystem" { + return Some(format!( + "'{}' uses '{key}', which has no v1 cross-entry composition rule", + node.entry.entry_id + )); + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Revision-level validation +// --------------------------------------------------------------------------- + +/// Validates one catalog revision against the v1 contract (design §7). +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", "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 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 variant in &entry.platform_variants { + for dependency in variant.dependencies.iter().flatten() { + if !by_id.contains_key(dependency.entry_id.as_str()) { + 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)); + } + } + } + for platform in Platform::ALL { + for architecture in Architecture::ALL { + let Some(selected) = select_variant(entry, platform, architecture) else { + continue; + }; + let nodes = match dependency_closure(entry, selected, &by_id, platform, architecture) { + Ok(nodes) => nodes, + Err(failure) => { + return fail(format!( + "'{}' on {platform}/{architecture}: {} ({})", + entry.entry_id, failure.reason, failure.detail + )) + } + }; + if let Some(violation) = composition_violation(&nodes) { + return fail(format!( + "'{}' on {platform}/{architecture}: {violation}", + entry.entry_id + )); + } + let classes: Vec<(&str, Vec)> = COMPOSABLE_FILESYSTEM_FIELDS + .iter() + .map(|field| { + let values = nodes + .iter() + .flat_map(|node| node.variant.sandbox_policy.filesystem_field(field)) + .map(str::to_string) + .collect(); + (*field, values) + }) + .collect(); + if let Some(overlap) = find_cross_class_overlap(&classes, platform) { + return fail(format!("'{}' on {platform}/{architecture}: {overlap}", entry.entry_id)); + } + } + } + } + + Ok(CatalogRevision { + catalog_schema_version: contract.catalog_schema_version.clone(), + catalog_revision, + 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::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/policy-catalog/rust/src/cli.rs b/policy-catalog/rust/src/cli.rs new file mode 100644 index 000000000..248129c3a --- /dev/null +++ b/policy-catalog/rust/src/cli.rs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `policy-catalog resolve | inspect | validate`, output-equivalent to the +//! TypeScript CLI (`src/cli.ts`). +//! +//! ```text +//! policy-catalog resolve [--catalog DIR] [--diagnostics] [--platform P] +//! [--architecture A] [--revision R] [--project-root PATH] +//! [--symbol name=value]... [--allow-weak] +//! [--purl URL] [--detected-version V] ... +//! policy-catalog inspect [--catalog DIR] +//! policy-catalog validate [--catalog DIR] [--base-ref REF] +//! ``` +//! +//! Exit codes: 0 success (including "no policy"), 1 library failure or +//! failed validation, 2 usage error. Output is JSON on stdout. + +use crate::errors::PolicyCatalogError; +use crate::json::{Json, JsonObject}; +use crate::model::{ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs}; +use crate::resolver::PolicyCatalog; +use crate::store::{bundled_catalog_store, load_catalog_directory, CatalogStore}; +use crate::validate::{resolve_path, validate_bundled_catalog, validate_catalog_directory}; +use std::sync::Arc; + +pub const USAGE: &str = "usage: policy-catalog [options]"; + +enum Failure { + Usage(String), + Library(PolicyCatalogError), +} + +impl From for Failure { + fn from(error: PolicyCatalogError) -> Self { + Failure::Library(error) + } +} + +/// Captured process output of one CLI invocation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CliOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +fn take_value(args: &[String], index: usize, flag: &str) -> Result { + match args.get(index + 1) { + Some(value) if !value.starts_with("--") => Ok(value.clone()), + _ => Err(Failure::Usage(format!("{flag} requires a value"))), + } +} + +fn store_from(catalog_dir: &Option) -> Result, PolicyCatalogError> { + match catalog_dir { + None => bundled_catalog_store(), + Some(dir) => Ok(Arc::new(load_catalog_directory(resolve_path(dir))?)), + } +} + +fn print(value: &Json, out: &mut String) { + out.push_str(&value.to_pretty_string()); + out.push('\n'); +} + +fn reject_rest(args: &[String], command: &str) -> Result<(), Failure> { + match args.first() { + Some(first) => Err(Failure::Usage(format!("{command}: unexpected argument '{first}'"))), + None => Ok(()), + } +} + +/// Runs the CLI over `argv` (without the program name) and returns its output. +pub fn run(argv: &[String]) -> CliOutput { + let mut output = CliOutput::default(); + match main_inner(argv, &mut output.stdout) { + Ok(code) => output.exit_code = code, + Err(Failure::Usage(message)) => { + output.stdout.clear(); + output.stderr = format!("policy-catalog: {message}\n{USAGE}\n"); + output.exit_code = 2; + } + Err(Failure::Library(error)) => { + let mut details = JsonObject::new(); + details.insert("reason", error.reason().as_str().into()); + let mut body = JsonObject::new(); + body.insert("code", error.code().as_str().into()); + body.insert("message", error.message().into()); + body.insert("details", Json::Object(details)); + let mut root = JsonObject::new(); + root.insert("error", Json::Object(body)); + output.stdout.clear(); + print(&Json::Object(root), &mut output.stdout); + output.exit_code = 1; + } + } + output +} + +fn main_inner(argv: &[String], out: &mut String) -> Result { + let command = argv.first().map(String::as_str); + let rest = argv.get(1..).unwrap_or_default(); + let mut catalog_dir: Option = None; + let mut base_ref: Option = None; + let mut args: Vec = Vec::new(); + let mut i = 0; + while i < rest.len() { + if rest[i] == "--catalog" { + catalog_dir = Some(take_value(rest, i, "--catalog")?); + i += 1; + } else if rest[i] == "--base-ref" && command == Some("validate") { + base_ref = Some(take_value(rest, i, "--base-ref")?); + i += 1; + } else { + args.push(rest[i].clone()); + } + i += 1; + } + match command { + Some("inspect") => { + reject_rest(&args, "inspect")?; + let catalog = PolicyCatalog::new(store_from(&catalog_dir)?); + let info = catalog.get_catalog_info()?; + let entries = catalog.list_catalog_entries()?; + let mut root = JsonObject::new(); + root.insert("info", info.to_json()); + root.insert("entries", Json::Array(entries.iter().map(|e| e.to_json()).collect())); + print(&Json::Object(root), out); + Ok(0) + } + Some("validate") => { + reject_rest(&args, "validate")?; + let report = match &catalog_dir { + Some(dir) => validate_catalog_directory(dir, base_ref.as_deref()), + None => validate_bundled_catalog(base_ref.as_deref()), + }; + print(&report.to_json(), out); + Ok(if report.ok { 0 } else { 1 }) + } + Some("resolve") => resolve_command(&args, &catalog_dir, out), + None => Err(Failure::Usage("missing command".to_string())), + Some(other) => Err(Failure::Usage(format!("unknown command '{other}'"))), + } +} + +fn resolve_command(args: &[String], catalog_dir: &Option, out: &mut String) -> Result { + let mut ctx = ResolveContext::new(); + let mut symbols = SymbolMap::new(); + let mut tools: Vec = Vec::new(); + let mut diagnostics = false; + let mut purl: Option = None; + let mut detected_version: Option = None; + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + match arg { + "--diagnostics" => diagnostics = true, + "--allow-weak" => ctx.allow_weak_identity_fallback = true, + "--platform" => { + ctx.platform = Some(take_value(args, i, arg)?); + i += 1; + } + "--architecture" => { + ctx.architecture = Some(take_value(args, i, arg)?); + i += 1; + } + "--revision" => { + ctx.catalog_revision = Some(take_value(args, i, arg)?); + i += 1; + } + "--project-root" => { + ctx.project_root = Some(take_value(args, i, arg)?); + i += 1; + } + "--symbol" => { + let pair = take_value(args, i, arg)?; + i += 1; + match pair.find('=') { + Some(eq) if eq > 0 => symbols.insert(&pair[..eq], &pair[eq + 1..]), + _ => return Err(Failure::Usage("--symbol expects name=value".to_string())), + } + } + "--purl" => { + purl = Some(take_value(args, i, arg)?); + i += 1; + } + "--detected-version" => { + detected_version = Some(take_value(args, i, arg)?); + i += 1; + } + _ => { + if arg.starts_with("--") { + return Err(Failure::Usage(format!("unknown option '{arg}'"))); + } + // --purl / --detected-version apply to the next tool name only. + if purl.is_none() && detected_version.is_none() { + tools.push(ToolInput::Name(arg.to_string())); + } else { + tools.push(ToolInput::Candidate(ToolCandidate { + invocation_name: arg.to_string(), + package_url: purl.take(), + detected_version: detected_version.take(), + })); + } + } + } + i += 1; + } + if purl.is_some() || detected_version.is_some() { + return Err(Failure::Usage( + "--purl/--detected-version must precede a tool name".to_string(), + )); + } + if !symbols.is_empty() { + ctx.symbols = Some(symbols); + } + let catalog = PolicyCatalog::new(store_from(catalog_dir)?); + let tools = ToolInputs(tools); + if diagnostics { + print( + &catalog.get_sandbox_config_with_diagnostics(tools, &ctx)?.to_json(), + out, + ); + } else { + let policy = catalog.get_sandbox_config(tools, &ctx)?; + print(&policy.map_or(Json::Null, |p| p.to_json()), out); + } + Ok(0) +} diff --git a/policy-catalog/rust/src/errors.rs b/policy-catalog/rust/src/errors.rs new file mode 100644 index 000000000..79772e9e0 --- /dev/null +++ b/policy-catalog/rust/src/errors.rs @@ -0,0 +1,158 @@ +// 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` (TypeScript +//! `src/errors.ts`). 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, + InvalidContext, + UnsupportedHost, + Integrity, + RevisionUnavailable, +} + +impl ErrorReason { + pub const ALL: [ErrorReason; 6] = [ + ErrorReason::InvalidCatalog, + ErrorReason::CompositionConflict, + 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::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 => 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 TypeScript. +#[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 + } +} + +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", + "malformed_request", + "unsupported_containment", + "backend_error", + "backend_error" + ] + ); + } +} diff --git a/policy-catalog/rust/src/history.rs b/policy-catalog/rust/src/history.rs new file mode 100644 index 000000000..11d168b66 --- /dev/null +++ b/policy-catalog/rust/src/history.rs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Entry-revision monotonicity and published-revision immutability +//! (TypeScript `src/history.ts`). + +use crate::catalog::{compare_catalog_revisions, entry_semantic_key, revision_number, CatalogRevision}; +use crate::json::{canonical_json, Json}; +use crate::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, + pub sha256: Option, +} + +impl PublishedRevision { + pub fn new(catalog_revision: &str, file: &str, sha256: &str) -> Self { + Self { + catalog_revision: Some(Json::from(catalog_revision)), + file: Some(Json::from(file)), + sha256: Some(Json::from(sha256)), + } + } + + fn from_json(value: &Json) -> Self { + Self { + catalog_revision: value.get("catalogRevision").cloned(), + file: value.get("file").cloned(), + sha256: value.get("sha256").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::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, digest, 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 || now.sha256 != published.sha256 { + 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/policy-catalog/rust/src/host.rs b/policy-catalog/rust/src/host.rs new file mode 100644 index 000000000..181084964 --- /dev/null +++ b/policy-catalog/rust/src/host.rs @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host platform, native system architecture, and approved host symbols +//! (TypeScript `src/host.ts`). +//! +//! 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)`). + +use crate::errors::{ErrorReason, PolicyCatalogError, Result}; +use crate::model::{Architecture, Platform}; +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; +} + +/// Maps an OS-reported machine/architecture string to a catalog selector. +pub fn architecture_from_machine(machine: &str) -> Option { + match crate::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::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::text::js_is_space); + if value.len() < after.len() { + let end = value.find(crate::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(), + _ => None, + } + } +} + +/// A host with fixed facts, for tests and callers that already know them. +#[derive(Clone, Debug)] +pub struct FixedHost { + pub platform: Platform, + pub architecture: Architecture, + pub symbols: Vec<(String, String)>, +} + +impl FixedHost { + pub fn new(platform: Platform, architecture: Architecture) -> Self { + Self { + platform, + architecture, + symbols: Vec::new(), + } + } + + pub fn with_symbol(mut self, name: impl Into, value: impl Into) -> Self { + self.symbols.push((name.into(), value.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()) + } +} + +#[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/policy-catalog/rust/src/json.rs b/policy-catalog/rust/src/json.rs new file mode 100644 index 000000000..ffcf58630 --- /dev/null +++ b/policy-catalog/rust/src/json.rs @@ -0,0 +1,793 @@ +// 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 digests, +//! 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 sha2::{Digest, Sha256}; +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), + } +} + +/// Lower-case hex SHA-256 of `text` (UTF-8). +pub fn sha256_hex(text: &str) -> String { + let digest = Sha256::digest(text.as_bytes()); + let mut out = String::with_capacity(64); + for byte in digest.iter() { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// Lower-case hex SHA-256 of the canonical JSON form. +pub fn canonical_sha256(value: &Json) -> String { + sha256_hex(&canonical_json(value)) +} + +/// 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/policy-catalog/rust/src/lib.rs b/policy-catalog/rust/src/lib.rs new file mode 100644 index 000000000..2a760e21c --- /dev/null +++ b/policy-catalog/rust/src/lib.rs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! PROTOTYPE: native Rust implementation of the MXC known-tool policy +//! catalog library, with behavioral parity to the TypeScript implementation +//! in `policy-catalog/src`. +//! +//! The runtime lookup API ([`get_sandbox_config`], +//! [`get_sandbox_config_with_diagnostics`], [`get_catalog_info`], +//! [`list_catalog_entries`], and [`PolicyCatalog`]) returns a **candidate +//! lower-bound** policy. It never grants access, launches a sandbox, contacts +//! a network service, or writes consumer state. The catalog bundled with this +//! crate is compiled in and integrity-checked on first use. +//! +//! [`tooling`] exposes the contribution/CI rules (validation, history, +//! canonical JSON, paths) shared with the `policy-catalog` binary. + +pub mod catalog; +pub mod errors; +pub mod history; +pub mod host; +pub mod json; +pub mod model; +pub mod paths; +pub mod purl; +pub mod resolver; +pub mod store; +mod text; +pub mod validate; +pub mod version_range; + +pub use errors::{ErrorCode, ErrorReason, PolicyCatalogError}; +pub use host::{FixedHost, HostEnvironment, SystemHost}; +pub use model::{ + Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, Diagnostics, + EntryMatchRecord, FilesystemPolicy, IdentityStrength, MatchedIdentity, Platform, PlatformVariantMetadata, + Provenance, ResolveContext, SandboxConfigResolution, SandboxPolicy, SymbolMap, ToolCandidate, ToolInput, + ToolInputs, ToolRecord, +}; +pub use resolver::{ + get_catalog_info, get_sandbox_config, get_sandbox_config_with_diagnostics, list_catalog_entries, 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::catalog::{ + compare_catalog_revisions, select_variant, validate_catalog_revision, validate_contract, CatalogContract, + CatalogEntry, CatalogRevision, IdentityPredicate, PlatformVariant, + }; + pub use crate::history::{ + check_entry_revisions, check_published_immutability, check_store_history, PublishedRevision, PublishedState, + }; + pub use crate::host::architecture_from_machine; + pub use crate::json::{canonical_json, canonical_sha256, js_number_to_string, Json, JsonObject}; + pub use crate::paths::{case_key, folds_case, is_absolute_path, normalize_path, path_key_segments}; + pub use crate::purl::{parse_purl, ParsedPurl}; + pub use crate::store::{bundled_catalog_files, bundled_catalog_source, validate_manifest}; + pub use crate::validate::{ + check_against_base_ref, read_published_state_at_ref, validate_bundled_catalog, validate_catalog_directory, + CatalogValidationReport, + }; + pub use crate::version_range::{is_valid_version_range, satisfies_version_range}; +} + +/// The command-line harness behind the `policy-catalog` binary. +pub mod cli; diff --git a/policy-catalog/rust/src/model.rs b/policy-catalog/rust/src/model.rs new file mode 100644 index 000000000..c0543b30f --- /dev/null +++ b/policy-catalog/rust/src/model.rs @@ -0,0 +1,573 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Public request and result types (design §5), mirroring TypeScript +//! `src/types.ts`. Every result type renders to the exact JSON shape the +//! TypeScript library produces (`to_json`), with absent optional fields +//! omitted rather than written as `null`. + +use crate::json::{Json, JsonObject}; +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 (design §5.1). +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ToolCandidate { + pub invocation_name: String, + pub package_url: Option, + pub detected_version: 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 + } +} + +/// 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> 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 (design §5.1). `platform` and `architecture` are +/// the caller's raw strings so that an unsupported value is reported as +/// `malformed_request` (`invalid_context`) exactly like TypeScript. +#[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)] +pub enum IdentityStrength { + Strong, + Weak, +} + +impl IdentityStrength { + pub fn as_str(self) -> &'static str { + match self { + IdentityStrength::Strong => "strong", + IdentityStrength::Weak => "weak", + } + } +} + +/// The filesystem part of a composed policy. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FilesystemPolicy { + pub denied_paths: Option>, + pub readonly_paths: Option>, + pub readwrite_paths: Option>, +} + +fn strings(values: &[String]) -> Json { + Json::Array(values.iter().map(|v| Json::String(v.clone())).collect()) +} + +impl FilesystemPolicy { + 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) + } +} + +/// The catalog-supported subset of MXC's `SandboxPolicy`, structurally +/// compatible with the MXC SDK type. `network` and `ui` are carried as the +/// reviewed JSON from the catalog. +#[derive(Clone, Debug, PartialEq)] +pub struct SandboxPolicy { + pub version: String, + pub filesystem: Option, + pub network: Option, + pub ui: Option, + pub timeout_ms: Option, +} + +impl SandboxPolicy { + pub fn to_json(&self) -> Json { + let mut object = JsonObject::new(); + object.insert("version", Json::String(self.version.clone())); + 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(timeout)); + } + Json::Object(object) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MatchedIdentity { + pub kind: String, + pub strength: IdentityStrength, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EntryMatchRecord { + pub entry_id: String, + pub entry_revision: f64, + pub matched_identities: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ToolRecord { + pub input_index: usize, + pub matches: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DependencyRecord { + pub entry_id: String, + pub entry_revision: f64, + pub required_version_range: Option, +} + +#[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)); + let matches = tool + .matches + .iter() + .map(|m| { + 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)); + Json::Object(o) + }) + .collect(); + record.insert("matches", Json::Array(matches)); + 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)); + if let Some(range) = &d.required_version_range { + o.insert("requiredVersionRange", Json::String(range.clone())); + } + Json::Object(o) + }) + .collect(); + object.insert("resolvedDependencies", Json::Array(dependencies)); + object.insert("warnings", strings(&self.warnings)); + Json::Object(object) + } +} + +/// Result of `get_sandbox_config_with_diagnostics` (design §5.1). +#[derive(Clone, Debug, PartialEq)] +pub struct SandboxConfigResolution { + pub policy: Option, + pub diagnostics: Diagnostics, +} + +impl SandboxConfigResolution { + /// `{"policy"?, "diagnostics"}`; `policy` is omitted when absent. + pub fn to_json(&self) -> Json { + let mut object = JsonObject::new(); + if let Some(policy) = &self.policy { + object.insert("policy", policy.to_json()); + } + object.insert("diagnostics", self.diagnostics.to_json()); + Json::Object(object) + } +} + +/// Inspection metadata for one identity predicate (design §5.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CatalogIdentityMetadata { + Purl { + value: String, + version_range: Option, + }, + InvocationName { + names: Vec, + }, +} + +impl CatalogIdentityMetadata { + pub fn to_json(&self) -> Json { + let mut o = JsonObject::new(); + match self { + CatalogIdentityMetadata::Purl { value, version_range } => { + o.insert("kind", "purl".into()); + o.insert("value", value.as_str().into()); + if let Some(range) = version_range { + o.insert("versionRange", range.as_str().into()); + } + } + CatalogIdentityMetadata::InvocationName { names } => { + o.insert("kind", "invocation-name".into()); + o.insert("names", strings(names)); + } + } + Json::Object(o) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlatformVariantMetadata { + pub platform: Platform, + pub architecture: Option, + pub dependency_entry_ids: Vec, + pub sandbox_policy_version: String, +} + +#[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 identity: Vec, + pub platform_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( + "identity", + Json::Array(self.identity.iter().map(|i| i.to_json()).collect()), + ); + let 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()); + } + vo.insert("dependencyEntryIds", strings(&v.dependency_entry_ids)); + vo.insert("sandboxPolicyVersion", v.sandbox_policy_version.as_str().into()); + Json::Object(vo) + }) + .collect(); + o.insert("platformVariants", Json::Array(variants)); + o.insert("provenance", self.provenance.to_json()); + Json::Object(o) + } +} + +/// Result of `get_catalog_info` (design §5.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogInfo { + pub catalog_schema_version: String, + pub catalog_revision: 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()); + Json::Object(o) + } +} diff --git a/policy-catalog/rust/src/paths.rs b/policy-catalog/rust/src/paths.rs new file mode 100644 index 000000000..546070a1c --- /dev/null +++ b/policy-catalog/rust/src/paths.rs @@ -0,0 +1,517 @@ +// 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 (TypeScript `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::model::Platform; +use crate::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 (TypeScript `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 { + 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| case_key(segment, platform)).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/policy-catalog/rust/src/purl.rs b/policy-catalog/rust/src/purl.rs new file mode 100644 index 000000000..e3c0366c4 --- /dev/null +++ b/policy-catalog/rust/src/purl.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Package URL parsing reduced to identity matching (TypeScript `src/purl.ts`). + +/// A parsed package URL. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedPurl { + /// `type/[namespace/]name` with the type lower-cased. Version-free. + pub key: String, + pub version: Option, +} + +/// 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 == '-') +} + +/// Parses `pkg:type/namespace/name@version?qualifiers#subpath`. +pub fn parse_purl(value: &str) -> Option { + let rest = value.strip_prefix("pkg:")?; + let rest = rest.split('#').next().unwrap_or(""); + let mut rest = rest.split('?').next().unwrap_or(""); + let last_slash = rest.rfind('/'); + let at = rest.rfind('@'); + let mut version = None; + if let Some(at) = at { + if last_slash.is_none_or(|slash| at > slash) { + version = Some(decode_uri_component(&rest[at + 1..])?); + rest = &rest[..at]; + } + } + let segments: Vec<&str> = rest.split('/').collect(); + if segments.len() < 2 || segments.iter().any(|s| s.is_empty()) { + return None; + } + if !is_purl_type(segments[0]) { + return None; + } + Some(ParsedPurl { + key: format!("{}/{}", segments[0].to_ascii_lowercase(), segments[1..].join("/")), + version: version.filter(|v| !v.is_empty()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn purl(key: &str, version: Option<&str>) -> Option { + Some(ParsedPurl { + key: key.into(), + version: version.map(Into::into), + }) + } + + #[test] + fn parses_like_typescript() { + assert_eq!(parse_purl("pkg:npm/npm@10.9.0"), purl("npm/npm", Some("10.9.0"))); + assert_eq!( + parse_purl("pkg:NPM/%40scope/pkg@1.0.0?x=y#sub"), + purl("npm/%40scope/pkg", Some("1.0.0")) + ); + assert_eq!(parse_purl("pkg:npm/npm"), purl("npm/npm", None)); + assert_eq!(parse_purl("pkg:npm/npm@"), purl("npm/npm", None)); + assert_eq!(parse_purl("pkg:npm/a@%E2%82%AC"), purl("npm/a", Some("\u{20ac}"))); + assert_eq!(parse_purl("npm/npm"), None); + assert_eq!(parse_purl("pkg:npm"), None); + assert_eq!(parse_purl("pkg:npm//x"), None); + assert_eq!(parse_purl("pkg:1pm/x"), None); + assert_eq!(parse_purl("pkg:npm/npm@%E0%A4%A"), None); + assert_eq!(parse_purl("pkg:npm/npm@%C0%AF"), None); + assert_eq!(parse_purl("pkg:npm/npm@%ED%A0%80"), None); + assert_eq!(parse_purl("pkg:npm/npm@%zz"), None); + } +} diff --git a/policy-catalog/rust/src/resolver.rs b/policy-catalog/rust/src/resolver.rs new file mode 100644 index 000000000..c6eb51a51 --- /dev/null +++ b/policy-catalog/rust/src/resolver.rs @@ -0,0 +1,644 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Runtime lookup and inspection (TypeScript `src/resolver.ts`). + +use crate::catalog::{ + composition_violation, dependency_closure, entry_index, find_cross_class_overlap, policy_symbols, select_variant, + CatalogEntry, ClosureNode, IdentityPredicate, SymbolSource, VariantSelection, COMPOSABLE_FILESYSTEM_FIELDS, +}; +use crate::errors::{invalid_context, ErrorReason, PolicyCatalogError, Result}; +use crate::host::{HostEnvironment, SystemHost}; +use crate::json::cmp_utf16; +use crate::model::{ + Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, Diagnostics, + EntryMatchRecord, FilesystemPolicy, MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, ResolveContext, + SandboxConfigResolution, SandboxPolicy, ToolCandidate, ToolInputs, ToolRecord, +}; +use crate::paths::{case_key, is_absolute_path, normalize_path, path_key_segments}; +use crate::purl::{parse_purl, ParsedPurl}; +use crate::store::{bundled_catalog_store, CatalogStore}; +use crate::text::replace_symbols; +use crate::version_range::satisfies_version_range; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; + +struct EntryMatch<'a> { + entry: &'a CatalogEntry, + selection: VariantSelection<'a>, + satisfied: Vec<&'a IdentityPredicate>, +} + +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), + ] { + 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) +} + +/// 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 (design §5.2) + // ----------------------------------------------------------------------- + + 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(), + }) + } + + /// 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(), + identity: entry + .identity + .iter() + .map(|predicate| match predicate { + IdentityPredicate::Purl { value, version_range } => CatalogIdentityMetadata::Purl { + value: value.clone(), + version_range: version_range.clone(), + }, + IdentityPredicate::InvocationName { names } => { + CatalogIdentityMetadata::InvocationName { names: names.clone() } + } + }) + .collect(), + platform_variants: entry + .platform_variants + .iter() + .map(|variant| PlatformVariantMetadata { + platform: variant.platform, + architecture: variant.architecture, + dependency_entry_ids: variant + .dependencies + .iter() + .flatten() + .map(|d| d.entry_id.clone()) + .collect(), + sandbox_policy_version: variant.sandbox_policy.version().to_string(), + }) + .collect(), + provenance: Provenance { + method: entry.provenance_method.clone(), + source_revision: entry.provenance_source_revision.clone(), + }, + }) + .collect()) + } + + // ----------------------------------------------------------------------- + // Runtime lookup (design §5.1) + // ----------------------------------------------------------------------- + + /// The composed candidate policy, or `None` when no policy can be resolved. + pub fn get_sandbox_config( + &self, + tools: impl Into, + ctx: &ResolveContext, + ) -> Result> { + Ok(self.resolve(&tools.into(), ctx)?.policy) + } + + /// Resolves one tool or a list of tools in one pass: the composed + /// candidate policy with attribution and warnings. Library failures are + /// errors, never absence. The result is a candidate lower bound, not + /// authorization. + pub fn get_sandbox_config_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 architecture = LazyArchitecture { + host: self.host.as_ref(), + value: Cell::new(ctx_architecture), + }; + + let mut warnings: Vec = Vec::new(); + let mut tool_records = Vec::new(); + let mut selected: Vec> = Vec::new(); + let mut selected_ids: HashSet<&str> = HashSet::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)); + + for (input_index, tool) in candidates.iter().enumerate() { + let matches = self.match_tool( + &ordered, + tool, + input_index, + platform, + allow_weak, + &architecture, + &mut warnings, + )?; + tool_records.push(ToolRecord { + input_index, + matches: matches + .iter() + .map(|m| EntryMatchRecord { + entry_id: m.entry.entry_id.clone(), + entry_revision: m.entry.entry_revision, + matched_identities: m + .satisfied + .iter() + .map(|p| MatchedIdentity { + kind: p.kind().to_string(), + strength: p.strength(), + }) + .collect(), + }) + .collect(), + }); + if matches.len() > 1 { + let ids: Vec<&str> = matches.iter().map(|m| m.entry.entry_id.as_str()).collect(); + warnings.push(format!( + "{} matched {} entries ({}); all contribute", + describe_input(input_index, tool), + matches.len(), + ids.join(", ") + )); + } + for m in &matches { + let nodes = + dependency_closure(m.entry, m.selection, &by_id, platform, architecture.get()?).map_err(|f| { + PolicyCatalogError::new( + ErrorReason::InvalidCatalog, + format!("dependency resolution failed: {} ({})", f.reason, f.detail), + ) + })?; + for node in nodes { + if selected_ids.insert(node.entry.entry_id.as_str()) { + selected.push(node); + } + } + } + } + + let mut diagnostics = Diagnostics { + catalog_revision: revision.catalog_revision.clone(), + tools: tool_records, + resolved_dependencies: dependency_records(&selected, &by_id), + warnings: Vec::new(), + }; + if selected.is_empty() { + diagnostics.warnings = warnings; + return Ok(SandboxConfigResolution { + policy: None, + diagnostics, + }); + } + + if ctx_architecture.is_none() { + warnings.push(format!( + "architecture was not specified; variants were selected for the native system architecture '{}'; the tool's architecture was not verified", + architecture.get()? + )); + } + for node in &selected { + if !node.exact { + warnings.push(format!( + "{} uses its architecture-neutral {platform} variant; no {}-specific variant exists", + node.entry.entry_id, + architecture.get()? + )); + } + } + + if let Some(violation) = composition_violation(&selected) { + return Err(PolicyCatalogError::new( + ErrorReason::CompositionConflict, + format!("selected entries cannot be composed: {violation}"), + )); + } + + let symbols = self.resolve_symbols(&selected, ctx, platform, &mut warnings)?; + let policy = match symbols { + None => None, + Some(symbols) => Some(compose_policy(&selected, &symbols, platform)?), + }; + diagnostics.warnings = warnings; + Ok(SandboxConfigResolution { policy, diagnostics }) + } + + #[allow(clippy::too_many_arguments)] + fn match_tool<'a>( + &self, + ordered: &[&'a CatalogEntry], + tool: &ToolCandidate, + input_index: usize, + platform: Platform, + allow_weak: bool, + architecture: &LazyArchitecture<'_>, + warnings: &mut Vec, + ) -> Result>> { + let mut purl: Option = None; + if let Some(package_url) = &tool.package_url { + purl = parse_purl(package_url); + if purl.is_none() { + return Err(invalid_context(format!( + "{}: '{package_url}' is not a valid package URL", + describe_input(input_index, tool) + ))); + } + } + let invocation = case_key(&tool.invocation_name, platform); + + let mut matches = 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 Some(selection) = select_variant(entry, platform, architecture.get()?) else { + skipped.push(format!( + "{} has no variant for {platform}/{}", + entry.entry_id, + architecture.get()? + )); + continue; + }; + for predicate in &satisfied { + let IdentityPredicate::Purl { + version_range: Some(range), + .. + } = predicate + else { + continue; + }; + let evidence = tool + .detected_version + .clone() + .or_else(|| purl.as_ref().and_then(|p| p.version.clone())); + let Some(evidence) = evidence else { + continue; + }; + let in_range = satisfies_version_range(&evidence, range); + if in_range != Some(true) { + warnings.push(format!( + "{}: detected version '{evidence}' {} the reviewed range '{range}' for {}", + describe_input(input_index, tool), + if in_range == Some(false) { + "is outside" + } else { + "could not be compared with" + }, + entry.entry_id + )); + } + } + if !strong { + warnings.push(format!( + "{} matched {} only by invocation name (weak identity)", + describe_input(input_index, tool), + entry.entry_id + )); + } + matches.push(EntryMatch { + entry, + selection, + satisfied, + }); + } + if matches.is_empty() { + let suffix = if skipped.is_empty() { + String::new() + } else { + format!(": {}", skipped.join("; ")) + }; + warnings.push(format!( + "{} matched no eligible catalog entry{suffix}", + describe_input(input_index, tool) + )); + } + Ok(matches) + } + + 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, _) 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" + ))); + }; + if definition.source == SymbolSource::Context { + return Err(invalid_context(format!( + "symbol '{name}' is supplied through ResolveContext.projectRoot, not symbols" + ))); + } + } + Ok((platform, architecture)) + } + + /// Resolves every symbol the selected entries need; `None` (with + /// warnings) when any is unresolved. Never a partial policy. + fn resolve_symbols( + &self, + nodes: &[ClosureNode<'_>], + ctx: &ResolveContext, + platform: Platform, + warnings: &mut Vec, + ) -> Result>> { + let contract = self.store.contract(); + let mut values: HashMap = HashMap::new(); + let mut missing: Vec<(String, Vec)> = Vec::new(); + for node in nodes { + for name in policy_symbols(&node.variant.sandbox_policy) { + if values.contains_key(&name) { + continue; + } + let definition = contract + .symbol(&name) + .expect("validated revisions reference declared symbols"); + let value = if definition.source == SymbolSource::Context { + ctx.project_root.clone() + } else { + let mut value = ctx.symbols.as_ref().and_then(|s| s.get(&name)).map(str::to_string); + if value.is_none() + && definition.source == SymbolSource::Host + && self.host.platform().ok() == Some(platform) + { + value = self.host.symbol(&name); + } + value + }; + let Some(value) = value else { + match missing.iter_mut().find(|(n, _)| *n == name) { + Some((_, ids)) => ids.push(node.entry.entry_id.clone()), + None => missing.push((name, vec![node.entry.entry_id.clone()])), + } + continue; + }; + if value.contains("${") || !is_absolute_path(&value, platform) { + return Err(invalid_context(format!( + "symbol '{name}' must resolve to an absolute {platform} path" + ))); + } + values.insert(name, value); + } + } + if !missing.is_empty() { + missing.sort_by(|a, b| cmp_utf16(&a.0, &b.0)); + for (name, entry_ids) in missing { + let hint = if contract.symbol(&name).map(|d| d.source) == Some(SymbolSource::Context) { + "ResolveContext.projectRoot".to_string() + } else { + format!("ResolveContext.symbols.{name}") + }; + warnings.push(format!( + "required symbol '{name}' (needed by {}) is unresolved; supply {hint}; no policy was returned", + entry_ids.join(", ") + )); + } + return Ok(None); + } + Ok(Some(values)) + } +} + +/// Distinct dependency edges among the selected entries, ordered by +/// entryId, entryRevision, then requiredVersionRange (absent first). +fn dependency_records(nodes: &[ClosureNode<'_>], by_id: &HashMap<&str, &CatalogEntry>) -> Vec { + let mut records: Vec = Vec::new(); + for node in nodes { + for dependency in node.variant.dependencies.iter().flatten() { + let target = by_id[dependency.entry_id.as_str()]; + let record = DependencyRecord { + entry_id: target.entry_id.clone(), + entry_revision: target.entry_revision, + required_version_range: dependency.version_range.clone(), + }; + if !records.contains(&record) { + records.push(record); + } + } + } + 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), + }) + }); + records +} + +/// Composes the v1 vocabulary (design §4.5). +fn compose_policy( + nodes: &[ClosureNode<'_>], + symbols: &HashMap, + platform: Platform, +) -> Result { + let mut classes: Vec<(&str, Vec)> = Vec::new(); + for field in COMPOSABLE_FILESYSTEM_FIELDS { + let mut seen: HashSet> = HashSet::new(); + let mut out = Vec::new(); + for node in nodes { + for template in node.variant.sandbox_policy.filesystem_field(field) { + let substituted = replace_symbols(template, |name| symbols[name].clone()); + let resolved = normalize_path(&substituted, platform); + if seen.insert(path_key_segments(&resolved, platform)) { + out.push(resolved); + } + } + } + classes.push((field, out)); + } + if let Some(overlap) = find_cross_class_overlap(&classes, platform) { + return Err(PolicyCatalogError::new( + ErrorReason::CompositionConflict, + format!("resolved paths overlap across access classes: {overlap}"), + )); + } + + let root = &nodes[0].variant.sandbox_policy; + let filesystem = nodes + .iter() + .any(|n| n.variant.sandbox_policy.has_filesystem()) + .then(|| { + let pick = |index: usize| Some(classes[index].1.clone()).filter(|v| !v.is_empty()); + FilesystemPolicy { + denied_paths: pick(0), + readonly_paths: pick(1), + readwrite_paths: pick(2), + } + }); + Ok(SandboxPolicy { + version: root.version().to_string(), + filesystem, + network: root.field("network").cloned(), + ui: root.field("ui").cloned(), + timeout_ms: root.field("timeoutMs").and_then(|v| v.as_f64()), + }) +} + +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 candidate policy for one or several tools, from the bundled catalog. +pub fn get_sandbox_config(tools: impl Into, ctx: &ResolveContext) -> Result> { + bundled()?.get_sandbox_config(tools, ctx) +} + +/// Composed candidate policy plus attribution, from the bundled catalog. +pub fn get_sandbox_config_with_diagnostics( + tools: impl Into, + ctx: &ResolveContext, +) -> Result { + bundled()?.get_sandbox_config_with_diagnostics(tools, ctx) +} + +/// Bundled catalog entry metadata. +pub fn list_catalog_entries() -> Result> { + bundled()?.list_catalog_entries() +} + +/// Bundled catalog schema version and revision. +pub fn get_catalog_info() -> Result { + bundled()?.get_catalog_info() +} diff --git a/policy-catalog/rust/src/store.rs b/policy-catalog/rust/src/store.rs new file mode 100644 index 000000000..1bd806fa0 --- /dev/null +++ b/policy-catalog/rust/src/store.rs @@ -0,0 +1,414 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Manifest validation and lazy, digest-checked, validated revision loading +//! (TypeScript `src/store.ts`), plus the catalog bundled into this crate. + +use crate::catalog::{ + compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, validate_contract, CatalogContract, + CatalogRevision, CATALOG_SCHEMA_VERSION, +}; +use crate::errors::{invalid_catalog, ErrorReason, PolicyCatalogError, Result}; +use crate::json::{canonical_sha256, 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, + pub sha256: 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` with correct canonical + /// digests; the default is the last revision unless `default_revision` + /// is given. `digest_overrides` replaces digests by revision id. + pub fn publishing( + contract: Json, + revisions: &[Json], + default_revision: Option<&str>, + digest_overrides: &HashMap, + ) -> 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)); + let digest = digest_overrides + .get(&id) + .cloned() + .unwrap_or_else(|| canonical_sha256(revision)); + entry.insert("sha256", Json::String(digest)); + 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` (TypeScript `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", "sha256"].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}'")); + } + let sha256 = match item.get("sha256").and_then(Json::as_str) { + Some(d) if d.len() == 64 && d.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => { + d.to_string() + } + _ => return invalid(format!("'{at}.sha256' must be a lower-case hex SHA-256 digest")), + }; + revisions.push(ManifestRevision { + catalog_revision, + file: expected_file, + sha256, + }); + } + 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, integrity-validated catalog revisions. +/// Revisions load lazily on first use, are verified against the manifest +/// digest and 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 digest = canonical_sha256(&raw); + // BREAK-VERIFY(tamper): the published-digest comparison. + if digest != listed.sha256 { + return Err(PolicyCatalogError::new( + ErrorReason::Integrity, + format!( + "catalog revision '{id}' digest {digest} does not match the published digest {}", + listed.sha256 + ), + )); + } + 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"), "/bundled_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/policy-catalog/rust/src/text.rs b/policy-catalog/rust/src/text.rs new file mode 100644 index 000000000..dc0319a60 --- /dev/null +++ b/policy-catalog/rust/src/text.rs @@ -0,0 +1,95 @@ +// 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) +} + +/// ECMAScript line terminators (what `.` in a regular expression does not match). +pub fn js_is_line_terminator(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +/// `String.prototype.toLowerCase` (locale-independent Unicode lowercasing). +pub fn js_to_lower(value: &str) -> String { + value.to_lowercase() +} + +/// Every match of `/\$\{([a-z][a-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_lowercase() || b.is_ascii_digit() || *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-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-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_lowercase() || c.is_ascii_digit() || 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").iter().map(|m| m.2).collect(); + assert_eq!(found, ["a", "b_1"]); + assert_eq!(replace_symbols("${a}/x/${a}", |_| "$&".into()), "$&/x/$&"); + assert!(is_symbol_name("git_prefix")); + assert!(!is_symbol_name("Git")); + assert!(!is_symbol_name("")); + } +} diff --git a/policy-catalog/rust/src/validate.rs b/policy-catalog/rust/src/validate.rs new file mode 100644 index 000000000..07010a2ae --- /dev/null +++ b/policy-catalog/rust/src/validate.rs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Catalog-directory validation and the `--base-ref` published-revision +//! immutability check (TypeScript `src/validate.ts`). Tooling only: the +//! runtime lookup path never runs git. + +use crate::errors::{invalid_catalog, PolicyCatalogError}; +use crate::history::{check_published_immutability, check_store_history, PublishedRevision, PublishedState}; +use crate::json::{Json, JsonObject}; +use crate::model::Platform; +use crate::paths::{is_absolute_path, normalize_path}; +use crate::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 `policy-catalog validate` prints it (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::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::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, &r.sha256) + }) + .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::store::bundled_catalog_source_dir()); + let store = crate::store::bundled_catalog_source().and_then(CatalogStore::new); + run_checks(store, dir.clone(), &dir, base_ref) +} diff --git a/policy-catalog/rust/src/version_range.rs b/policy-catalog/rust/src/version_range.rs new file mode 100644 index 000000000..8a1b02037 --- /dev/null +++ b/policy-catalog/rust/src/version_range.rs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Minimal version-range support for catalog v1, identical to TypeScript +//! `src/version-range.ts`. +//! +//! ```text +//! range := comparatorSet ( "||" comparatorSet )* +//! comparatorSet := comparator ( " " comparator )* +//! comparator := op? version +//! op := ">=" | "<=" | ">" | "<" | "=" +//! version := N ( "." N ( "." N )? )? +//! ``` +//! +//! Missing components are zero-filled. A comparator with no operator is a +//! prefix match. Prerelease and build metadata on evidence are ignored. + +use crate::text::{js_is_line_terminator, js_is_space, js_trim}; + +type Triple = [f64; 3]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Op { + Ge, + Le, + Gt, + Lt, + Eq, + Prefix, +} + +struct Comparator { + op: Op, + version: Triple, + parts: usize, +} + +/// Consumes `\d+` (ASCII) from `s`, returning the digits and the rest. +fn digits(s: &str) -> Option<(&str, &str)> { + let end = s.bytes().position(|b| !b.is_ascii_digit()).unwrap_or(s.len()); + if end == 0 { + None + } else { + Some((&s[..end], &s[end..])) + } +} + +fn number(digits: &str) -> f64 { + digits.parse::().expect("ASCII digits parse as f64") +} + +/// `/^(>=|<=|>|<|=)?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/` +fn parse_comparator(token: &str) -> Option { + let (op, rest) = if let Some(rest) = token.strip_prefix(">=") { + (Op::Ge, rest) + } else if let Some(rest) = token.strip_prefix("<=") { + (Op::Le, rest) + } else if let Some(rest) = token.strip_prefix('>') { + (Op::Gt, rest) + } else if let Some(rest) = token.strip_prefix('<') { + (Op::Lt, rest) + } else if let Some(rest) = token.strip_prefix('=') { + (Op::Eq, rest) + } else { + (Op::Prefix, token) + }; + let (major, mut rest) = digits(rest)?; + let mut version = [number(major), 0.0, 0.0]; + let mut parts = 1; + if let Some(after_dot) = rest.strip_prefix('.') { + let (minor, after_minor) = digits(after_dot)?; + version[1] = number(minor); + parts = 2; + rest = after_minor; + if let Some(after_dot) = rest.strip_prefix('.') { + let (patch, after_patch) = digits(after_dot)?; + version[2] = number(patch); + parts = 3; + rest = after_patch; + } + } + if !rest.is_empty() { + return None; + } + Some(Comparator { op, version, parts }) +} + +fn parse_range(range: &str) -> Option>> { + if js_trim(range).is_empty() { + return None; + } + let mut sets = Vec::new(); + for alternative in range.split("||") { + let tokens: Vec<&str> = js_trim(alternative) + .split(js_is_space) + .filter(|t| !t.is_empty()) + .collect(); + if tokens.is_empty() { + return None; + } + let mut comparators = Vec::new(); + for token in tokens { + comparators.push(parse_comparator(token)?); + } + sets.push(comparators); + } + Some(sets) +} + +/// True when `range` uses the supported v1 range grammar. +pub fn is_valid_version_range(range: &str) -> bool { + parse_range(range).is_some() +} + +/// `/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/` over the trimmed value. +fn parse_version_evidence(value: &str) -> Option { + let trimmed = js_trim(value); + let rest = trimmed.strip_prefix('v').unwrap_or(trimmed); + let (major, mut rest) = digits(rest)?; + let mut version = [number(major), 0.0, 0.0]; + for slot in version.iter_mut().skip(1) { + if let Some(after_dot) = rest.strip_prefix('.') { + if let Some((value, after)) = digits(after_dot) { + *slot = number(value); + rest = after; + } + } + } + if rest.is_empty() + || ((rest.starts_with('-') || rest.starts_with('+')) && !rest[1..].chars().any(js_is_line_terminator)) + { + Some(version) + } else { + None + } +} + +fn compare(left: &Triple, right: &Triple) -> i32 { + for index in 0..3 { + if left[index] != right[index] { + return if left[index] < right[index] { -1 } else { 1 }; + } + } + 0 +} + +fn satisfies_comparator(version: &Triple, comparator: &Comparator) -> bool { + let order = compare(version, &comparator.version); + match comparator.op { + Op::Ge => order >= 0, + Op::Le => order <= 0, + Op::Gt => order > 0, + Op::Lt => order < 0, + Op::Eq => order == 0, + Op::Prefix => (0..comparator.parts).all(|i| version[i] == comparator.version[i]), + } +} + +/// Evaluates `version` against `range`; `None` when either cannot be evaluated. +pub fn satisfies_version_range(version: &str, range: &str) -> Option { + let parsed_version = parse_version_evidence(version)?; + let parsed_range = parse_range(range)?; + Some( + parsed_range + .iter() + .any(|set| set.iter().all(|c| satisfies_comparator(&parsed_version, c))), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grammar() { + for range in [">=10 <12", "22", ">=1.2.3", "=1.0.0", "<2 || >=4"] { + assert!(is_valid_version_range(range), "{range}"); + } + for range in ["", "^1.2.3", "~1", "1.x", ">= 1", "1 ||", "latest", "1.2.3.4", "1"] { + assert!(!is_valid_version_range(range), "{range}"); + } + } + + #[test] + fn evaluation() { + assert_eq!(satisfies_version_range("10.9.0", ">=10 <12"), Some(true)); + assert_eq!(satisfies_version_range("v12.0.0", ">=10 <12"), Some(false)); + assert_eq!(satisfies_version_range("22.3.1", "22"), Some(true)); + assert_eq!(satisfies_version_range("23.0.0", "22"), Some(false)); + assert_eq!(satisfies_version_range("3.0.0", "<2 || >=3"), Some(true)); + assert_eq!(satisfies_version_range("10.0.0-rc.1", ">=10"), Some(true)); + assert_eq!(satisfies_version_range("nightly", ">=10"), None); + assert_eq!(satisfies_version_range(" 1.2 ", "1.2"), Some(true)); + assert_eq!(satisfies_version_range("1.0.0-a\nb", ">=1"), None); + assert_eq!(satisfies_version_range("1..2", ">=1"), None); + assert_eq!(satisfies_version_range("1.2.3.4", ">=1"), None); + } +} diff --git a/policy-catalog/rust/tests/common/mod.rs b/policy-catalog/rust/tests/common/mod.rs new file mode 100644 index 000000000..e11beb112 --- /dev/null +++ b/policy-catalog/rust/tests/common/mod.rs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared helpers for the integration tests. Tests read the repository's +//! `../conformance` and `../catalog` through `CARGO_MANIFEST_DIR`; they do +//! not ship in the `.crate`. + +#![allow(dead_code)] + +use mxc_policy_catalog::tooling::{Json, JsonObject}; +use mxc_policy_catalog::{ + Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, PolicyCatalogError, + ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs, +}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +pub fn repo_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +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_policy_catalog::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, &HashMap::new()) +} + +pub fn store_with( + revisions: &[Json], + default_revision: Option<&str>, + digests: &HashMap, +) -> Result { + CatalogStore::new(MemorySource::publishing( + contract(), + revisions, + default_revision, + digests, + )) +} + +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_policy_catalog::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("entries", Json::Array(entries)); + Json::Object(o) +} + +pub fn revision(entries: Vec) -> Json { + revision_with(entries, "2000-01-01.1") +} + +/// Minimal valid entry (linux, readwrite `${project_root}`) with 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}", + "identity":[{{"kind":"invocation-name","names":["{name}"]}}], + "platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"0.9.0-alpha","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() +} + +fn candidate(value: &Json) -> ToolInput { + match value { + Json::String(s) => ToolInput::Name(s.clone()), + Json::Object(o) => ToolInput::Candidate(ToolCandidate { + invocation_name: o.get("invocationName").and_then(Json::as_str).unwrap().to_string(), + package_url: o.get("packageUrl").and_then(Json::as_str).map(str::to_string), + detected_version: o.get("detectedVersion").and_then(Json::as_str).map(str::to_string), + }), + other => panic!("unsupported tool input {other}"), + } +} + +/// Fixture `tools` (single input or array) as library inputs. +pub fn tools_from(value: &Json) -> ToolInputs { + match value { + Json::Array(items) => ToolInputs(items.iter().map(candidate).collect()), + single => ToolInputs(vec![candidate(single)]), + } +} + +/// Fixture `context` as a library context. +pub fn context_from(value: Option<&Json>) -> ResolveContext { + let mut ctx = ResolveContext::new(); + let Some(value) = value else { return ctx }; + let s = |k: &str| value.get(k).and_then(Json::as_str).map(str::to_string); + ctx.project_root = s("projectRoot"); + ctx.platform = s("platform"); + ctx.architecture = s("architecture"); + ctx.catalog_revision = s("catalogRevision"); + ctx.allow_weak_identity_fallback = value.get("allowWeakIdentityFallback").and_then(Json::as_bool) == Some(true); + if let Some(symbols) = value.get("symbols").and_then(Json::as_object) { + let mut map = SymbolMap::new(); + for (k, v) in symbols.iter() { + map.insert(k, v.as_str().unwrap()); + } + ctx.symbols = Some(map); + } + ctx +} diff --git a/policy-catalog/rust/tests/conformance.rs b/policy-catalog/rust/tests/conformance.rs new file mode 100644 index 000000000..ca0af22df --- /dev/null +++ b/policy-catalog/rust/tests/conformance.rs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Runs every language-neutral conformance fixture in +//! `../conformance/fixtures/*.json` with the same expectations as the +//! TypeScript suite. + +mod common; + +use common::*; +use mxc_policy_catalog::tooling::{Json, JsonObject}; +use mxc_policy_catalog::{Architecture, Platform, PolicyCatalogError}; + +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 = repo_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 mut cases = 0; + for name in names { + let fixture = read_json(dir.join(&name)); + 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()); + let host_platform = case + .get("host") + .and_then(|h| h.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 = fixed_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 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.get_sandbox_config_with_diagnostics(tools.clone(), &ctx)), + expected, + "{case_name}" + ); + assert_eq!( + failure(catalog.get_sandbox_config(tools, &ctx)), + expected, + "{case_name}" + ); + cases += 1; + continue; + } + let expect = case.get("expect").unwrap(); + let mut expected = JsonObject::new(); + let expected_policy = expect.get("policy").filter(|p| !p.is_null()).cloned(); + if let Some(policy) = &expected_policy { + expected.insert("policy", policy.clone()); + } + expected.insert("diagnostics", expect.get("diagnostics").unwrap().clone()); + let expected = Json::Object(expected); + + let actual = catalog + .get_sandbox_config_with_diagnostics(tools.clone(), &ctx) + .unwrap(); + assert_eq!(actual.to_json(), expected, "{case_name}\nactual: {}", actual.to_json()); + let policy = catalog.get_sandbox_config(tools.clone(), &ctx).unwrap(); + assert_eq!(policy.map(|p| p.to_json()), expected_policy, "{case_name}"); + if !matches!(raw_tools, Json::Array(_)) { + let as_array = tools_from(&Json::Array(vec![raw_tools.clone()])); + let again = catalog.get_sandbox_config_with_diagnostics(as_array, &ctx).unwrap(); + assert_eq!(again, actual, "{case_name}: single input == one-element array"); + } + cases += 1; + } + } + assert!(cases >= 20, "ran {cases} conformance cases"); +} diff --git a/policy-catalog/rust/tests/library.rs b/policy-catalog/rust/tests/library.rs new file mode 100644 index 000000000..08e661445 --- /dev/null +++ b/policy-catalog/rust/tests/library.rs @@ -0,0 +1,1048 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Ports of the TypeScript unit suites (`tests/unit/resolver.test.ts`, +//! `validation.test.ts`, `store.test.ts`). + +mod common; + +use common::*; +use mxc_policy_catalog::tooling::{ + canonical_json, canonical_sha256, check_entry_revisions, check_published_immutability, check_store_history, + validate_catalog_revision, validate_contract, Json, PublishedRevision, PublishedState, +}; +use mxc_policy_catalog::{ + get_catalog_info, get_sandbox_config, get_sandbox_config_with_diagnostics, list_catalog_entries, Architecture, + ErrorReason, FixedHost, HostEnvironment, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, + ToolCandidate, ToolInput, ToolInputs, +}; +use std::collections::HashMap; +use std::sync::Arc; + +const V: &str = "0.9.0-alpha"; + +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() + ); +} + +fn linux(policy: &str, extra: &str) -> String { + let extra = if extra.is_empty() { + String::new() + } else { + format!(",{extra}") + }; + format!(r#"{{"when":{{"platform":"linux"}}{extra},"sandboxPolicy":{policy}}}"#) +} + +fn variants(list: &[String]) -> String { + format!(r#"{{"platformVariants":[{}]}}"#, list.join(",")) +} + +fn fs_paths(policy: &Option) -> Json { + policy.as_ref().unwrap().filesystem.as_ref().unwrap().to_json() +} + +// --------------------------------------------------------------------------- +// Runtime lookup: defaults +// --------------------------------------------------------------------------- + +fn arch_catalog(host: Arc) -> PolicyCatalog { + catalog_for( + revision(vec![entry( + "tool:a", + &format!( + r#"{{"identity":[{{"kind":"purl","value":"pkg:npm/a"}},{{"kind":"invocation-name","names":["a"]}}], + "platformVariants":[ + {{"when":{{"platform":"windows","architecture":"x64"}},"sandboxPolicy":{{"version":"{V}","timeoutMs":1}}}}, + {{"when":{{"platform":"windows","architecture":"arm64"}},"sandboxPolicy":{{"version":"{V}","timeoutMs":2}}}}]}}"# + ), + )]), + host, + ) +} + +#[test] +fn omitted_context_uses_host_platform_native_arch_default_revision_no_weak() { + let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); + assert_eq!(catalog.get_sandbox_config("a", &ResolveContext::new()).unwrap(), None); + let result = catalog + .get_sandbox_config_with_diagnostics( + ToolCandidate::new("a").with_package_url("pkg:npm/a"), + &ResolveContext::new(), + ) + .unwrap(); + assert_eq!(result.policy.unwrap().timeout_ms, Some(2.0)); + assert_eq!(result.diagnostics.catalog_revision, "2000-01-01.1"); + assert!(result + .diagnostics + .warnings + .join("\n") + .contains("native system architecture 'arm64'; the tool's architecture was not verified")); +} + +#[test] +fn explicit_architecture_wins_and_suppresses_host_default_warning() { + let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); + let result = catalog + .get_sandbox_config_with_diagnostics( + ToolCandidate::new("a").with_package_url("pkg:npm/a"), + &ResolveContext::new().architecture(Architecture::X64), + ) + .unwrap(); + assert_eq!(result.policy.unwrap().timeout_ms, Some(1.0)); + assert!(result.diagnostics.warnings.is_empty()); +} + +#[test] +fn another_architecture_is_never_a_fallback() { + let catalog = catalog_for( + revision(vec![entry( + "tool:a", + &format!( + r#"{{"platformVariants":[{{"when":{{"platform":"linux","architecture":"arm64"}},"sandboxPolicy":{{"version":"{V}"}}}}]}}"# + ), + )]), + linux_x64(), + ); + let result = catalog + .get_sandbox_config_with_diagnostics("a", &weak().architecture(Architecture::X64)) + .unwrap(); + assert_eq!(result.policy, None); + assert!(result.diagnostics.warnings[0].contains("tool:a has no variant for linux/x64")); +} + +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 + } +} + +#[test] +fn host_detection_failure_is_an_error_and_only_attempted_when_needed() { + let store = Arc::new(store_for(&[revision(vec![entry("tool:t", "")])]).unwrap()); + let catalog = PolicyCatalog::with_host(store, Arc::new(FailingHost)); + let error = catalog.get_sandbox_config("t", &weak().project_root("/p")).unwrap_err(); + assert!(error.message().contains("unknown machine")); + assert_eq!(error.reason(), ErrorReason::UnsupportedHost); + assert_eq!(catalog.get_sandbox_config("nothing", &weak()).unwrap(), None); + let policy = catalog + .get_sandbox_config("t", &weak().architecture(Architecture::X64).project_root("/p")) + .unwrap(); + assert_eq!(fs_paths(&policy), j(r#"{"readwritePaths":["/p"]}"#)); +} + +#[test] +fn never_fabricates_project_root_or_caller_symbols() { + let result = bundled_catalog(linux_x64()) + .get_sandbox_config_with_diagnostics("git", &weak().architecture(Architecture::X64)) + .unwrap(); + assert_eq!(result.policy, None); + let warnings = result.diagnostics.warnings.join("\n"); + let a = warnings.find("required symbol 'git_prefix'").unwrap(); + let b = warnings.find("required symbol 'project_root'").unwrap(); + assert!(a < b); +} + +#[test] +fn host_symbols_only_for_current_host_platform_and_caller_overrides() { + let rev = revision(vec![entry( + "tool:t", + &format!( + r#"{{"platformVariants":[ + {{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{user_home}}/.cfg"]}}}}}}, + {{"when":{{"platform":"macos"}},"sandboxPolicy":{{"version":"{V}","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); + assert_eq!( + fs_paths(&catalog.get_sandbox_config("t", &weak()).unwrap()), + j(r#"{"readonlyPaths":["/home/me/.cfg"]}"#) + ); + assert_eq!( + catalog + .get_sandbox_config("t", &weak().platform(Platform::Macos).architecture(Architecture::Arm64)) + .unwrap(), + None + ); + assert_eq!( + fs_paths( + &catalog + .get_sandbox_config("t", &weak().symbol("user_home", "/srv/u")) + .unwrap() + ), + j(r#"{"readonlyPaths":["/srv/u/.cfg"]}"#) + ); +} + +// --------------------------------------------------------------------------- +// Inputs and failures +// --------------------------------------------------------------------------- + +#[test] +fn string_object_and_one_element_array_are_equivalent() { + let catalog = bundled_catalog(linux_x64()); + let ctx = weak() + .architecture(Architecture::X64) + .project_root("/p") + .symbol("git_prefix", "/g"); + let a = catalog.get_sandbox_config_with_diagnostics("git", &ctx).unwrap(); + assert_eq!( + catalog + .get_sandbox_config_with_diagnostics(ToolCandidate::new("git"), &ctx) + .unwrap(), + a + ); + assert_eq!( + catalog.get_sandbox_config_with_diagnostics(vec!["git"], &ctx).unwrap(), + a + ); + assert_eq!(a.diagnostics.tools[0].input_index, 0); + let strict = ResolveContext::new() + .architecture(Architecture::X64) + .project_root("/p") + .symbol("git_prefix", "/g"); + assert_eq!(catalog.get_sandbox_config("git", &strict).unwrap(), None); + assert_eq!( + catalog.get_sandbox_config(ToolCandidate::new("git"), &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_package_url("npm").into(), + ResolveContext::new(), + ), + ( + ToolCandidate::new("npm").with_detected_version("").into(), + ResolveContext::new(), + ), + ( + ToolCandidate::new("npm").with_package_url("").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().symbol("project_root", "/x")), + ("git".into(), ResolveContext::new().symbol("__proto__", "/x")), + ("git".into(), ResolveContext::new().project_root("")), + ]; + for (tools, ctx) in cases { + assert_eq!( + reason_of(catalog.get_sandbox_config(tools.clone(), &ctx)), + ErrorReason::InvalidContext, + "{tools:?} {ctx:?}" + ); + } + // A relative or `${`-containing symbol value is rejected once it is needed. + for value in ["bin", "/x/${node_prefix}"] { + let ctx = weak().architecture(Architecture::X64).symbol("node_prefix", value); + let error = catalog.get_sandbox_config("node", &ctx).unwrap_err(); + assert_eq!( + error.message(), + "[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path" + ); + } +} + +#[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", "/n"); + let tool = ToolCandidate::new("npm").with_package_url("pkg:npm/npm"); + let mut first = catalog.get_sandbox_config_with_diagnostics(tool.clone(), &ctx).unwrap(); + first + .policy + .as_mut() + .unwrap() + .filesystem + .as_mut() + .unwrap() + .readwrite_paths + .as_mut() + .unwrap() + .push("/mutated".into()); + first.diagnostics.warnings.push("mutated".into()); + let second = catalog.get_sandbox_config_with_diagnostics(tool, &ctx).unwrap(); + assert_eq!( + fs_paths(&second.policy), + j(r#"{"readonlyPaths":["/n"],"readwritePaths":["/p","/c"]}"#) + ); + assert!(!second.diagnostics.warnings.contains(&"mutated".to_string())); +} + +#[test] +fn dedupes_normalized_paths_with_platform_casing() { + let windows = catalog_for( + revision(vec![entry( + "tool:w", + &format!( + r#"{{"platformVariants":[{{"when":{{"platform":"windows"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}","${{node_prefix}}\\"]}}}}}}]}}"# + ), + )]), + linux_x64(), + ); + let ctx = weak() + .platform(Platform::Windows) + .architecture(Architecture::X64) + .symbol("git_prefix", "C:\\Tools") + .symbol("node_prefix", "c:\\tools"); + assert_eq!( + fs_paths(&windows.get_sandbox_config("w", &ctx).unwrap()), + j(r#"{"readonlyPaths":["C:\\Tools"]}"#) + ); + let tmpl = |platform: &str, id: &str| { + entry( + id, + &format!( + r#"{{"platformVariants":[{{"when":{{"platform":"{platform}"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}","${{node_prefix}}/"]}}}}}}]}}"# + ), + ) + }; + let linux = catalog_for(revision(vec![tmpl("linux", "tool:l")]), linux_x64()); + let ctx = weak().symbol("git_prefix", "/Tools").symbol("node_prefix", "/tools"); + assert_eq!( + fs_paths(&linux.get_sandbox_config("l", &ctx).unwrap()), + j(r#"{"readonlyPaths":["/Tools","/tools"]}"#) + ); + let mac = catalog_for(revision(vec![tmpl("macos", "tool:m")]), linux_x64()); + let ctx = ctx.platform(Platform::Macos).architecture(Architecture::Arm64); + assert_eq!( + fs_paths(&mac.get_sandbox_config("m", &ctx).unwrap()), + j(r#"{"readonlyPaths":["/Tools"]}"#) + ); +} + +#[test] +fn overlap_detection_follows_platform_casing() { + let make = |platform: &str| { + catalog_for( + revision(vec![entry( + "tool:m", + &format!( + r#"{{"platformVariants":[{{"when":{{"platform":"{platform}"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}"],"readwritePaths":["${{project_root}}"]}}}}}}]}}"# + ), + )]), + linux_x64(), + ) + }; + let ctx = weak() + .architecture(Architecture::X64) + .project_root("/tools/work") + .symbol("git_prefix", "/Tools"); + assert_eq!( + reason_of(make("macos").get_sandbox_config("m", &ctx.clone().platform(Platform::Macos))), + ErrorReason::CompositionConflict + ); + assert_eq!( + fs_paths( + &make("linux") + .get_sandbox_config("m", &ctx.platform(Platform::Linux)) + .unwrap() + ), + j(r#"{"readonlyPaths":["/Tools"],"readwritePaths":["/tools/work"]}"#) + ); +} + +#[test] +fn invocation_name_casing_per_platform() { + let variants_all = ["linux", "macos", "windows"] + .iter() + .map(|p| format!(r#"{{"when":{{"platform":"{p}"}},"sandboxPolicy":{{"version":"{V}"}}}}"#)) + .collect::>() + .join(","); + let catalog = catalog_for( + revision(vec![entry( + "tool:gh", + &format!(r#"{{"platformVariants":[{variants_all}]}}"#), + )]), + linux_x64(), + ); + let on = |platform: Platform, name: &str| -> Vec { + catalog + .get_sandbox_config_with_diagnostics(name, &weak().platform(platform).architecture(Architecture::X64)) + .unwrap() + .diagnostics + .tools[0] + .matches + .iter() + .map(|m| m.entry_id.clone()) + .collect() + }; + assert_eq!(on(Platform::Linux, "gh"), ["tool:gh"]); + assert!(on(Platform::Linux, "GH").is_empty()); + assert_eq!(on(Platform::Macos, "GH"), ["tool:gh"]); + assert_eq!(on(Platform::Windows, "Gh"), ["tool:gh"]); +} + +#[test] +fn dependency_chain_composes_each_entry_once_in_order() { + let variant = |deps: &[&str], path: &str| { + let deps = if deps.is_empty() { + String::new() + } else { + format!( + r#","dependencies":[{}]"#, + deps.iter() + .map(|d| format!(r#"{{"entryId":"{d}"}}"#)) + .collect::>() + .join(",") + ) + }; + format!( + r#"{{"platformVariants":[{{"when":{{"platform":"linux"}}{deps},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{project_root}}/{path}"]}}}}}}]}}"# + ) + }; + let catalog = catalog_for( + revision(vec![ + entry("tool:top", &variant(&["tool:left", "tool:right"], "top")), + entry("tool:left", &variant(&["tool:leaf"], "left")), + entry("tool:right", &variant(&["tool:leaf"], "right")), + entry("tool:leaf", &variant(&[], "leaf")), + ]), + linux_x64(), + ); + let result = catalog + .get_sandbox_config_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_paths(&result.policy), + j(r#"{"readonlyPaths":["/r/top","/r/left","/r/leaf","/r/right"]}"#) + ); +} + +#[test] +fn dependency_diagnostics_keep_distinct_ranges_sorted() { + let catalog = catalog_for( + revision(vec![ + entry( + "tool:a", + &variants(&[linux( + &format!(r#"{{"version":"{V}"}}"#), + r#""dependencies":[{"entryId":"tool:c","versionRange":">=2"}]"#, + )]), + ), + entry( + "tool:b", + &variants(&[linux( + &format!(r#"{{"version":"{V}"}}"#), + r#""dependencies":[{"entryId":"tool:c"}]"#, + )]), + ), + entry("tool:c", &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")])), + ]), + linux_x64(), + ); + let result = catalog + .get_sandbox_config_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},{"entryId":"tool:c","entryRevision":1,"requiredVersionRange":">=2"}]"# + ) + ); + assert_eq!(result.policy.unwrap().to_json(), j(&format!(r#"{{"version":"{V}"}}"#))); +} + +#[test] +fn catalog_file_order_does_not_matter() { + let e = |id: &str, p: &str| { + entry( + id, + &format!( + r#"{{"identity":[{{"kind":"invocation-name","names":["x"]}}],"platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{project_root}}/{p}"]}}}}}}]}}"# + ), + ) + }; + let ctx = weak().project_root("/r"); + let forward = catalog_for(revision(vec![e("tool:b", "b"), e("tool:a", "a")]), linux_x64()) + .get_sandbox_config_with_diagnostics("x", &ctx) + .unwrap(); + let backward = catalog_for(revision(vec![e("tool:a", "a"), e("tool:b", "b")]), linux_x64()) + .get_sandbox_config_with_diagnostics("x", &ctx) + .unwrap(); + assert_eq!(forward, backward); + assert_eq!(fs_paths(&forward.policy), j(r#"{"readonlyPaths":["/r/a","/r/b"]}"#)); +} + +#[test] +fn mixed_versions_across_inputs_conflict() { + let catalog = catalog_for( + revision(vec![ + entry("tool:a", &variants(&[linux(r#"{"version":"0.8.0-alpha"}"#, "")])), + entry("tool:b", &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")])), + ]), + linux_x64(), + ); + let error = catalog.get_sandbox_config(vec!["a", "b"], &weak()).unwrap_err(); + assert_eq!( + error.message(), + "[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)" + ); + assert_eq!( + catalog.get_sandbox_config("a", &weak()).unwrap().unwrap().to_json(), + j(r#"{"version":"0.8.0-alpha"}"#) + ); +} + +// --------------------------------------------------------------------------- +// 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-09-29.1"}"#) + ); + 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"]); + let npm = entries.iter().find(|e| e.entry_id == "tool:npm").unwrap().to_json(); + assert_eq!( + npm.get("platformVariants").unwrap().as_array().unwrap()[0], + j(&format!( + r#"{{"platform":"windows","dependencyEntryIds":["tool:node"],"sandboxPolicyVersion":"{V}"}}"# + )) + ); + let text = Json::Array(entries.iter().map(|e| e.to_json()).collect()).to_compact_string(); + assert!(!text.contains("Paths") && !text.contains("${") && !text.contains("sandboxPolicy\"")); +} + +#[test] +fn module_level_functions_use_the_bundled_catalog() { + let ctx = weak().platform(Platform::Linux).architecture(Architecture::X64); + assert_eq!(get_sandbox_config("definitely-unknown-tool", &ctx).unwrap(), None); + let result = get_sandbox_config_with_diagnostics(vec![ToolInput::from("definitely-unknown-tool")], &ctx).unwrap(); + assert_eq!(result.policy, None); + assert_eq!( + result.diagnostics.to_json().get("tools").unwrap(), + &j(r#"[{"inputIndex":0,"matches":[]}]"#) + ); + assert_eq!( + reason_of(get_sandbox_config("git", &ctx.catalog_revision("1999-01-01.1"))), + ErrorReason::RevisionUnavailable + ); +} + +// --------------------------------------------------------------------------- +// Validation (validation.test.ts) +// --------------------------------------------------------------------------- + +fn dep(id: &str) -> String { + variants(&[linux( + &format!(r#"{{"version":"{V}"}}"#), + &format!(r#""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: cycle (tool:a -> tool:b -> tool:c -> tool:a)" + .replace("windows", "linux") + ); + 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'", + ); + validate_err( + vec![ + entry( + "tool:a", + &variants(&[linux( + &format!(r#"{{"version":"{V}"}}"#), + r#""dependencies":[{"entryId":"tool:b","versionRange":"^1.x"}]"#, + )]), + ), + entry("tool:b", ""), + ], + "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}}},"sandboxPolicy":{{"version":"{V}"}}}}"#) + }; + 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", + ); +} + +#[test] +fn validation_policy_rules() { + let p = |policy: &str| vec![entry("tool:a", &variants(&[linux(policy, "")]))]; + validate_err( + p(&format!(r#"{{"version":"{V}","processContainer":{{}}}}"#)), + "containment backend", + ); + validate_err( + p(&format!(r#"{{"version":"{V}","containment":"lxc"}}"#)), + "containment backend", + ); + validate_err(p(r#"{"version":"0.9.0"}"#), "not a SandboxPolicy version registered"); + validate_err( + p(r#"{"version":"0.10.0-alpha"}"#), + "not a SandboxPolicy version registered", + ); + validate_err( + vec![entry("tool:a", r#"{"extra":1}"#)], + "unsupported field 'entries[0].extra'", + ); + validate_err( + p(&format!( + r#"{{"version":"{V}","filesystem":{{"clearPolicyOnExit":true}}}}"# + )), + "unsupported field", + ); + validate_err( + p(&format!(r#"{{"version":"{V}","network":{{"allowOutbound":true}}}}"#)), + "unsupported field", + ); + validate_err( + p(&format!(r#"{{"version":"{V}","telemetry":{{}}}}"#)), + "unsupported field", + ); + let path = |value: &str| { + p(&format!( + r#"{{"version":"{V}","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("${project_root}\\x")).unwrap(); + let net = |n: &str| p(&format!(r#"{{"version":"{V}","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":0}]}]}}"#), + "must be an integer in 1..65535", + ); + 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(net(r#"{"egress":{"default":"deny","allow":[{"to":[{"cidr":"192.0.2.0/24"}],"ports":[{"protocol":"tcp","port":443}]}]}}"#)).unwrap(); + validate_err( + p(&format!(r#"{{"version":"{V}","timeoutMs":1.5}}"#)), + "timeoutMs' must be a positive integer", + ); + validate_err( + p(&format!(r#"{{"version":"{V}","ui":{{"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(vec![ + entry("tool:a", r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"}]}"#), + entry("tool:b", r#"{"identity":[{"kind":"purl","value":"pkg:NPM/x"}]}"#), + ]) + .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":"invocation-name","names":["\u00c9","\u00e9"]}]}"#, + )], + "repeats identity", + ); + 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 not pin a version", + ); + validate_err( + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"invocation-name","names":["bin/a"]}]}"#, + )], + "bare invocation name", + ); + validate_err( + vec![entry("tool:a", r#"{"identity":[{"kind":"sha256","value":"x"}]}"#)], + "not a supported identity kind", + ); + 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_composition_vocabulary() { + let with_dep = |policy: &str, dep_policy: &str| { + validate(vec![ + entry( + "tool:a", + &variants(&[linux(policy, r#""dependencies":[{"entryId":"tool:b"}]"#)]), + ), + entry("tool:b", &variants(&[linux(dep_policy, "")])), + ]) + }; + with_dep( + &format!(r#"{{"version":"{V}","filesystem":{{"readwritePaths":["${{project_root}}"],"readonlyPaths":["${{node_prefix}}"]}}}}"#), + &format!(r#"{{"version":"{V}","filesystem":{{"readonlyPaths":["${{node_prefix}}","${{git_prefix}}"],"deniedPaths":["${{user_home}}/.ssh"]}}}}"#), + ) + .unwrap(); + let err = |r: Result<(), PolicyCatalogError>, needle: &str| { + let m = r.unwrap_err().message().to_string(); + assert!(m.contains(needle), "{m}"); + }; + err( + with_dep( + &format!(r#"{{"version":"{V}","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}"#), + &format!(r#"{{"version":"{V}","filesystem":{{"deniedPaths":["${{project_root}}/secrets"]}}}}"#), + ), + "'${project_root}/secrets' (deniedPaths) overlaps '${project_root}' (readwritePaths)", + ); + err( + with_dep(&format!(r#"{{"version":"{V}"}}"#), r#"{"version":"0.8.0-alpha"}"#), + "mixed sandboxPolicy.version", + ); + err( + with_dep( + &format!(r#"{{"version":"{V}","network":{{"egress":{{"default":"deny"}}}}}}"#), + &format!(r#"{{"version":"{V}"}}"#), + ), + "'tool:a' uses 'network', which has no v1 cross-entry composition rule", + ); + err( + with_dep( + &format!(r#"{{"version":"{V}"}}"#), + &format!(r#"{{"version":"{V}","timeoutMs":5}}"#), + ), + "'timeoutMs'", + ); +} + +// --------------------------------------------------------------------------- +// Store and history (store.test.ts) +// --------------------------------------------------------------------------- + +#[test] +fn bundled_store_verifies_and_canonical_digest_ignores_formatting() { + let store = mxc_policy_catalog::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_sha256(&j(r#"{"a":1,"b":2}"#)), + canonical_sha256(&j(r#"{"b":2,"a":1}"#)) + ); + assert_ne!(canonical_sha256(&j(r#"{"a":1}"#)), canonical_sha256(&j(r#"{"a":2}"#))); +} + +#[test] +fn tampered_revision_is_an_integrity_error_everywhere() { + let digests = HashMap::from([("2000-01-01.1".to_string(), "0".repeat(64))]); + let store = Arc::new(store_with(&[revision(vec![entry("tool:a", "")])], None, &digests).unwrap()); + assert_eq!(reason_of(store.revision(None)), ErrorReason::Integrity); + let catalog = PolicyCatalog::with_host(store, linux_x64()); + assert_eq!( + reason_of(catalog.get_sandbox_config("a", &weak())), + ErrorReason::Integrity + ); + assert_eq!( + reason_of(catalog.get_sandbox_config_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()); + } + let digests = HashMap::from([("2000-01-01.1".to_string(), canonical_sha256(&other))]); + assert_eq!( + reason_of(store_with(&[relabeled.clone()], None, &digests).unwrap().revision(None)), + ErrorReason::Integrity + ); + // Correct digest but wrong declared id → integrity (revision-id check). + let digests = HashMap::from([("2000-01-01.1".to_string(), canonical_sha256(&other))]); + let source = mxc_policy_catalog::MemorySource { + files: HashMap::from([("revisions/2000-01-01.1.json".to_string(), other.clone())]), + ..mxc_policy_catalog::MemorySource::publishing(contract(), &[relabeled], None, &digests) + }; + let error = mxc_policy_catalog::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"), + &HashMap::new() + )), + 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.get_sandbox_config_with_diagnostics("a", ctx).unwrap(); + ( + r.diagnostics.catalog_revision.clone(), + r.diagnostics.tools[0].matches[0].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 + .get_sandbox_config("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", &"a".repeat(64)); + 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", &"b".repeat(64)), + ], + 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 redigested = PublishedState { + revisions: vec![PublishedRevision::new( + "2000-01-01.1", + "revisions/2000-01-01.1.json", + &"c".repeat(64), + )], + files, + }; + assert_eq!( + check_published_immutability(&base, &redigested), + ["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/policy-catalog/rust/tests/vectors.rs b/policy-catalog/rust/tests/vectors.rs new file mode 100644 index 000000000..e14754407 --- /dev/null +++ b/policy-catalog/rust/tests/vectors.rs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared vectors in `../conformance/vectors/` (generated from the +//! TypeScript implementation). + +mod common; + +use common::*; +use mxc_policy_catalog::tooling::{ + canonical_json, canonical_sha256, is_absolute_path, normalize_path, parse_purl, path_key_segments, Json, ParsedPurl, +}; +use mxc_policy_catalog::Platform; + +#[test] +fn canonical_json_vectors() { + let vectors = read_json( + repo_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}" + ); + assert_eq!( + canonical_sha256(&value), + case.get("sha256").and_then(Json::as_str).unwrap(), + "{text}" + ); + } +} + +#[test] +fn path_vectors() { + let vectors = read_json(repo_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); + assert_eq!( + parse_purl("pkg:npm/npm@10.9.0"), + Some(ParsedPurl { + key: "npm/npm".into(), + version: Some("10.9.0".into()) + }) + ); +} + +/// The embedded catalog must equal the repository's `../catalog` (canonical +/// content), so a stale crate-local copy can never ship unnoticed. +#[test] +fn embedded_catalog_matches_repository_catalog() { + let dir = repo_dir().join("catalog"); + if !dir.join("manifest.json").is_file() { + eprintln!("skipped: ../catalog not present (packaged crate)"); + return; + } + let embedded = mxc_policy_catalog::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}" + ); + } +} diff --git a/policy-catalog/scripts/rust-functional.mjs b/policy-catalog/scripts/rust-functional.mjs new file mode 100644 index 000000000..d03bbffd4 --- /dev/null +++ b/policy-catalog/scripts/rust-functional.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Functional tests of the PACKAGED Rust crate (rust/). Cross-platform driver: +// +// 1. copies ../catalog into rust/catalog (rust/scripts/sync-catalog.mjs) +// 2. `cargo package` in rust/ (verifies the packaged crate builds in isolation) +// 3. extracts target/package/mxc-policy-catalog-*.crate into a fresh temp +// directory outside the repository (os.tmpdir()) +// 4. builds the packaged `policy-catalog` binary from the extracted crate +// 5. creates a consumer crate from rust/functional/ (Cargo.toml.in, src/, +// tests/) that depends on the extracted crate by `path`, and runs its tests +// +// Every build uses a CARGO_TARGET_DIR inside the temp directory, so nothing +// collides with rust/target. Prints test counts; exits nonzero on failure. +// +// node scripts/rust-functional.mjs [--allow-dirty] [--keep] +import { execFileSync, spawnSync } from 'node:child_process'; +import { copyFileSync, cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const allowDirty = process.argv.includes('--allow-dirty'); +const keep = process.argv.includes('--keep'); +const crateDir = fileURLToPath(new URL('../rust/', import.meta.url)); +const repoDir = fileURLToPath(new URL('../', import.meta.url)); +const exe = process.platform === 'win32' ? '.exe' : ''; + +function run(cmd, args, options = {}) { + console.log(`> ${cmd} ${args.join(' ')}${options.cwd ? ` (in ${options.cwd})` : ''}`); + const result = spawnSync(cmd, args, { stdio: 'inherit', ...options }); + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(' ')} failed with exit code ${result.status ?? result.signal}`); + } +} + +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-rust-functional-')); +const rel = relative(repoDir, work); +if (!(rel.startsWith('..') || isAbsolute(rel))) { + throw new Error(`temp directory ${work} is inside the repository`); +} + +let exitCode = 1; +try { + run(process.execPath, [join(crateDir, 'scripts', 'sync-catalog.mjs')]); + run('cargo', ['package', ...(allowDirty ? ['--allow-dirty'] : [])], { cwd: crateDir }); + + const packageDir = join(crateDir, 'target', 'package'); + const crates = readdirSync(packageDir) + .filter(name => /^mxc-policy-catalog-.*\.crate$/.test(name)) + .map(name => join(packageDir, name)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + if (crates.length === 0) { + throw new Error(`no .crate in ${packageDir}`); + } + const crate = crates[0]; + console.log(`Packaged crate: ${crate}`); + + // A .crate is a gzip'd tarball with one top-level `-/` directory. + const extractRoot = join(work, 'extracted'); + rmSync(extractRoot, { recursive: true, force: true }); + execFileSync('tar', ['-xzf', crate, '-C', work.replaceAll('\\', '/')], { stdio: 'inherit', cwd: work }); + const extracted = readdirSync(work) + .filter(name => name.startsWith('mxc-policy-catalog-') && statSync(join(work, name)).isDirectory()) + .map(name => join(work, name))[0]; + if (!extracted) { + throw new Error('the .crate did not contain a mxc-policy-catalog-* directory'); + } + console.log(`Extracted to: ${extracted}`); + + // Build the packaged binary from the extracted crate. + const crateTarget = join(work, 'target-crate'); + run('cargo', ['build', '--locked', '--bin', 'policy-catalog'], { + cwd: extracted, + env: { ...process.env, CARGO_TARGET_DIR: crateTarget }, + }); + const bin = join(crateTarget, 'debug', `policy-catalog${exe}`); + + // Consumer crate from the template. + const consumer = join(work, 'consumer'); + const template = join(crateDir, 'functional'); + cpSync(join(template, 'src'), join(consumer, 'src'), { recursive: true }); + cpSync(join(template, 'tests'), join(consumer, 'tests'), { recursive: true }); + const manifest = readFileSync(join(template, 'Cargo.toml.in'), 'utf8').replaceAll('@CRATE_PATH@', extracted.replaceAll('\\', '/')); + writeFileSync(join(consumer, 'Cargo.toml'), manifest); + // Pin dependency versions to the packaged crate's lock file and toolchain. + copyFileSync(join(extracted, 'Cargo.lock'), join(consumer, 'Cargo.lock')); + copyFileSync(join(extracted, 'rust-toolchain.toml'), join(consumer, 'rust-toolchain.toml')); + + const testWork = join(work, 'test-work'); + const result = spawnSync('cargo', ['test', '--', '--test-threads=4'], { + cwd: consumer, + encoding: 'utf8', + env: { + ...process.env, + CARGO_TARGET_DIR: join(work, 'target-consumer'), + POLICY_CATALOG_BIN: bin, + POLICY_CATALOG_WORK: testWork, + }, + }); + process.stdout.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + let passed = 0; + let failed = 0; + for (const match of (result.stdout ?? '').matchAll(/test result: \w+\. (\d+) passed; (\d+) failed/g)) { + passed += Number(match[1]); + failed += Number(match[2]); + } + console.log(`\nrust functional (packaged crate): ${passed} passed, ${failed} failed`); + exitCode = result.status === 0 && failed === 0 && passed > 0 ? 0 : 1; +} catch (error) { + console.error(`rust-functional: ${error.message}`); + exitCode = 1; +} finally { + if (keep) { + console.log(`Kept ${work}`); + } else { + rmSync(work, { recursive: true, force: true }); + } +} +process.exitCode = exitCode; From 03a5fdf3bd5de35324bd7732001d6460da7147c0 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 12:30:48 -0700 Subject: [PATCH 13/50] feat(policy-catalog): add .NET library Microsoft.Mxc.PolicyCatalog net8.0 library, policy-catalog CLI, and xUnit v3 unit/conformance/vector tests under policy-catalog/dotnet/ (nullable enabled, warnings as errors). Functional tests consume the packed .nupkg from a local feed in a consumer outside the repository. global.json pins SDK 10.0.401 because SDK 8 cannot read .slnx or run MTP-mode dotnet test; all projects still target net8.0. --- policy-catalog/dotnet/.gitignore | 5 + .../Microsoft.Mxc.PolicyCatalog.Cli.csproj | 21 + .../Program.cs | 281 +++++ .../ConformanceTests.cs | 107 ++ .../Microsoft.Mxc.PolicyCatalog.Tests.csproj | 33 + .../PrimitivesTests.cs | 172 +++ .../ResolverTests.cs | 303 +++++ .../StoreTests.cs | 157 +++ .../TestData.cs | 202 ++++ .../ValidationTests.cs | 182 +++ .../VectorTests.cs | 73 ++ .../dotnet/Microsoft.Mxc.PolicyCatalog.slnx | 5 + .../CatalogStore.cs | 246 ++++ .../CatalogValidation.cs | 452 ++++++++ .../Microsoft.Mxc.PolicyCatalog/Inputs.cs | 93 ++ .../Internal/CanonicalJson.cs | 72 ++ .../Internal/Catalog.cs | 1015 +++++++++++++++++ .../Internal/JsNumber.cs | 113 ++ .../Internal/JsString.cs | 213 ++++ .../Internal/Json.cs | 437 +++++++ .../Internal/Paths.cs | 478 ++++++++ .../Internal/Purl.cs | 171 +++ .../Internal/VersionRange.cs | 149 +++ .../Microsoft.Mxc.PolicyCatalog.csproj | 46 + .../PolicyCatalog.cs | 579 ++++++++++ .../PolicyCatalogException.cs | 115 ++ .../PolicyCatalogJson.cs | 286 +++++ .../Microsoft.Mxc.PolicyCatalog/Results.cs | 224 ++++ .../SystemHostEnvironment.cs | 231 ++++ policy-catalog/dotnet/README.md | 48 + .../BaseRefTests.cs | 95 ++ .../FunctionalTests.cs | 390 +++++++ .../Helpers.cs | 239 ++++ ...t.Mxc.PolicyCatalog.FunctionalTests.csproj | 24 + .../policy-catalog-cli-consumer.csproj | 26 + policy-catalog/dotnet/global.json | 9 + policy-catalog/dotnet/tools/compare-cli.mjs | 136 +++ policy-catalog/scripts/dotnet-functional.mjs | 145 +++ 38 files changed, 7573 insertions(+) create mode 100644 policy-catalog/dotnet/.gitignore create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs create mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs create mode 100644 policy-catalog/dotnet/README.md create mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs create mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs create mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs create mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj create mode 100644 policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj create mode 100644 policy-catalog/dotnet/global.json create mode 100644 policy-catalog/dotnet/tools/compare-cli.mjs create mode 100644 policy-catalog/scripts/dotnet-functional.mjs diff --git a/policy-catalog/dotnet/.gitignore b/policy-catalog/dotnet/.gitignore new file mode 100644 index 000000000..8b8f996ac --- /dev/null +++ b/policy-catalog/dotnet/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.nupkg +*.snupkg +TestResults/ diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj new file mode 100644 index 000000000..b4567ab0a --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + Exe + enable + enable + latest + true + Microsoft.Mxc.PolicyCatalog.Cli + policy-catalog + false + + false + + + + + + + diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs new file mode 100644 index 000000000..605cb1653 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Command-line harness over the public library API, output-equivalent to the +// TypeScript `policy-catalog` CLI (src/cli.ts): +// +// policy-catalog resolve [--catalog DIR] [--diagnostics] [--platform P] +// [--architecture A] [--revision R] [--project-root PATH] +// [--symbol name=value]... [--allow-weak] +// [--purl URL] [--detected-version V] ... +// policy-catalog inspect [--catalog DIR] +// policy-catalog validate [--catalog DIR] [--base-ref REF] +// +// Exit codes: 0 success (including "no policy"), 1 library failure or failed +// validation, 2 usage error. Output is JSON (2-space indent) on stdout. +using System.Text; +using Microsoft.Mxc.PolicyCatalog; + +namespace Microsoft.Mxc.PolicyCatalog.Cli; + +internal sealed class UsageException : Exception +{ + public UsageException(string message) + : base(message) + { + } +} + +internal static class Program +{ + private const string Usage = "usage: policy-catalog [options]"; + + private static readonly Encoding Utf8 = new UTF8Encoding(false); + + public static int Main(string[] argv) + { + try + { + return Run(argv); + } + catch (Exception error) + { + Write(Console.OpenStandardError(), $"{error}\n"); + return 1; + } + } + + private static void Write(Stream stream, string text) + { + var bytes = Utf8.GetBytes(text); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + } + + private static void Print(string json) + { + using var stdout = Console.OpenStandardOutput(); + Write(stdout, json + "\n"); + } + + private static string TakeValue(IReadOnlyList args, int index, string flag) + { + if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new UsageException($"{flag} requires a value"); + } + + return args[index + 1]; + } + + private static CatalogStore StoreFrom(string? catalogDir) => + catalogDir is null ? CatalogStore.Bundled() : CatalogStore.FromDirectory(Path.GetFullPath(catalogDir)); + + private static void RejectRest(List args, string command) + { + if (args.Count > 0) + { + throw new UsageException($"{command}: unexpected argument '{args[0]}'"); + } + } + + internal static int Run(string[] argv) + { + var command = argv.Length > 0 ? argv[0] : null; + var rest = argv.Skip(1).ToList(); + try + { + string? catalogDir = null; + string? baseRef = null; + var args = new List(); + for (var i = 0; i < rest.Count; i++) + { + if (rest[i] == "--catalog") + { + catalogDir = TakeValue(rest, i, "--catalog"); + i++; + } + else if (rest[i] == "--base-ref" && command == "validate") + { + baseRef = TakeValue(rest, i, "--base-ref"); + i++; + } + else + { + args.Add(rest[i]); + } + } + + switch (command) + { + case "inspect": + { + RejectRest(args, command); + var catalog = new PolicyCatalog(StoreFrom(catalogDir)); + var info = catalog.GetCatalogInfo(); + var entries = catalog.ListCatalogEntries(); + Print(PolicyCatalogJson.SerializeInspect(info, entries, 2)); + return 0; + } + + case "validate": + { + RejectRest(args, command); + var report = CatalogValidation.ValidateCatalogDirectory(catalogDir ?? CatalogValidation.BundledCatalogDirectory, baseRef); + Print(PolicyCatalogJson.Serialize(report, 2)); + return report.Ok ? 0 : 1; + } + + case "resolve": + return Resolve(args, catalogDir); + default: + throw new UsageException(command is null ? "missing command" : $"unknown command '{command}'"); + } + } + catch (UsageException error) + { + using var stderr = Console.OpenStandardError(); + Write(stderr, $"policy-catalog: {error.Message}\n{Usage}\n"); + return 2; + } + catch (PolicyCatalogException error) + { + Print(PolicyCatalogJson.Serialize(error, 2)); + return 1; + } + } + + private static int Resolve(List args, string? catalogDir) + { + string? platform = null; + string? architecture = null; + string? revision = null; + string? projectRoot = null; + var allowWeak = false; + // Insertion-ordered; a repeated name keeps its first position and takes the last value. + var symbolOrder = new List(); + var symbolValues = new Dictionary(StringComparer.Ordinal); + var tools = new List(); + var diagnostics = false; + string? purl = null; + string? detectedVersion = null; + for (var i = 0; i < args.Count; i++) + { + var arg = args[i]; + switch (arg) + { + case "--diagnostics": + diagnostics = true; + break; + case "--allow-weak": + allowWeak = true; + break; + case "--platform": + platform = TakeValue(args, i++, arg); + break; + case "--architecture": + architecture = TakeValue(args, i++, arg); + break; + case "--revision": + revision = TakeValue(args, i++, arg); + break; + case "--project-root": + projectRoot = TakeValue(args, i++, arg); + break; + case "--symbol": + { + var pair = TakeValue(args, i++, arg); + var eq = pair.IndexOf('=', StringComparison.Ordinal); + if (eq <= 0) + { + throw new UsageException("--symbol expects name=value"); + } + + var name = pair.Substring(0, eq); + if (!symbolValues.ContainsKey(name)) + { + symbolOrder.Add(name); + } + + symbolValues[name] = pair.Substring(eq + 1); + break; + } + + case "--purl": + purl = TakeValue(args, i++, arg); + break; + case "--detected-version": + detectedVersion = TakeValue(args, i++, arg); + break; + default: + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + throw new UsageException($"unknown option '{arg}'"); + } + + // --purl / --detected-version apply to the next tool name only. + tools.Add(new ToolInput(arg) { PackageUrl = purl, DetectedVersion = detectedVersion }); + purl = null; + detectedVersion = null; + break; + } + } + + if (purl is not null || detectedVersion is not null) + { + throw new UsageException("--purl/--detected-version must precede a tool name"); + } + + var context = new ResolveContext + { + Platform = platform, + Architecture = architecture, + CatalogRevision = revision, + ProjectRoot = projectRoot, + AllowWeakIdentityFallback = allowWeak, + Symbols = symbolOrder.Count > 0 ? new OrderedSymbols(symbolOrder, symbolValues) : null, + }; + var catalog = new PolicyCatalog(StoreFrom(catalogDir)); + if (diagnostics) + { + Print(PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(tools, context), 2)); + } + else + { + // `null` is JSON's rendering of "no policy"; it is never an empty policy. + Print(PolicyCatalogJson.Serialize(catalog.GetSandboxConfig(tools, context), 2)); + } + + return 0; + } + + /// A read-only dictionary that enumerates in insertion order. + private sealed class OrderedSymbols : IReadOnlyDictionary + { + private readonly List _order; + private readonly Dictionary _values; + + public OrderedSymbols(List order, Dictionary values) + { + _order = order; + _values = values; + } + + public string? this[string key] => _values[key]; + + public IEnumerable Keys => _order; + + public IEnumerable Values => _order.Select(key => _values[key]); + + public int Count => _order.Count; + + public bool ContainsKey(string key) => _values.ContainsKey(key); + + public bool TryGetValue(string key, out string? value) => _values.TryGetValue(key, out value); + + public IEnumerator> GetEnumerator() => + _order.Select(key => new KeyValuePair(key, _values[key])).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs new file mode 100644 index 000000000..d057b8009 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// Runs every case in conformance/fixtures/*.json (the TypeScript conformance runner's rules). +public sealed class ConformanceTests +{ + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (var resource in TestData.Resources("conformance/fixtures/")) + { + using var doc = JsonDocument.Parse(TestData.Resource(resource)); + var index = 0; + foreach (var testCase in doc.RootElement.GetProperty("cases").EnumerateArray()) + { + data.Add(resource, index++, testCase.GetProperty("name").GetString()!); + } + } + + return data; + } + + [Fact] + public void EveryFixtureFileHasCases() + { + var files = TestData.Resources("conformance/fixtures/").ToList(); + Assert.Contains("conformance/fixtures/bundled-catalog.json", files); + Assert.Contains("conformance/fixtures/synthetic-catalog.json", files); + Assert.True(Cases().Count >= 20); + } + + [Theory] + [MemberData(nameof(Cases))] + public void Case(string file, int index, string name) + { + Assert.False(string.IsNullOrEmpty(name)); + using var doc = JsonDocument.Parse(TestData.Resource(file)); + var root = doc.RootElement; + var testCase = root.GetProperty("cases")[index]; + var hostPlatform = "linux"; + var hostArchitecture = "x64"; + if (testCase.TryGetProperty("host", out var host)) + { + if (host.TryGetProperty("platform", out var p)) + { + hostPlatform = p.GetString()!; + } + + if (host.TryGetProperty("nativeArchitecture", out var a)) + { + hostArchitecture = a.GetString()!; + } + } + + var fixedHost = new FixedHost(hostPlatform, hostArchitecture); + var catalogElement = root.GetProperty("catalog"); + var catalog = catalogElement.ValueKind == JsonValueKind.String && catalogElement.GetString() == "bundled" + ? TestData.Bundled(fixedHost) + : TestData.CatalogFor(JsonNode.Parse(catalogElement.GetRawText())!, fixedHost); + var toolsElement = testCase.GetProperty("tools"); + var tools = TestData.Tools(toolsElement); + JsonElement? contextElement = testCase.TryGetProperty("context", out var c) ? c : null; + var context = TestData.Context(contextElement); + + if (testCase.TryGetProperty("expectError", out var expectError)) + { + var code = expectError.GetProperty("code").GetString(); + var reason = expectError.GetProperty("reason").GetString(); + var withDiagnostics = TestData.Failure(() => catalog.GetSandboxConfigWithDiagnostics(tools, context)); + Assert.Equal((code, reason), (withDiagnostics.Code, withDiagnostics.Reason)); + var policyOnly = TestData.Failure(() => catalog.GetSandboxConfig(tools, context)); + Assert.Equal((code, reason), (policyOnly.Code, policyOnly.Reason)); + return; + } + + var expect = JsonNode.Parse(testCase.GetProperty("expect").GetRawText())!.AsObject(); + var expectedPolicy = expect["policy"]; + if (expectedPolicy is null) + { + // JSON.stringify omits an undefined policy. + expect.Remove("policy"); + } + + var expected = TestData.Canonical(expect.ToJsonString()); + var result = catalog.GetSandboxConfigWithDiagnostics(tools, context); + Assert.Equal(expected, TestData.Canonical(PolicyCatalogJson.Serialize(result))); + + var policy = catalog.GetSandboxConfig(tools, context); + Assert.Equal(TestData.Canonical(expectedPolicy?.ToJsonString() ?? "null"), TestData.Canonical(PolicyCatalogJson.Serialize(policy))); + Assert.Equal(result.Policy is null, policy is null); + + if (TestData.IsSingle(toolsElement)) + { + var single = catalog.GetSandboxConfigWithDiagnostics(tools[0], context); + Assert.Equal(expected, TestData.Canonical(PolicyCatalogJson.Serialize(single))); + Assert.Equal( + TestData.Canonical(PolicyCatalogJson.Serialize(catalog.GetSandboxConfig(tools[0], context))), + TestData.Canonical(PolicyCatalogJson.Serialize(policy))); + } + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj new file mode 100644 index 000000000..a56bde5d7 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + + Exe + enable + enable + latest + false + true + + + + + + + + + + + + + + + + + diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs new file mode 100644 index 000000000..b66ddcc3e --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Microsoft.Mxc.PolicyCatalog.Internal; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// Version ranges, package URLs, and the JavaScript semantics the port reproduces. +public sealed class PrimitivesTests +{ + [Theory] + [InlineData(">=10 <12", true)] + [InlineData("22", true)] + [InlineData(">=1.2.3", true)] + [InlineData("=1.0.0", true)] + [InlineData("<2 || >=4", true)] + [InlineData("", false)] + [InlineData("^1.2.3", false)] + [InlineData("~1", false)] + [InlineData("1.x", false)] + [InlineData(">= 1", false)] + [InlineData("1 ||", false)] + [InlineData("latest", false)] + public void VersionRangeGrammar(string range, bool valid) => Assert.Equal(valid, VersionRange.IsValid(range)); + + [Theory] + [InlineData("10.9.0", ">=10 <12", true)] + [InlineData("v12.0.0", ">=10 <12", false)] + [InlineData("22.3.1", "22", true)] + [InlineData("23.0.0", "22", false)] + [InlineData("3.0.0", "<2 || >=3", true)] + [InlineData("10.0.0-rc.1", ">=10", true)] + [InlineData(" 10.1 ", ">=10.1", true)] + [InlineData("nightly", ">=10", null)] + [InlineData("10.0.0", "bogus", null)] + public void VersionRangeEvaluation(string version, string range, bool? expected) => Assert.Equal(expected, VersionRange.Satisfies(version, range)); + + [Fact] + public void PackageUrls() + { + Assert.Equal(new ParsedPurl("npm/npm", "10.9.0"), Purl.Parse("pkg:npm/npm@10.9.0")); + Assert.Equal(new ParsedPurl("npm/%40scope/pkg", "1.0.0"), Purl.Parse("pkg:NPM/%40scope/pkg@1.0.0?x=y#sub")); + Assert.Equal(new ParsedPurl("npm/npm", null), Purl.Parse("pkg:npm/npm")); + Assert.Equal(new ParsedPurl("npm/npm", null), Purl.Parse("pkg:npm/npm@")); + Assert.Equal(new ParsedPurl("npm/npm", "1 2\u00e9"), Purl.Parse("pkg:npm/npm@1%202%C3%A9")); + Assert.Null(Purl.Parse("npm/npm")); + Assert.Null(Purl.Parse("pkg:npm")); + Assert.Null(Purl.Parse("pkg:1npm/x")); + Assert.Null(Purl.Parse("pkg:npm//x")); + Assert.Null(Purl.Parse("pkg:npm/npm@%E0%A4%A")); + Assert.Null(Purl.Parse("pkg:npm/npm@%ED%A0%80")); + Assert.Null(Purl.Parse("pkg:npm/npm@%zz")); + } + + [Theory] + [InlineData(0.0, "0")] + [InlineData(1e21, "1e+21")] + [InlineData(1e20, "100000000000000000000")] + [InlineData(1e-7, "1e-7")] + [InlineData(0.000001, "0.000001")] + [InlineData(12345678901234567890.0, "12345678901234567000")] + [InlineData(5e-324, "5e-324")] + [InlineData(1.7976931348623157e308, "1.7976931348623157e+308")] + [InlineData(-1234.5678, "-1234.5678")] + [InlineData(0.30000000000000004, "0.30000000000000004")] + [InlineData(123e-20, "1.23e-18")] + [InlineData(1.5, "1.5")] + public void EcmaScriptNumberToString(double value, string expected) + { + Assert.Equal(expected, JsNumber.ToString(value)); + Assert.Equal("0", JsNumber.ToString(-0.0)); + } + + [Fact] + public void NumberFormattingMatchesRoundTripForManyValues() + { + var random = new Random(1234); + for (var i = 0; i < 20000; i++) + { + var value = BitConverter.Int64BitsToDouble(random.NextInt64()); + if (!double.IsFinite(value)) + { + continue; + } + + var text = JsNumber.ToString(value); + Assert.Equal(value == 0 ? 0 : value, double.Parse(text, CultureInfo.InvariantCulture)); + } + } + + [Fact] + public void StringEscapingMatchesJsonStringify() + { + Assert.Equal("\"\\u0000\\b\\t\\n\\f\\r\\u001f \\\" \\\\ / \u007f \u2028 \u00e9\"", JsonText.Quote("\0\b\t\n\f\r\u001f \" \\ / \u007f \u2028 \u00e9")); + Assert.Equal("\"\\ud800x\\udc00\ud83d\ude00\"", JsonText.Quote("\ud800x\udc00\ud83d\ude00")); + } + + [Fact] + public void ParsingKeepsJavaScriptKeyOrderAndDuplicateSemantics() + { + var parsed = (JsonObject)JsonText.Parse("""{"b":1,"2":0,"a":2,"1":0,"b":3}"""); + Assert.Equal(new[] { "1", "2", "b", "a" }, parsed.Keys); + Assert.Equal(3.0, ((JsonNumber)parsed.Get("b")!).Value); + Assert.Equal("""{"1":0,"2":0,"b":3,"a":2}""", JsonText.Stringify(parsed)); + Assert.ThrowsAny(() => JsonText.Parse("{\"a\":1,}")); + Assert.ThrowsAny(() => JsonText.Parse("{} x")); + } + + [Theory] + [InlineData("GIT.EXE", "git.exe")] + [InlineData("\u00c9T\u00c9", "\u00e9t\u00e9")] + [InlineData("\u0130", "i\u0307")] + [InlineData("\u0391\u03a3", "\u03b1\u03c2")] + [InlineData("\u0391\u03a3\u0391", "\u03b1\u03c3\u03b1")] + [InlineData("\u03a3", "\u03c3")] + [InlineData("\ua7cb", "\u0264")] + [InlineData("\u212a", "k")] + public void LowerCasingMatchesJavaScript(string input, string expected) => Assert.Equal(expected, JsString.ToLower(input)); + + [Theory] + [InlineData("AMD64", "x64")] + [InlineData("x86_64", "x64")] + [InlineData(" x64\n", "x64")] + [InlineData("ARM64", "arm64")] + [InlineData("aarch64", "arm64")] + [InlineData("riscv64", null)] + [InlineData("x86", null)] + public void MachineNamesMapToSelectors(string machine, string? expected) => Assert.Equal(expected, SystemHostEnvironment.ArchitectureFromMachine(machine)); + + [Fact] + public void SystemHostDetectsANativeArchitectureOrFailsWithUnsupportedHost() + { + var host = SystemHostEnvironment.Instance; + Assert.Contains(host.Platform(), CatalogPlatforms.All); + try + { + Assert.Contains(host.NativeArchitecture(), CatalogArchitectures.All); + } + catch (PolicyCatalogException error) + { + Assert.Equal("unsupported_host", error.Reason); + } + + Assert.Null(host.Symbol("git_prefix")); + Assert.NotNull(host.Symbol("temp_dir")); + } + + [Fact] + public void ErrorCodesMapLikeTypeScript() + { + var expected = new Dictionary + { + [PolicyCatalogErrorReason.InvalidCatalog] = ("invalid_catalog", "policy_validation"), + [PolicyCatalogErrorReason.CompositionConflict] = ("composition_conflict", "policy_validation"), + [PolicyCatalogErrorReason.InvalidContext] = ("invalid_context", "malformed_request"), + [PolicyCatalogErrorReason.UnsupportedHost] = ("unsupported_host", "unsupported_containment"), + [PolicyCatalogErrorReason.Integrity] = ("integrity", "backend_error"), + [PolicyCatalogErrorReason.RevisionUnavailable] = ("revision_unavailable", "backend_error"), + }; + foreach (var (reason, (wire, code)) in expected) + { + var error = new PolicyCatalogException(reason, "m"); + Assert.Equal((wire, code, $"[{code}] m"), (error.Reason, error.Code, error.Message)); + } + + Assert.Equal( + "{\"error\":{\"code\":\"backend_error\",\"message\":\"[backend_error] m\",\"details\":{\"reason\":\"integrity\"}}}", + PolicyCatalogJson.Serialize(new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, "m"))); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs new file mode 100644 index 000000000..ff54c179a --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// Port of tests/unit/resolver.test.ts. +public sealed class ResolverTests +{ + private static readonly ResolveContext Weak = new() { AllowWeakIdentityFallback = true }; + + private static PolicyCatalog ArchCatalog(IHostEnvironment? host = null) => TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:a", """ + { "identity": [{ "kind": "purl", "value": "pkg:npm/a" }, { "kind": "invocation-name", "names": ["a"] }], + "platformVariants": [ + { "when": { "platform": "windows", "architecture": "x64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "timeoutMs": 1 } }, + { "when": { "platform": "windows", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "timeoutMs": 2 } } ] } + """), + }), host ?? new FixedHost("windows", "arm64")); + + private static string Warnings(SandboxConfigResolution result) => string.Join("\n", result.Diagnostics.Warnings); + + [Fact] + public void OmittedContextUsesHostDefaultsAndNoWeakFallback() + { + var catalog = ArchCatalog(); + Assert.Null(catalog.GetSandboxConfig("a")); + var result = catalog.GetSandboxConfigWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }); + Assert.Equal(2.0, result.Policy?.TimeoutMs); + Assert.Equal("2000-01-01.1", result.Diagnostics.CatalogRevision); + Assert.Contains("native system architecture 'arm64'; the tool's architecture was not verified", Warnings(result), StringComparison.Ordinal); + } + + [Fact] + public void ExplicitArchitectureWinsAndSuppressesTheWarning() + { + var result = ArchCatalog().GetSandboxConfigWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }, new ResolveContext { Architecture = "x64" }); + Assert.Equal(1.0, result.Policy?.TimeoutMs); + Assert.Empty(result.Diagnostics.Warnings); + } + + [Fact] + public void AnotherArchitectureIsNeverAFallback() + { + var catalog = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), + })); + var result = catalog.GetSandboxConfigWithDiagnostics("a", Weak with { Architecture = "x64" }); + Assert.Null(result.Policy); + Assert.Equal("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/x64", result.Diagnostics.Warnings[0]); + } + + [Fact] + public void ArchitectureDetectionFailureIsALibraryErrorAndIsLazy() + { + var host = new FixedHost("linux", () => throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, "unknown machine")); + var catalog = new PolicyCatalog(TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:t") }) }), host); + var error = TestData.Failure(() => catalog.GetSandboxConfig("t", Weak with { ProjectRoot = "/p" })); + Assert.Equal(("unsupported_containment", "unsupported_host"), (error.Code, error.Reason)); + Assert.Null(catalog.GetSandboxConfig("nothing", Weak)); + var explicitArch = catalog.GetSandboxConfig("t", Weak with { Architecture = "x64", ProjectRoot = "/p" }); + Assert.Equal(new[] { "/p" }, explicitArch?.Filesystem?.ReadwritePaths); + } + + [Fact] + public void NeverFabricatesSymbols() + { + var result = TestData.Bundled().GetSandboxConfigWithDiagnostics("git", Weak with { Architecture = "x64" }); + Assert.Null(result.Policy); + Assert.Matches("(?s)required symbol 'git_prefix'.*required symbol 'project_root'", Warnings(result)); + } + + [Fact] + public void HostSymbolsOnlyForTheCurrentHostPlatform() + { + var revision = TestData.Revision(new[] + { + TestData.Entry("tool:t", """ + { "platformVariants": [ + { "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${user_home}/.cfg"] } } }, + { "when": { "platform": "macos" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${user_home}/.cfg"] } } } ] } + """), + }); + var catalog = TestData.CatalogFor(revision, new FixedHost("linux", "x64", new Dictionary { ["user_home"] = "/home/me" })); + Assert.Equal(new[] { "/home/me/.cfg" }, catalog.GetSandboxConfig("t", Weak)?.Filesystem?.ReadonlyPaths); + Assert.Null(catalog.GetSandboxConfig("t", Weak with { Platform = "macos", Architecture = "arm64" })); + Assert.Equal(new[] { "/srv/u/.cfg" }, catalog.GetSandboxConfig("t", Weak with { Symbols = new Dictionary { ["user_home"] = "/srv/u" } })?.Filesystem?.ReadonlyPaths); + } + + [Fact] + public void StringObjectAndOneElementListAreEquivalent() + { + var catalog = TestData.Bundled(); + var ctx = Weak with { Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["git_prefix"] = "/g" } }; + var a = PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics("git", ctx)); + Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(new ToolInput("git"), ctx))); + Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(new ToolInput[] { "git" }, ctx))); + Assert.Null(catalog.GetSandboxConfig("git", ctx with { AllowWeakIdentityFallback = false })); + } + + public static TheoryData InvalidInputs() => new() + { + { "empty name", "", new ResolveContext() }, + { "path", "/usr/bin/git", new ResolveContext() }, + { "bad purl", new ToolInput("npm") { PackageUrl = "npm" }, new ResolveContext() }, + { "empty version", new ToolInput("npm") { DetectedVersion = "" }, new ResolveContext() }, + { "null input", null, new ResolveContext() }, + { "platform", "git", new ResolveContext { Platform = "plan9" } }, + { "architecture", "git", new ResolveContext { Architecture = "x86" } }, + { "unknown symbol", "git", new ResolveContext { Symbols = new Dictionary { ["nope"] = "/x" } } }, + { "context symbol", "git", new ResolveContext { Symbols = new Dictionary { ["project_root"] = "/x" } } }, + { "null symbol", "git", new ResolveContext { Symbols = new Dictionary { ["git_prefix"] = null } } }, + { "proto symbol", "git", new ResolveContext { Symbols = new Dictionary { ["__proto__"] = "/x" } } }, + { "empty root", "git", new ResolveContext { ProjectRoot = "" } }, + }; + + [Theory] + [MemberData(nameof(InvalidInputs))] + public void InvalidInputsAreLibraryFailures(string label, ToolInput? tool, ResolveContext ctx) + { + Assert.NotEmpty(label); + Assert.Equal("invalid_context", TestData.ErrorReason(() => TestData.Bundled().GetSandboxConfig(new[] { tool! }, ctx))); + } + + [Fact] + public void RelativeOrTemplatedSymbolValuesAreRejected() + { + var catalog = TestData.Bundled(); + var ctx = Weak with { Platform = "linux", Architecture = "x64" }; + var error = TestData.Failure(() => catalog.GetSandboxConfig("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "bin" } })); + Assert.Equal("[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path", error.Message); + Assert.Equal("invalid_context", TestData.ErrorReason(() => catalog.GetSandboxConfig("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "/x/${git_prefix}" } }))); + } + + [Fact] + public void ResultsAreDeterministicCopies() + { + var catalog = TestData.Bundled(); + var ctx = new ResolveContext { Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["npm_prefix"] = "/n", ["npm_cache"] = "/c", ["node_prefix"] = "/n" } }; + var tool = new ToolInput("npm") { PackageUrl = "pkg:npm/npm" }; + var first = PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(tool, ctx)); + Assert.Equal(first, PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(tool, ctx))); + var policy = catalog.GetSandboxConfig(tool, ctx)!; + Assert.Equal(new[] { "/n" }, policy.Filesystem!.ReadonlyPaths); + Assert.Equal(new[] { "/p", "/c" }, policy.Filesystem!.ReadwritePaths); + } + + [Fact] + public void DeduplicatesWithPlatformPathRules() + { + var windows = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:w", """{ "platformVariants": [{ "when": { "platform": "windows" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}", "${node_prefix}\\"] } } }] }"""), + })); + var policy = windows.GetSandboxConfig("w", Weak with { Platform = "windows", Architecture = "x64", Symbols = new Dictionary { ["git_prefix"] = "C:\\Tools", ["node_prefix"] = "c:\\tools" } }); + Assert.Equal(new[] { "C:\\Tools" }, policy?.Filesystem?.ReadonlyPaths); + var linux = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:l", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}", "${node_prefix}/"] } } }] }"""), + })); + Assert.Equal(new[] { "/Tools", "/tools" }, linux.GetSandboxConfig("l", Weak with { Symbols = new Dictionary { ["git_prefix"] = "/Tools", ["node_prefix"] = "/tools" } })?.Filesystem?.ReadonlyPaths); + } + + [Fact] + public void MacOSFoldsPathCaseForOverlap() + { + string Entry(string platform) => $$"""{ "platformVariants": [{ "when": { "platform": "{{platform}}" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}"], "readwritePaths": ["${project_root}"] } } }] }"""; + var mac = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:m", Entry("macos")) })); + var ctx = Weak with { Platform = "macos", Architecture = "arm64", ProjectRoot = "/tools/work", Symbols = new Dictionary { ["git_prefix"] = "/Tools" } }; + var error = TestData.Failure(() => mac.GetSandboxConfig("m", ctx)); + Assert.Equal("[policy_validation] resolved paths overlap across access classes: '/Tools' (readonlyPaths) overlaps '/tools/work' (readwritePaths)", error.Message); + var linux = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:m", Entry("linux")) })); + Assert.NotNull(linux.GetSandboxConfig("m", ctx with { Platform = "linux", Architecture = "x64" })); + } + + [Fact] + public void InvocationNameCasingFollowsThePlatform() + { + var catalog = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:gh", """ + { "platformVariants": [ + { "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }, + { "when": { "platform": "macos" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }, + { "when": { "platform": "windows" }, "sandboxPolicy": { "version": "0.9.0-alpha" } } ] } + """), + })); + IEnumerable On(string platform, string name) => + catalog.GetSandboxConfigWithDiagnostics(name, Weak with { Platform = platform, Architecture = "x64" }).Diagnostics.Tools[0].Matches.Select(m => m.EntryId); + Assert.Equal(new[] { "tool:gh" }, On("linux", "gh")); + Assert.Empty(On("linux", "GH")); + Assert.Equal(new[] { "tool:gh" }, On("macos", "GH")); + Assert.Equal(new[] { "tool:gh" }, On("windows", "Gh")); + } + + [Fact] + public void DependencyChainsComposeEachEntryOnceInOrder() + { + static string Variant(string[] deps, string path) => $$""" + { "platformVariants": [{ "when": { "platform": "linux" } + {{(deps.Length > 0 ? ", \"dependencies\": [" + string.Join(",", deps.Select(d => $"{{\"entryId\":\"{d}\"}}")) + "]" : string.Empty)}}, + "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/{{path}}"] } } }] } + """; + var catalog = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:top", Variant(new[] { "tool:left", "tool:right" }, "top")), + TestData.Entry("tool:left", Variant(new[] { "tool:leaf" }, "left")), + TestData.Entry("tool:right", Variant(new[] { "tool:leaf" }, "right")), + TestData.Entry("tool:leaf", Variant(Array.Empty(), "leaf")), + })); + var result = catalog.GetSandboxConfigWithDiagnostics("top", Weak with { ProjectRoot = "/r" }); + Assert.Equal(new[] { "tool:leaf", "tool:left", "tool:right" }, result.Diagnostics.ResolvedDependencies.Select(d => d.EntryId)); + Assert.Equal(new[] { "/r/top", "/r/left", "/r/leaf", "/r/right" }, result.Policy?.Filesystem?.ReadonlyPaths); + } + + [Fact] + public void DependencyDiagnosticsKeepDistinctRangesSorted() + { + var catalog = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:c", "versionRange": ">=2" }], "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), + TestData.Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:c" }], "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), + TestData.Entry("tool:c", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), + })); + var result = catalog.GetSandboxConfigWithDiagnostics(new ToolInput[] { "a", "b", "a" }, Weak); + Assert.Equal( + new[] { new ResolvedDependency("tool:c", 1, null), new ResolvedDependency("tool:c", 1, ">=2") }, + result.Diagnostics.ResolvedDependencies); + Assert.Equal("""{"version":"0.9.0-alpha"}""", PolicyCatalogJson.Serialize(result.Policy)); + } + + [Fact] + public void CatalogFileOrderDoesNotMatter() + { + var entries = new[] + { + TestData.Entry("tool:b", """{ "identity": [{ "kind": "invocation-name", "names": ["x"] }], "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/b"] } } }] }"""), + TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["x"] }], "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/a"] } } }] }"""), + }; + var ctx = Weak with { ProjectRoot = "/r" }; + var forward = TestData.CatalogFor(TestData.Revision(entries.Select(e => e.DeepClone()))).GetSandboxConfigWithDiagnostics("x", ctx); + var backward = TestData.CatalogFor(TestData.Revision(Enumerable.Reverse(entries).Select(e => e.DeepClone()))).GetSandboxConfigWithDiagnostics("x", ctx); + Assert.Equal(PolicyCatalogJson.Serialize(forward), PolicyCatalogJson.Serialize(backward)); + Assert.Equal(new[] { "/r/a", "/r/b" }, forward.Policy?.Filesystem?.ReadonlyPaths); + Assert.Contains("input 0 ('x') matched 2 entries (tool:a, tool:b); all contribute", forward.Diagnostics.Warnings); + } + + [Fact] + public void MixedVersionsAcrossInputsAreACompositionConflict() + { + var catalog = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.8.0-alpha" } }] }"""), + TestData.Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), + })); + var error = TestData.Failure(() => catalog.GetSandboxConfig(new ToolInput[] { "a", "b" }, Weak)); + Assert.Equal("[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)", error.Message); + Assert.Equal("""{"version":"0.8.0-alpha"}""", PolicyCatalogJson.Serialize(catalog.GetSandboxConfig("a", Weak))); + } + + [Fact] + public void VersionRangeWarnings() + { + var catalog = TestData.CatalogFor(TestData.Revision(new[] + { + TestData.Entry("tool:app", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/app", "versionRange": ">=2 <3" }] }"""), + })); + var ctx = new ResolveContext { ProjectRoot = "/p" }; + Assert.DoesNotContain(catalog.GetSandboxConfigWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@2.1.0" }, ctx).Diagnostics.Warnings, w => w.Contains("range", StringComparison.Ordinal)); + Assert.Contains( + "input 0 ('app'): detected version 'nightly' could not be compared with the reviewed range '>=2 <3' for tool:app", + catalog.GetSandboxConfigWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@3.0.0", DetectedVersion = "nightly" }, ctx).Diagnostics.Warnings); + } + + [Fact] + public void InspectionListsMetadataOnly() + { + Assert.Equal(new CatalogInfo("1", "2026-09-29.1"), BundledPolicyCatalog.GetCatalogInfo()); + var entries = BundledPolicyCatalog.ListCatalogEntries(); + Assert.Equal(new[] { "tool:git", "tool:node", "tool:npm" }, entries.Select(e => e.EntryId)); + var npm = entries.Single(e => e.EntryId == "tool:npm"); + Assert.Equal("windows", npm.PlatformVariants[0].Platform); + Assert.Null(npm.PlatformVariants[0].Architecture); + Assert.Equal(new[] { "tool:node" }, npm.PlatformVariants[0].DependencyEntryIds); + var text = PolicyCatalogJson.Serialize(entries); + Assert.DoesNotContain("Paths", text, StringComparison.Ordinal); + Assert.DoesNotContain("${", text, StringComparison.Ordinal); + } + + [Fact] + public void BundledConvenienceFunctions() + { + var ctx = Weak with { Platform = "linux", Architecture = "x64" }; + Assert.Null(BundledPolicyCatalog.GetSandboxConfig("definitely-unknown-tool", ctx)); + var result = BundledPolicyCatalog.GetSandboxConfigWithDiagnostics(new ToolInput[] { "definitely-unknown-tool" }, ctx); + Assert.Null(result.Policy); + Assert.Equal("""{"diagnostics":{"catalogRevision":"2026-09-29.1","tools":[{"inputIndex":0,"matches":[]}],"resolvedDependencies":[],"warnings":["input 0 ('definitely-unknown-tool') matched no eligible catalog entry"]}}""", PolicyCatalogJson.Serialize(result)); + Assert.Equal("revision_unavailable", TestData.ErrorReason(() => BundledPolicyCatalog.GetSandboxConfig("git", ctx with { CatalogRevision = "1999-01-01.1" }))); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs new file mode 100644 index 000000000..41c3840c8 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Mxc.PolicyCatalog.Internal; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// Port of tests/unit/store.test.ts: integrity, revisions, history, immutability. +public sealed class StoreTests +{ + private static readonly CatalogContract Contract = Catalog.ValidateContract(JsonText.Parse(TestData.Contract)); + + private static CatalogRevisionData Validated(JsonNode revision) => Catalog.ValidateRevision(JsonText.Parse(revision.ToJsonString()), Contract); + + [Fact] + public void BundledCatalogVerifies() + { + var store = CatalogStore.Bundled(); + Assert.Empty(CatalogValidation.CheckStoreHistory(store)); + Assert.Equal("2026-09-29.1", store.DefaultRevision); + store.Verify(); + } + + [Fact] + public void CanonicalDigestIgnoresFormattingButNotContent() + { + Assert.Equal("""{"a":[2,{"c":4,"d":3}],"b":1}""", PolicyCatalogJson.Canonicalize("""{ "b": 1, "a": [2, { "d": 3, "c": 4 }] }""")); + Assert.Equal(PolicyCatalogJson.CanonicalSha256("""{"a":1,"b":2}"""), PolicyCatalogJson.CanonicalSha256("{\r\n \"b\": 2,\n\"a\": 1}")); + Assert.NotEqual(PolicyCatalogJson.CanonicalSha256("""{"a":1}"""), PolicyCatalogJson.CanonicalSha256("""{"a":2}""")); + } + + [Fact] + public void TamperedRevisionIsAnIntegrityError() + { + var store = TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:a") }) }, digests: new Dictionary { ["2000-01-01.1"] = new string('0', 64) }); + var error = TestData.Failure(() => store.Verify()); + Assert.Equal(("backend_error", "integrity"), (error.Code, error.Reason)); + Assert.Matches("^\\[backend_error\\] catalog revision '2000-01-01.1' digest [0-9a-f]{64} does not match the published digest 0{64}$", error.Message); + var catalog = new PolicyCatalog(store, new FixedHost()); + var weak = new ResolveContext { AllowWeakIdentityFallback = true }; + Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetSandboxConfig("a", weak))); + Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetSandboxConfigWithDiagnostics("a", weak))); + Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ListCatalogEntries())); + Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetCatalogInfo())); + } + + [Fact] + public void RevisionIdMismatchIsAnIntegrityError() + { + var revision = TestData.Revision(new[] { TestData.Entry("tool:a") }, "2000-01-02.1"); + var relabelled = revision.DeepClone(); + relabelled["catalogRevision"] = "2000-01-01.1"; + // The digest was computed for other content, so this is caught as tampering first. + var store = TestData.StoreFor(new[] { relabelled }, digests: new Dictionary { ["2000-01-01.1"] = PolicyCatalogJson.CanonicalSha256(revision.ToJsonString()) }); + Assert.Equal("integrity", TestData.ErrorReason(() => store.Verify())); + + // Correct digest, but the file declares another revision. + var files = new Dictionary { ["revisions/2000-01-01.1.json"] = revision.ToJsonString() }; + var manifest = $$"""{"catalogSchemaVersion":"1","defaultRevision":"2000-01-01.1","revisions":[{"catalogRevision":"2000-01-01.1","file":"revisions/2000-01-01.1.json","sha256":"{{PolicyCatalogJson.CanonicalSha256(revision.ToJsonString())}}"}]}"""; + var mismatched = CatalogStore.FromJson(TestData.Contract, manifest, file => files[file]); + var error = TestData.Failure(() => mismatched.Verify()); + Assert.Equal("[backend_error] file 'revisions/2000-01-01.1.json' declares revision '2000-01-02.1', expected '2000-01-01.1'", error.Message); + } + + [Fact] + public void InvalidManifestIsAValidationError() + { + var revision = TestData.Revision(new[] { TestData.Entry("tool:a") }); + Assert.Equal("invalid_catalog", TestData.ErrorReason(() => TestData.StoreFor(new[] { revision }, defaultRevision: "2001-01-01.1"))); + var error = TestData.Failure(() => TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:a") }, "2000-01-02.1"), revision })); + Assert.Equal("[policy_validation] manifest.revisions must be strictly increasing ('2000-01-01.1')", error.Message); + } + + [Fact] + public void ExplicitRevisionIsNeverSubstituted() + { + var r1 = TestData.Revision(new[] { TestData.Entry("tool:a"), TestData.Entry("tool:b") }, "2000-01-01.1"); + var r2 = TestData.Revision(new[] { TestData.Entry("tool:a", """{ "entryRevision": 2, "displayName": "renamed" }"""), TestData.Entry("tool:b") }, "2000-01-02.1"); + var catalog = new PolicyCatalog(TestData.StoreFor(new[] { r1, r2 }), new FixedHost()); + var ctx = new ResolveContext { AllowWeakIdentityFallback = true, ProjectRoot = "/p" }; + var latest = catalog.GetSandboxConfigWithDiagnostics("a", ctx); + Assert.Equal(("2000-01-02.1", 2.0), (latest.Diagnostics.CatalogRevision, latest.Diagnostics.Tools[0].Matches[0].EntryRevision)); + var older = catalog.GetSandboxConfigWithDiagnostics("a", ctx with { CatalogRevision = "2000-01-01.1" }); + Assert.Equal(("2000-01-01.1", 1.0), (older.Diagnostics.CatalogRevision, older.Diagnostics.Tools[0].Matches[0].EntryRevision)); + var error = TestData.Failure(() => catalog.GetSandboxConfig("a", ctx with { CatalogRevision = "2000-01-03.1" })); + Assert.Equal(("backend_error", "revision_unavailable", "[backend_error] catalog revision '2000-01-03.1' is not installed"), (error.Code, error.Reason, error.Message)); + } + + [Fact] + public void EntryRevisionMustIncreaseExactlyWhenAnEntryChanges() + { + var r1 = Validated(TestData.Revision(new[] { TestData.Entry("tool:a"), TestData.Entry("tool:b") }, "2000-01-01.1")); + var changedNoBump = Validated(TestData.Revision(new[] { TestData.Entry("tool:a", """{ "displayName": "x" }"""), TestData.Entry("tool:b") }, "2000-01-02.1")); + var bumpedNoChange = Validated(TestData.Revision(new[] { TestData.Entry("tool:a"), TestData.Entry("tool:b", """{ "entryRevision": 2 }""") }, "2000-01-02.1")); + var ok = Validated(TestData.Revision(new[] { TestData.Entry("tool:a", """{ "displayName": "x", "entryRevision": 2 }"""), TestData.Entry("tool:b") }, "2000-01-02.1")); + Assert.Equal(new[] { "2000-01-02.1: 'tool:a' changed but entryRevision did not increase (1 -> 1)" }, CatalogValidation.CheckEntryRevisions(r1, changedNoBump)); + Assert.Equal(new[] { "2000-01-02.1: 'tool:b' is unchanged but entryRevision moved (1 -> 2)" }, CatalogValidation.CheckEntryRevisions(r1, bumpedNoChange)); + Assert.Empty(CatalogValidation.CheckEntryRevisions(r1, ok)); + Assert.Contains("catalog revision '2000-01-01.1' must be newer than '2000-01-02.1'", CatalogValidation.CheckEntryRevisions(ok, r1)); + } + + private static CatalogValidation.PublishedRevision Rev(string id, string sha) => + new(new JsonString(id), new JsonString($"revisions/{id}.json"), new JsonString(sha)); + + [Fact] + public void PublishedRevisionsAreImmutable() + { + var published = Rev("2000-01-01.1", new string('a', 64)); + var files = new Dictionary { ["revisions/2000-01-01.1.json"] = "{\"x\":1}\n" }; + var @base = new CatalogValidation.PublishedState(new[] { published }, files); + Assert.Empty(CatalogValidation.CheckPublishedImmutability(@base, new(new[] { published, Rev("2000-01-02.1", new string('b', 64)) }, new Dictionary { ["revisions/2000-01-01.1.json"] = "{ \"x\" : 1 }\r\n" }))); + Assert.Equal( + new[] { "published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead" }, + CatalogValidation.CheckPublishedImmutability(@base, new(new[] { published }, new Dictionary { ["revisions/2000-01-01.1.json"] = "{\"x\":2}\n" }))); + Assert.Equal( + new[] { "published revision '2000-01-01.1' manifest entry was modified" }, + CatalogValidation.CheckPublishedImmutability(@base, new(new[] { Rev("2000-01-01.1", new string('c', 64)) }, files))); + Assert.Equal( + new[] { "published revision '2000-01-01.1' was removed or reordered in the manifest" }, + CatalogValidation.CheckPublishedImmutability(@base, new(Array.Empty(), new Dictionary()))); + Assert.Equal( + new[] { "published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead" }, + CatalogValidation.CheckPublishedImmutability(@base, new(new[] { published }, new Dictionary { ["revisions/2000-01-01.1.json"] = "not json" }))); + } + + [Fact] + public void DirectoryStoreReportsUnreadableData() + { + var dir = Path.Combine(Path.GetTempPath(), "pc-dotnet-unit-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var error = TestData.Failure(() => CatalogStore.FromDirectory(dir)); + Assert.Equal("integrity", error.Reason); + Assert.StartsWith("[backend_error] catalog data could not be read: ", error.Message, StringComparison.Ordinal); + var report = CatalogValidation.ValidateCatalogDirectory(dir); + Assert.False(report.Ok); + Assert.Null(report.DefaultRevision); + Assert.Equal(Path.GetFullPath(dir), report.CatalogDir); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void BaseRefRejectsOptionLikeRefsWithoutRunningGit() + { + var (compared, errors) = CatalogValidation.CheckAgainstBaseRef(Path.GetTempPath(), Array.Empty(), "-h"); + Assert.Equal(0, compared); + Assert.Equal(new[] { "[policy_validation] base-ref check: '-h' is not a valid git ref" }, errors); + Assert.Equal("[policy_validation] base-ref check: 'a b' is not a valid git ref", CatalogValidation.CheckAgainstBaseRef(Path.GetTempPath(), Array.Empty(), "a b").Errors.Single()); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs new file mode 100644 index 000000000..6500b79a2 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// Host with fixed facts, so tests never depend on the machine running them. +internal sealed class FixedHost : IHostEnvironment +{ + private readonly string _platform; + private readonly Func _architecture; + private readonly IReadOnlyDictionary _symbols; + + public FixedHost(string platform = "linux", string architecture = "x64", IReadOnlyDictionary? symbols = null) + : this(platform, () => architecture, symbols) + { + } + + public FixedHost(string platform, Func architecture, IReadOnlyDictionary? symbols = null) + { + _platform = platform; + _architecture = architecture; + _symbols = symbols ?? new Dictionary(); + } + + public int ArchitectureCalls { get; private set; } + + public string Platform() => _platform; + + public string NativeArchitecture() + { + ArchitectureCalls++; + return _architecture(); + } + + public string? Symbol(string name) => _symbols.TryGetValue(name, out var value) ? value : null; +} + +internal static class TestData +{ + public const string V = "0.9.0-alpha"; + + public static string Resource(string name) + { + var assembly = typeof(TestData).Assembly; + var actual = assembly.GetManifestResourceNames().Single(resource => resource.Replace('\\', '/') == name); + using var stream = assembly.GetManifestResourceStream(actual)!; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + public static IEnumerable Resources(string prefix) => + typeof(TestData).Assembly.GetManifestResourceNames() + .Select(resource => resource.Replace('\\', '/')) + .Where(resource => resource.StartsWith(prefix, StringComparison.Ordinal)) + .OrderBy(resource => resource, StringComparer.Ordinal); + + public static string Contract => Resource("catalog/contract.v1.json"); + + /// An in-memory store whose manifest publishes the given revisions with correct (or overridden) digests. + public static CatalogStore StoreFor(IReadOnlyList revisions, string? defaultRevision = null, IReadOnlyDictionary? digests = null) + { + var files = new Dictionary(); + var manifestRevisions = new JsonArray(); + foreach (var revision in revisions) + { + var id = revision["catalogRevision"]!.GetValue(); + var file = $"revisions/{id}.json"; + var text = revision.ToJsonString(); + files[file] = text; + manifestRevisions.Add(new JsonObject + { + ["catalogRevision"] = id, + ["file"] = file, + ["sha256"] = digests is not null && digests.TryGetValue(id, out var digest) ? digest : PolicyCatalogJson.CanonicalSha256(text), + }); + } + + var manifest = new JsonObject + { + ["catalogSchemaVersion"] = "1", + ["defaultRevision"] = defaultRevision ?? revisions[^1]["catalogRevision"]!.GetValue(), + ["revisions"] = manifestRevisions, + }; + return CatalogStore.FromJson(Contract, manifest.ToJsonString(), file => files[file]); + } + + public static PolicyCatalog CatalogFor(JsonNode revision, IHostEnvironment? host = null) => + new(StoreFor(new[] { revision }), host ?? new FixedHost()); + + public static PolicyCatalog Bundled(IHostEnvironment? host = null) => new(CatalogStore.Bundled(), host ?? new FixedHost()); + + public static JsonNode Revision(IEnumerable entries, string catalogRevision = "2000-01-01.1") => new JsonObject + { + ["catalogSchemaVersion"] = "1", + ["catalogRevision"] = catalogRevision, + ["entries"] = new JsonArray(entries.ToArray()), + }; + + public static JsonNode Parse(string json) => JsonNode.Parse(json)!; + + /// Minimal valid entry; is a JSON object merged over it. + public static JsonNode Entry(string entryId, string? overrides = null) + { + var name = entryId.Split(':')[1]; + var entry = new JsonObject + { + ["entryId"] = entryId, + ["entryRevision"] = 1, + ["displayName"] = name, + ["identity"] = Parse($$"""[{ "kind": "invocation-name", "names": ["{{name}}"] }]"""), + ["platformVariants"] = Parse("""[{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"] } } }]"""), + ["provenance"] = Parse("""{ "method": "test", "sourceRevision": "test" }"""), + }; + if (overrides is not null) + { + foreach (var (key, value) in Parse(overrides).AsObject().ToList()) + { + entry[key] = value?.DeepClone(); + } + } + + return entry; + } + + /// The failure's reason, after checking that its code is the reason's code. + public static string? ErrorReason(Action action) + { + try + { + action(); + } + catch (PolicyCatalogException error) + { + Assert.Equal(PolicyCatalogException.CodeFor(error.ErrorReason), error.ErrorCode); + Assert.StartsWith($"[{error.Code}] ", error.Message, StringComparison.Ordinal); + return error.Reason; + } + + return null; + } + + public static PolicyCatalogException Failure(Action action) => Assert.Throws(action); + + // --- fixture conversion -------------------------------------------------- + + public static List Tools(JsonElement tools) + { + if (tools.ValueKind == JsonValueKind.Array) + { + return tools.EnumerateArray().Select(Tool).ToList(); + } + + return new List { Tool(tools) }; + } + + public static bool IsSingle(JsonElement tools) => tools.ValueKind != JsonValueKind.Array; + + public static ToolInput Tool(JsonElement tool) + { + if (tool.ValueKind == JsonValueKind.String) + { + return tool.GetString()!; + } + + return new ToolInput(tool.GetProperty("invocationName").GetString()!) + { + PackageUrl = tool.TryGetProperty("packageUrl", out var purl) ? purl.GetString() : null, + DetectedVersion = tool.TryGetProperty("detectedVersion", out var version) ? version.GetString() : null, + }; + } + + public static ResolveContext Context(JsonElement? context) + { + if (context is not { } ctx || ctx.ValueKind != JsonValueKind.Object) + { + return new ResolveContext(); + } + + string? Str(string name) => ctx.TryGetProperty(name, out var value) ? value.GetString() : null; + Dictionary? symbols = null; + if (ctx.TryGetProperty("symbols", out var s)) + { + symbols = s.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()); + } + + return new ResolveContext + { + Platform = Str("platform"), + Architecture = Str("architecture"), + ProjectRoot = Str("projectRoot"), + CatalogRevision = Str("catalogRevision"), + AllowWeakIdentityFallback = ctx.TryGetProperty("allowWeakIdentityFallback", out var weak) && weak.GetBoolean(), + Symbols = symbols, + }; + } + + public static string Canonical(string json) => PolicyCatalogJson.Canonicalize(json); +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs new file mode 100644 index 000000000..e9cbcab26 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Mxc.PolicyCatalog.Internal; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// Port of tests/unit/validation.test.ts (contribution/CI rules). +public sealed class ValidationTests +{ + private static readonly CatalogContract Contract = Catalog.ValidateContract(JsonText.Parse(TestData.Contract)); + + private static string? Validate(params JsonNode[] entries) + { + try + { + Catalog.ValidateRevision(JsonText.Parse(TestData.Revision(entries).ToJsonString()), Contract); + return null; + } + catch (PolicyCatalogException error) + { + Assert.Equal("invalid_catalog", error.Reason); + return error.Message; + } + } + + private static void Rejects(string expected, params JsonNode[] entries) + { + var message = Validate(entries); + Assert.NotNull(message); + Assert.Contains(expected, message, StringComparison.Ordinal); + } + + private static string Linux(string policy, string extra = "") => + $$"""{ "when": { "platform": "linux" }{{extra}}, "sandboxPolicy": {{policy}} }"""; + + private static string Variants(params string[] variants) => $$"""{ "platformVariants": [{{string.Join(",", variants)}}] }"""; + + private static string Dep(string id) => Variants(Linux("""{ "version": "0.9.0-alpha" }""", $$""", "dependencies": [{ "entryId": "{{id}}" }]""")); + + [Fact] + public void AcceptsMinimalRevision() => Assert.Null(Validate(TestData.Entry("tool:a"))); + + [Fact] + public void RejectsDependencyCycles() + { + Rejects("cycle (tool:a -> tool:b -> tool:c -> tool:a)", TestData.Entry("tool:a", Dep("tool:b")), TestData.Entry("tool:b", Dep("tool:c")), TestData.Entry("tool:c", Dep("tool:a"))); + Rejects("depends on itself", TestData.Entry("tool:a", Dep("tool:a"))); + Rejects("[policy_validation] 'tool:a' on linux/x64: cycle", TestData.Entry("tool:a", Dep("tool:b")), TestData.Entry("tool:b", Dep("tool:a"))); + } + + [Fact] + public void RejectsUnknownDependencies() => Rejects("unknown entry 'tool:missing'", TestData.Entry("tool:a", Dep("tool:missing"))); + + [Fact] + public void RejectsInvalidDependencyRanges() => + Rejects("versionRange", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha" }""", """, "dependencies": [{ "entryId": "tool:b", "versionRange": "^1.x" }]"""))), TestData.Entry("tool:b")); + + [Fact] + public void RejectsDuplicateSelectors() + { + static string V(string? arch) => arch is null + ? """{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }""" + : $$"""{ "when": { "platform": "linux", "architecture": "{{arch}}" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }"""; + Rejects("duplicates selector 'linux/x64'", TestData.Entry("tool:a", Variants(V("x64"), V("x64")))); + Rejects("second architecture-neutral", TestData.Entry("tool:a", Variants(V(null), V(null)))); + Assert.Null(Validate(TestData.Entry("tool:a", Variants(V(null), V("x64"), V("arm64"))))); + Rejects("'entries[0].platformVariants[0].when.architecture' must be one of x64, arm64", TestData.Entry("tool:a", Variants(V("riscv")))); + } + + [Fact] + public void RejectsBackendKeysAndUnregisteredVersions() + { + Rejects("names a containment backend", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "processContainer": {} }""")))); + Rejects("containment backend", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "containment": "lxc" }""")))); + Rejects("not a SandboxPolicy version registered", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0" }""")))); + } + + [Fact] + public void RejectsUnsupportedFields() + { + Rejects("unsupported field 'entries[0].extra'", TestData.Entry("tool:a", """{ "extra": 1 }""")); + Rejects("unsupported field", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "filesystem": { "clearPolicyOnExit": true } }""")))); + Rejects("unsupported field", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "network": { "allowOutbound": true } }""")))); + Rejects("unsupported field", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "telemetry": {} }""")))); + } + + [Theory] + [InlineData("/home/alice/.npm", "literal paths are not allowed")] + [InlineData("C:\\\\Users\\\\alice", "literal paths are not allowed")] + [InlineData("${project_root}/*", "wildcard")] + [InlineData("${project_root}/../x", "'..'")] + [InlineData("${unknown_thing}", "unknown symbol 'unknown_thing'")] + [InlineData("${project_root", "malformed symbol")] + [InlineData("${project_root}/node_modules", null)] + public void PathRules(string path, string? expected) + { + var entry = TestData.Entry("tool:a", Variants(Linux($$"""{ "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["{{path}}"] } }"""))); + if (expected is null) + { + Assert.Null(Validate(entry)); + } + else + { + Rejects(expected, entry); + } + } + + [Theory] + [InlineData("""{ "egress": { "default": "allow" } }""", "default-allow")] + [InlineData("""{ "ingress": { "default": "allow" } }""", "default-allow")] + [InlineData("""{ "egress": { "allow": [{ "ports": [{ "port": 443 }] }] } }""", "wildcard network grant")] + [InlineData("""{ "egress": { "allow": [{ "to": [{ "cidr": "0.0.0.0/0" }] }] } }""", "wildcard network grant")] + [InlineData("""{ "egress": { "allow": [{ "to": [{ "cidr": "10.0.0.0/8" }], "ports": [{ "port": 70000 }] }] } }""", "must be an integer in 1..65535")] + [InlineData("""{ "egress": { "allow": [{ "to": [{ "cidr": "10.0.0.0/8" }], "ports": [{ "port": 5, "endPort": 4 }] }] } }""", "requires a lower or equal 'port'")] + [InlineData("""{ "egress": { "deny": [{ "ports": [{ "protocol": "tcp" }] }] } }""", null)] + [InlineData("""{ "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } }""", null)] + public void NetworkRules(string network, string? expected) + { + var entry = TestData.Entry("tool:a", Variants(Linux($$"""{ "version": "0.9.0-alpha", "network": {{network}} }"""))); + if (expected is null) + { + Assert.Null(Validate(entry)); + } + else + { + Rejects(expected, entry); + } + } + + [Fact] + public void IdentityRules() + { + Assert.Null(Validate(TestData.Entry("tool:a"), TestData.Entry("tool:b", """{ "identity": [{ "kind": "invocation-name", "names": ["A"] }] }"""))); + Assert.Null(Validate( + TestData.Entry("tool:a", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/x" }] }"""), + TestData.Entry("tool:b", """{ "identity": [{ "kind": "purl", "value": "pkg:NPM/x" }] }"""))); + Rejects("repeats identity 'invocation-name:a'", TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["a", "A"] }] }""")); + // Folding matches JavaScript toLowerCase beyond ASCII (Latin-1 and final sigma). + Rejects("repeats identity", TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["\u00c9t\u00e9", "\u00e9T\u00c9"] }] }""")); + Rejects("repeats identity 'purl:npm/a'", TestData.Entry("tool:a", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/a" }, { "kind": "purl", "value": "pkg:NPM/a" }] }""")); + Rejects("must not pin a version", TestData.Entry("tool:a", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/a@1.0.0" }] }""")); + Rejects("bare invocation name", TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["bin/a"] }] }""")); + Rejects("not a supported identity kind", TestData.Entry("tool:a", """{ "identity": [{ "kind": "sha256", "value": "x" }] }""")); + Rejects("duplicate entryId 'tool:a'", TestData.Entry("tool:a"), TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["z"] }] }""")); + Rejects("must be namespaced", TestData.Entry("tool:a", """{ "entryId": "noNamespace" }""")); + Rejects("'entries[0].entryRevision' must be a positive integer", TestData.Entry("tool:a", """{ "entryRevision": 1.5 }""")); + } + + private static JsonNode[] WithDep(string policy, string depPolicy) => new[] + { + TestData.Entry("tool:a", Variants(Linux(policy, """, "dependencies": [{ "entryId": "tool:b" }]"""))), + TestData.Entry("tool:b", Variants(Linux(depPolicy))), + }; + + [Fact] + public void CompositionVocabulary() + { + Assert.Null(Validate(WithDep( + """{ "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"], "readonlyPaths": ["${node_prefix}"] } }""", + """{ "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${node_prefix}", "${git_prefix}"], "deniedPaths": ["${user_home}/.ssh"] } }"""))); + Rejects("overlaps", WithDep( + """{ "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"] } }""", + """{ "version": "0.9.0-alpha", "filesystem": { "deniedPaths": ["${project_root}/secrets"] } }""")); + Rejects("mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)", WithDep("""{ "version": "0.9.0-alpha" }""", """{ "version": "0.8.0-alpha" }""")); + Rejects("'tool:a' uses 'network'", WithDep("""{ "version": "0.9.0-alpha", "network": { "egress": { "default": "deny" } } }""", """{ "version": "0.9.0-alpha" }""")); + Rejects("'timeoutMs'", WithDep("""{ "version": "0.9.0-alpha" }""", """{ "version": "0.9.0-alpha", "timeoutMs": 5 }""")); + Assert.Null(Validate(TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "network": { "egress": { "default": "deny" } }, "ui": { "clipboard": "read" }, "timeoutMs": 5 }"""))))); + } + + [Fact] + public void ContractValidation() + { + Assert.Equal(7, Contract.Symbols.Count); + var bad = TestData.Parse(TestData.Contract); + bad["symbols"]!["x"] = TestData.Parse("""{ "source": "elsewhere", "description": "d" }"""); + var error = Assert.Throws(() => Catalog.ValidateContract(JsonText.Parse(bad.ToJsonString()))); + Assert.Equal("[policy_validation] contract.symbols.x.source is unsupported", error.Message); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs new file mode 100644 index 000000000..fee6dadf0 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Mxc.PolicyCatalog.Internal; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.Tests; + +/// conformance/vectors/*.json: canonical JSON and path rules shared by every binding. +public sealed class VectorTests +{ + public static TheoryData CanonicalCases() + { + using var doc = JsonDocument.Parse(TestData.Resource("conformance/vectors/canonical-json.json")); + var data = new TheoryData(); + for (var i = 0; i < doc.RootElement.GetProperty("cases").GetArrayLength(); i++) + { + data.Add(i); + } + + return data; + } + + [Theory] + [MemberData(nameof(CanonicalCases))] + public void CanonicalJsonVector(int index) + { + using var doc = JsonDocument.Parse(TestData.Resource("conformance/vectors/canonical-json.json")); + var c = doc.RootElement.GetProperty("cases")[index]; + var json = c.GetProperty("json").GetString()!; + Assert.Equal(c.GetProperty("canonical").GetString(), PolicyCatalogJson.Canonicalize(json)); + Assert.Equal(c.GetProperty("sha256").GetString(), PolicyCatalogJson.CanonicalSha256(json)); + } + + [Fact] + public void EveryVectorFileIsCovered() + { + Assert.Equal( + new[] { "conformance/vectors/canonical-json.json", "conformance/vectors/paths.json" }, + TestData.Resources("conformance/vectors/").ToArray()); + } + + [Theory] + [InlineData("windows")] + [InlineData("linux")] + [InlineData("macos")] + public void PathVectors(string platform) + { + using var doc = JsonDocument.Parse(TestData.Resource("conformance/vectors/paths.json")); + var rows = doc.RootElement.GetProperty(platform).EnumerateArray().ToList(); + Assert.True(rows.Count > 5); + foreach (var row in rows) + { + var input = row.GetProperty("input").GetString()!; + Assert.True(row.GetProperty("absolute").GetBoolean() == Paths.IsAbsolutePath(input, platform), input); + var normalized = row.GetProperty("normalized"); + if (normalized.ValueKind != JsonValueKind.Null) + { + Assert.Equal(normalized.GetString(), Paths.NormalizePath(input, platform)); + } + + Assert.Equal(row.GetProperty("keySegments").EnumerateArray().Select(s => s.GetString()!).ToList(), Paths.PathKeySegments(input, platform)); + } + } + + [Fact] + public void MalformedPercentEncodingIsAnInvalidPurl() + { + Assert.Null(Purl.Parse("pkg:npm/npm@%E0%A4%A")); + Assert.Equal(new ParsedPurl("npm/npm", "10.9.0"), Purl.Parse("pkg:npm/npm@10.9.0")); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx new file mode 100644 index 000000000..858fa1753 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs new file mode 100644 index 000000000..59a94bb8b --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.Mxc.PolicyCatalog.Internal; + +namespace Microsoft.Mxc.PolicyCatalog; + +/// One published revision as listed in manifest.json. +/// The revision ID (YYYY-MM-DD.N). +/// The revision file, revisions/<id>.json. +/// Lower-case hex SHA-256 of the revision's canonical JSON. +public sealed record ManifestRevision(string CatalogRevision, string File, string Sha256); + +/// +/// Read-only access to locally installed, integrity-validated catalog revisions. Revisions are loaded +/// lazily, verified against the manifest digest, validated against the contract, and cached. +/// +public sealed class CatalogStore +{ + private static readonly Regex Sha256Pattern = new("^[0-9a-f]{64}\\z", RegexOptions.CultureInvariant); + private static readonly Lazy BundledStore = new(LoadBundled, LazyThreadSafetyMode.ExecutionAndPublication); + + private readonly Func _readRevision; + private readonly ConcurrentDictionary _loaded = new(StringComparer.Ordinal); + + internal CatalogStore(JsonValue contract, JsonValue manifest, Func readRevision) + { + _readRevision = readRevision; + Contract = Catalog.ValidateContract(contract); + var (defaultRevision, revisions) = ValidateManifest(manifest); + DefaultRevision = defaultRevision; + Revisions = revisions; + } + + internal CatalogContract Contract { get; } + + /// The installed default revision. + public string DefaultRevision { get; } + + /// The published revisions in manifest order. + public IReadOnlyList Revisions { get; } + + /// Every installed revision ID, in manifest order. + public IReadOnlyList AvailableRevisions => Revisions.Select(revision => revision.CatalogRevision).ToList(); + + /// The catalog embedded in this assembly (the repository's catalog/ directory). + /// The shared bundled store. + /// The bundled data is invalid. + public static CatalogStore Bundled() => BundledStore.Value; + + /// Loads a store from a catalog directory (contract.v1.json, manifest.json, revisions/). + /// The catalog directory. + /// The store. + /// The data cannot be read (integrity) or is invalid (invalid_catalog). + public static CatalogStore FromDirectory(string directory) + { + var baseDir = Path.GetFullPath(directory); + JsonValue contract; + JsonValue manifest; + try + { + contract = ReadJsonFile(Path.Combine(baseDir, "contract.v1.json")); + manifest = ReadJsonFile(Path.Combine(baseDir, "manifest.json")); + } + catch (Exception error) when (error is not PolicyCatalogException) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"catalog data could not be read: {error.Message}"); + } + + return new CatalogStore(contract, manifest, file => ReadJsonFile(Path.Combine(baseDir, file))); + } + + /// Creates a store from in-memory JSON text (used by tests and fixtures). + /// Contract JSON text. + /// Manifest JSON text. + /// Returns the JSON text of a manifest file entry. + /// The store. + /// The contract or manifest is invalid. + public static CatalogStore FromJson(string contractJson, string manifestJson, Func readRevisionJson) + { + ArgumentNullException.ThrowIfNull(readRevisionJson); + return new CatalogStore(JsonText.Parse(contractJson), JsonText.Parse(manifestJson), file => JsonText.Parse(readRevisionJson(file))); + } + + internal static JsonValue ReadJsonFile(string path) => JsonText.Parse(JsonText.DecodeUtf8(File.ReadAllBytes(path))); + + private static CatalogStore LoadBundled() + { + var assembly = typeof(CatalogStore).Assembly; + JsonValue contract; + JsonValue manifest; + try + { + contract = ReadResource(assembly, "contract.v1.json"); + manifest = ReadResource(assembly, "manifest.json"); + } + catch (Exception error) when (error is not PolicyCatalogException) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"catalog data could not be read: {error.Message}"); + } + + return new CatalogStore(contract, manifest, file => ReadResource(assembly, file)); + } + + private static JsonValue ReadResource(Assembly assembly, string file) + { + var name = "catalog/" + file.Replace('\\', '/'); + // MSBuild's RecursiveDir uses the build host's separator, so compare + // resource names with separators normalized. + var actual = assembly.GetManifestResourceNames().FirstOrDefault(resource => resource.Replace('\\', '/') == name); + using var stream = (actual is null ? null : assembly.GetManifestResourceStream(actual)) + ?? throw new FileNotFoundException($"embedded catalog resource '{name}' does not exist"); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return JsonText.Parse(JsonText.DecodeUtf8(memory.ToArray())); + } + + private static PolicyCatalogException Invalid(string message) => new(PolicyCatalogErrorReason.InvalidCatalog, message); + + private static (string DefaultRevision, List Revisions) ValidateManifest(JsonValue raw) + { + if (raw is not JsonObject obj) + { + throw Invalid("manifest root must be an object"); + } + + foreach (var key in obj.Keys) + { + if (key is not ("catalogSchemaVersion" or "defaultRevision" or "revisions")) + { + throw Invalid($"unsupported field 'manifest.{key}'"); + } + } + + if (obj.Get("catalogSchemaVersion") is not JsonString { Value: Catalog.SchemaVersion }) + { + throw Invalid($"manifest.catalogSchemaVersion must be '{Catalog.SchemaVersion}'"); + } + + if (obj.Get("revisions") is not JsonArray items || items.Count == 0) + { + throw Invalid("manifest.revisions must be a non-empty array"); + } + + var revisions = new List(); + for (var index = 0; index < items.Count; index++) + { + var at = $"manifest.revisions[{index}]"; + if (items[index] is not JsonObject item) + { + throw Invalid($"'{at}' must be an object"); + } + + foreach (var key in item.Keys) + { + if (key is not ("catalogRevision" or "file" or "sha256")) + { + throw Invalid($"unsupported field '{at}.{key}'"); + } + } + + if (item.Get("catalogRevision") is not JsonString revisionValue || !Catalog.IsRevisionId(revisionValue.Value)) + { + throw Invalid($"'{at}.catalogRevision' must match YYYY-MM-DD.N"); + } + + var catalogRevision = revisionValue.Value; + var expectedFile = $"revisions/{catalogRevision}.json"; + if (item.Get("file") is not JsonString file || file.Value != expectedFile) + { + throw Invalid($"'{at}.file' must be '{expectedFile}'"); + } + + if (item.Get("sha256") is not JsonString sha256 || !Sha256Pattern.IsMatch(sha256.Value)) + { + throw Invalid($"'{at}.sha256' must be a lower-case hex SHA-256 digest"); + } + + revisions.Add(new ManifestRevision(catalogRevision, file.Value, sha256.Value)); + } + + for (var index = 1; index < revisions.Count; index++) + { + if (Catalog.CompareRevisions(revisions[index - 1].CatalogRevision, revisions[index].CatalogRevision) >= 0) + { + throw Invalid($"manifest.revisions must be strictly increasing ('{revisions[index].CatalogRevision}')"); + } + } + + if (obj.Get("defaultRevision") is not JsonString defaultRevision || !revisions.Any(r => r.CatalogRevision == defaultRevision.Value)) + { + throw Invalid("manifest.defaultRevision must name a listed revision"); + } + + return (defaultRevision.Value, revisions); + } + + /// + /// Returns the requested revision, or the default when is null. + /// An explicitly requested revision that is not installed is an error; it is never substituted. + /// + internal CatalogRevisionData Revision(string? catalogRevision = null) + { + var id = catalogRevision ?? DefaultRevision; + if (_loaded.TryGetValue(id, out var cached)) + { + return cached; + } + + var listed = Revisions.FirstOrDefault(revision => revision.CatalogRevision == id) + ?? throw new PolicyCatalogException(PolicyCatalogErrorReason.RevisionUnavailable, $"catalog revision '{id}' is not installed"); + JsonValue raw; + try + { + raw = _readRevision(listed.File); + } + catch (Exception error) when (error is not PolicyCatalogException) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"catalog revision '{id}' could not be read: {error.Message}"); + } + + var digest = CanonicalJson.Sha256(raw); + if (digest != listed.Sha256) + { + throw new PolicyCatalogException( + PolicyCatalogErrorReason.Integrity, + $"catalog revision '{id}' digest {digest} does not match the published digest {listed.Sha256}"); + } + + var revision = Catalog.ValidateRevision(raw, Contract); + if (revision.CatalogRevision != id) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"file '{listed.File}' declares revision '{revision.CatalogRevision}', expected '{id}'"); + } + + return _loaded.GetOrAdd(id, revision); + } + + /// Loads and verifies a revision (integrity, contract, revision ID) without resolving anything. + /// The revision, or null for the default. + /// The revision is unavailable, tampered, or invalid. + public void Verify(string? catalogRevision = null) => Revision(catalogRevision); +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs new file mode 100644 index 000000000..a641cc0b2 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Mxc.PolicyCatalog.Internal; + +namespace Microsoft.Mxc.PolicyCatalog; + +/// +/// Contribution and CI tooling (TypeScript history.ts and validate.ts): catalog-directory validation, +/// entry-revision history, and the --base-ref published-revision immutability check. The runtime lookup +/// path never uses this class or runs git. +/// +public static class CatalogValidation +{ + /// + /// The on-disk copy of the bundled catalog shipped beside the consuming application + /// (<app>/catalog/, package content copied to output). The embedded copy is what + /// reads; both come from the same source directory. + /// + public static string BundledCatalogDirectory => Path.Combine(AppContext.BaseDirectory, "catalog"); + + /// + /// Validates a catalog directory: manifest and contract validity, per-revision integrity, the full catalog + /// contract, entry-revision history, and, when is given, immutability of every revision + /// published at that git ref. Never throws for catalog problems; they are reported in . + /// + /// The catalog directory (relative paths resolve against the current directory). + /// An optional git ref. + /// The report. + public static CatalogValidationReport ValidateCatalogDirectory(string catalogDir, string? baseRef = null) + { + var dir = Path.GetFullPath(catalogDir); + var errors = new List(); + CatalogStore store; + try + { + store = CatalogStore.FromDirectory(dir); + } + catch (Exception error) + { + errors.Add(DescribeError(error)); + return new CatalogValidationReport(false, dir, null, null, null, errors); + } + + errors.AddRange(CheckStoreHistory(store)); + BaseRefReport? baseRefReport = null; + if (baseRef is not null) + { + var (compared, baseErrors) = CheckAgainstBaseRef(dir, store.Revisions, baseRef); + baseRefReport = new BaseRefReport(baseRef, compared); + errors.AddRange(baseErrors); + } + + return new CatalogValidationReport(errors.Count == 0, dir, store.DefaultRevision, store.AvailableRevisions, baseRefReport, errors); + } + + /// Validates every installed revision (integrity + contract) and the entry-revision chain between consecutive revisions. + /// The store. + /// Error strings; empty when valid. + public static IReadOnlyList CheckStoreHistory(CatalogStore store) + { + ArgumentNullException.ThrowIfNull(store); + var errors = new List(); + CatalogRevisionData? previous = null; + foreach (var id in store.AvailableRevisions) + { + CatalogRevisionData current; + try + { + current = store.Revision(id); + } + catch (PolicyCatalogException error) + { + errors.Add(error.Message); + previous = null; + continue; + } + + if (previous is not null) + { + errors.AddRange(CheckEntryRevisions(previous, current)); + } + + previous = current; + } + + return errors; + } + + /// Entry-revision monotonicity between two consecutive published revisions (design §4.1, §10). + internal static List CheckEntryRevisions(CatalogRevisionData previous, CatalogRevisionData next) + { + var errors = new List(); + if (Catalog.CompareRevisions(previous.CatalogRevision, next.CatalogRevision) >= 0) + { + errors.Add($"catalog revision '{next.CatalogRevision}' must be newer than '{previous.CatalogRevision}'"); + } + + var before = new Dictionary(StringComparer.Ordinal); + foreach (var entry in previous.Entries) + { + before[entry.EntryId] = entry; + } + + foreach (var entry in next.Entries) + { + if (!before.TryGetValue(entry.EntryId, out var old)) + { + continue; + } + + var changed = SemanticKey(old) != SemanticKey(entry); + var oldRevision = JsNumber.ToString(old.EntryRevision); + var newRevision = JsNumber.ToString(entry.EntryRevision); + if (changed && entry.EntryRevision <= old.EntryRevision) + { + errors.Add($"{next.CatalogRevision}: '{entry.EntryId}' changed but entryRevision did not increase ({oldRevision} -> {newRevision})"); + } + else if (!changed && entry.EntryRevision != old.EntryRevision) + { + errors.Add($"{next.CatalogRevision}: '{entry.EntryId}' is unchanged but entryRevision moved ({oldRevision} -> {newRevision})"); + } + } + + return errors; + } + + // The validated entry holds exactly the raw entry's fields (unsupported fields + // are rejected), so the raw object minus entryRevision is its semantic key. + private static string SemanticKey(Entry entry) + { + var copy = new JsonObject(); + foreach (var key in entry.Raw.Keys) + { + if (key != "entryRevision") + { + copy.Set(key, entry.Raw.Get(key)!); + } + } + + return CanonicalJson.Serialize(copy); + } + + // ----------------------------------------------------------------------- + // Published-revision immutability + // ----------------------------------------------------------------------- + + /// A raw manifest revision record from a published state (fields may be absent or of any JSON type). + internal sealed record PublishedRevision(JsonValue? CatalogRevision, JsonValue? File, JsonValue? Sha256); + + /// Raw published state used to compare a proposed change against its base. + internal sealed record PublishedState(IReadOnlyList? Revisions, IReadOnlyDictionary Files); + + private static bool StrictEquals(JsonValue? left, JsonValue? right) => (left, right) switch + { + (null, null) => true, + (JsonString a, JsonString b) => a.Value == b.Value, + (JsonNumber a, JsonNumber b) => a.Value == b.Value, + (JsonBool a, JsonBool b) => a.Value == b.Value, + (JsonNull, JsonNull) => true, + _ => ReferenceEquals(left, right), + }; + + /// Published revisions keep their manifest entry, digest, and content; new revisions are only appended. + internal static List CheckPublishedImmutability(PublishedState @base, PublishedState proposed) + { + if (@base.Revisions is null) + { + throw new InvalidOperationException("Cannot read properties of undefined (reading 'forEach')"); + } + + var errors = new List(); + for (var index = 0; index < @base.Revisions.Count; index++) + { + var published = @base.Revisions[index]; + var name = Catalog.Js(published.CatalogRevision); + var now = proposed.Revisions is not null && index < proposed.Revisions.Count ? proposed.Revisions[index] : null; + if (now is null || !StrictEquals(now.CatalogRevision, published.CatalogRevision)) + { + errors.Add($"published revision '{name}' was removed or reordered in the manifest"); + continue; + } + + if (!StrictEquals(now.File, published.File) || !StrictEquals(now.Sha256, published.Sha256)) + { + errors.Add($"published revision '{name}' manifest entry was modified"); + } + + var file = Catalog.Js(published.File); + var baseContent = Canonical(@base.Files.TryGetValue(file, out var baseText) ? baseText : null); + var proposedContent = Canonical(proposed.Files.TryGetValue(file, out var proposedText) ? proposedText : null); + if (baseContent is not null && proposedContent != baseContent) + { + errors.Add($"published revision file '{file}' was modified; publish a new revision instead"); + } + } + + return errors; + } + + // Canonical content, not raw bytes, so line-ending conversion cannot produce a false positive. + private static string? Canonical(string? text) + { + if (text is null) + { + return null; + } + + try + { + return CanonicalJson.Serialize(JsonText.Parse(text)); + } + catch (System.Text.Json.JsonException) + { + return $"invalid:{text}"; + } + } + + private static string DescribeError(Exception error) => + error is PolicyCatalogException ? error.Message : $"[policy_validation] {error.Message}"; + + /// Compares a directory's manifest and revision files with the revisions published at . Returns errors, never throws. + internal static (int ComparedRevisions, List Errors) CheckAgainstBaseRef(string catalogDir, IReadOnlyList manifest, string baseRef) + { + var dir = Path.GetFullPath(catalogDir); + try + { + var @base = ReadPublishedStateAtRef(dir, baseRef); + if (@base is null) + { + return (0, new List()); + } + + var files = new Dictionary(StringComparer.Ordinal); + foreach (var revision in manifest) + { + string text; + try + { + text = JsonText.DecodeUtf8(File.ReadAllBytes(Path.GetFullPath(Path.Combine(dir, revision.File)))); + } + catch (Exception) + { + // A published file may not disappear; reported as a modification. + text = string.Empty; + } + + files[revision.File] = text; + } + + var proposed = new PublishedState( + manifest.Select(r => new PublishedRevision(new JsonString(r.CatalogRevision), new JsonString(r.File), new JsonString(r.Sha256))).ToList(), + files); + return (@base.Revisions!.Count, CheckPublishedImmutability(@base, proposed).Select(message => $"[immutability] {message}").ToList()); + } + catch (Exception error) + { + return (0, new List { DescribeError(error) }); + } + } + + /// + /// Reads the published catalog state at for the git repository containing + /// ; null when the ref has no catalog at that path. Throws when the check + /// cannot run, so a typo never passes silently. + /// + internal static PublishedState? ReadPublishedStateAtRef(string catalogDir, string baseRef) + { + // A ref starting with '-' would be parsed by git as an option. + if (baseRef.Length == 0 || baseRef.StartsWith('-') || baseRef.Any(c => JsString.IsWhitespace(c) || c == '\0')) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{baseRef}' is not a valid git ref"); + } + + var dir = RealPath(catalogDir); + string top; + try + { + top = RealPath(Git(new[] { "rev-parse", "--show-toplevel" }, dir).Trim()); + } + catch (Exception) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{catalogDir}' is not inside a git work tree"); + } + + if (!GitSucceeds(new[] { "rev-parse", "--verify", "--quiet", $"{baseRef}^{{commit}}" }, top)) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{baseRef}' does not name a commit"); + } + + var prefix = Relative(top, dir); + if (prefix is null) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{catalogDir}' is outside its git work tree"); + } + + string At(string file) => $"{baseRef}:{(prefix.Length == 0 ? string.Empty : prefix + "/")}{file}"; + if (!GitSucceeds(new[] { "cat-file", "-e", At("manifest.json") }, top)) + { + return null; + } + + var manifest = JsonText.Parse(Git(new[] { "show", At("manifest.json") }, top)); + List? revisions = null; + var files = new Dictionary(StringComparer.Ordinal); + var revisionsValue = manifest switch + { + JsonObject obj => obj.Get("revisions"), + JsonNull => throw new InvalidOperationException("Cannot read properties of null (reading 'revisions')"), + _ => null, + }; + if (revisionsValue is not null and not JsonNull) + { + if (revisionsValue is not JsonArray list) + { + throw new InvalidOperationException("manifest.revisions is not iterable"); + } + + revisions = new List(); + foreach (var item in list.Items) + { + if (item is not JsonObject record) + { + throw new InvalidOperationException("Cannot read properties of a non-object manifest revision"); + } + + var revision = new PublishedRevision(record.Get("catalogRevision"), record.Get("file"), record.Get("sha256")); + revisions.Add(revision); + var file = Catalog.Js(revision.File); + if (GitSucceeds(new[] { "cat-file", "-e", At(file) }, top)) + { + files[file] = Git(new[] { "show", At(file) }, top); + } + } + } + + return new PublishedState(revisions, files); + } + + /// Node path.relative(top, dir) with forward slashes, or null when is outside . + private static string? Relative(string top, string dir) + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var t = Path.TrimEndingDirectorySeparator(top); + var d = Path.TrimEndingDirectorySeparator(dir); + if (string.Equals(t, d, comparison)) + { + return string.Empty; + } + + var withSeparator = t.EndsWith(Path.DirectorySeparatorChar) ? t : t + Path.DirectorySeparatorChar; + if (!d.StartsWith(withSeparator, comparison)) + { + return null; + } + + return d.Substring(withSeparator.Length).Replace('\\', '/'); + } + + /// Node realpathSync.native: absolute, symlinks resolved, Windows 8.3 names expanded. Throws when missing. + private static string RealPath(string path) + { + var full = Path.GetFullPath(path); + if (!Directory.Exists(full) && !File.Exists(full)) + { + throw new DirectoryNotFoundException($"ENOENT: no such file or directory, realpath '{path}'"); + } + + var info = new DirectoryInfo(full); + var resolved = info.LinkTarget is not null ? info.ResolveLinkTarget(true)?.FullName ?? full : full; + // Resolve links in ancestors as well. + var parts = new List(); + var current = new DirectoryInfo(resolved); + while (current.Parent is not null) + { + if (current.LinkTarget is not null && current.ResolveLinkTarget(true) is { } target) + { + current = new DirectoryInfo(target.FullName); + continue; + } + + parts.Insert(0, current.Name); + current = current.Parent; + } + + var result = parts.Count == 0 ? current.FullName : Path.Combine(new[] { current.FullName }.Concat(parts).ToArray()); + return OperatingSystem.IsWindows() ? LongPath(result) : result; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "GetLongPathNameW")] + private static extern uint GetLongPathName(string shortPath, StringBuilder longPath, uint bufferLength); + + private static string LongPath(string path) + { + var buffer = new StringBuilder(1024); + var length = GetLongPathName(path, buffer, (uint)buffer.Capacity); + if (length > buffer.Capacity) + { + buffer = new StringBuilder((int)length); + length = GetLongPathName(path, buffer, (uint)buffer.Capacity); + } + + return length == 0 ? path : buffer.ToString(); + } + + private static string Git(IReadOnlyList args, string cwd) + { + var start = new ProcessStartInfo("git") + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = new UTF8Encoding(false), + }; + foreach (var arg in args) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start) ?? throw new InvalidOperationException("git could not be started"); + process.StandardInput.Close(); + var stderr = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + var errorText = stderr.Result; + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Command failed: git {string.Join(" ", args)}\n{errorText}"); + } + + return output; + } + + private static bool GitSucceeds(IReadOnlyList args, string cwd) + { + try + { + Git(args, cwd); + return true; + } + catch (Exception) + { + return false; + } + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs new file mode 100644 index 000000000..bf8299334 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.PolicyCatalog; + +/// Catalog platform selector values (design §4.4). +public static class CatalogPlatforms +{ + /// Windows. + public const string Windows = "windows"; + + /// Linux. + public const string Linux = "linux"; + + /// macOS. + public const string MacOS = "macos"; + + /// Every selector, in catalog order. + public static IReadOnlyList All { get; } = new[] { Windows, Linux, MacOS }; +} + +/// Catalog architecture selector values (design §4.4). +public static class CatalogArchitectures +{ + /// x64 (AMD64 / x86_64). + public const string X64 = "x64"; + + /// ARM64 (aarch64). + public const string Arm64 = "arm64"; + + /// Every selector, in catalog order. + public static IReadOnlyList All { get; } = new[] { X64, Arm64 }; +} + +/// +/// Runtime lookup input (design §5.1). A plain string converts implicitly to an input with only +/// . +/// +/// The bare invocation name (never a path). +public sealed record ToolInput(string InvocationName) +{ + /// Package URL evidence (strong identity). The caller must have verified it. + public string? PackageUrl { get; init; } + + /// Detected tool version evidence, compared with reviewed version ranges. + public string? DetectedVersion { get; init; } + + /// String shorthand for new ToolInput(name). + /// The invocation name. + public static implicit operator ToolInput(string invocationName) => new(invocationName); +} + +/// Runtime lookup context, shared by every input in one lookup (design §5.1). +public sealed record ResolveContext +{ + /// The consumer's project root; the only source of the project_root symbol. + public string? ProjectRoot { get; init; } + + /// Caller-supplied symbol values (caller and host symbols; host values override detection). + public IReadOnlyDictionary? Symbols { get; init; } + + /// Target platform (); defaults to the host platform. + public string? Platform { get; init; } + + /// Target architecture (); defaults to the native system architecture. + public string? Architecture { get; init; } + + /// Explicit catalog revision; defaults to the installed default revision. Never substituted. + public string? CatalogRevision { get; init; } + + /// Allows invocation-name-only (weak) identity matches. Off by default. + public bool AllowWeakIdentityFallback { get; init; } +} + +/// Host facts the resolver uses only when the caller omits them. +public interface IHostEnvironment +{ + /// The host platform selector. Throw (unsupported_host) when it has none. + /// A value of . + string Platform(); + + /// + /// The device's native system architecture (not the process architecture). Throw when it cannot be + /// determined; never guess. + /// + /// A value of . + string NativeArchitecture(); + + /// An approved host-known symbol (source: "host") for the current host. + /// The symbol name, e.g. user_home. + /// The value, or null when unknown. + string? Symbol(string name); +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs new file mode 100644 index 000000000..bc941ce80 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// +/// Canonical JSON used for revision integrity digests (TypeScript canonicalJson): +/// object keys sorted by UTF-16 code units at every level, arrays in order, no +/// whitespace, JSON.stringify string escaping, ECMAScript number formatting. +/// +internal static class CanonicalJson +{ + public static string Serialize(JsonValue value) + { + var builder = new StringBuilder(); + Write(builder, value); + return builder.ToString(); + } + + private static void Write(StringBuilder builder, JsonValue value) + { + switch (value) + { + case JsonArray array: + builder.Append('['); + for (var i = 0; i < array.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + Write(builder, array[i]); + } + + builder.Append(']'); + break; + case JsonObject obj: + var keys = obj.Keys.ToList(); + keys.Sort(string.CompareOrdinal); + builder.Append('{'); + for (var i = 0; i < keys.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + JsonText.Quote(builder, keys[i]); + builder.Append(':'); + Write(builder, obj.Get(keys[i])!); + } + + builder.Append('}'); + break; + default: + builder.Append(JsonText.Stringify(value)); + break; + } + } + + /// Lower-case hex SHA-256 of the UTF-8 canonical form. + public static string Sha256(JsonValue value) + { + // Lone surrogates become U+FFFD in UTF-8, exactly as Node's hash update does. + var bytes = Encoding.UTF8.GetBytes(Serialize(value)); + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs new file mode 100644 index 000000000..17d2203f2 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs @@ -0,0 +1,1015 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +internal sealed record SymbolDefinition(string Source, string Description); + +internal sealed class CatalogContract +{ + public CatalogContract(string schemaVersion, IReadOnlyList policyVersions, IReadOnlyDictionary symbols) + { + SchemaVersion = schemaVersion; + PolicyVersions = policyVersions; + Symbols = symbols; + } + + public string SchemaVersion { get; } + + public IReadOnlyList PolicyVersions { get; } + + /// Own-key symbol table; lookups never see inherited names. + public IReadOnlyDictionary Symbols { get; } +} + +internal abstract record IdentityPredicate(string Kind) +{ + public string Strength => Kind == "purl" ? "strong" : "weak"; +} + +internal sealed record PurlPredicate(string Value, string? VersionRange) : IdentityPredicate("purl"); + +internal sealed record NamePredicate(IReadOnlyList Names) : IdentityPredicate("invocation-name"); + +internal sealed record Dependency(string EntryId, string? VersionRange); + +internal sealed class Variant +{ + public Variant(string platform, string? architecture, IReadOnlyList? dependencies, JsonObject policy) + { + Platform = platform; + Architecture = architecture; + Dependencies = dependencies; + Policy = policy; + } + + public string Platform { get; } + + public string? Architecture { get; } + + public IReadOnlyList? Dependencies { get; } + + /// The validated raw policy object (only contract fields). + public JsonObject Policy { get; } + + public string PolicyVersion => ((JsonString)Policy.Get("version")!).Value; + + public IReadOnlyList DependencyList => Dependencies ?? Array.Empty(); + + public List Paths(string field) + { + if (Policy.Get("filesystem") is JsonObject filesystem && filesystem.Get(field) is JsonArray array) + { + return array.Items.Select(item => ((JsonString)item).Value).ToList(); + } + + return new List(); + } +} + +internal sealed class Entry +{ + public Entry(string entryId, double entryRevision, string displayName, IReadOnlyList identity, IReadOnlyList variants, string method, string sourceRevision, JsonObject raw) + { + EntryId = entryId; + EntryRevision = entryRevision; + DisplayName = displayName; + Identity = identity; + Variants = variants; + Method = method; + SourceRevision = sourceRevision; + Raw = raw; + } + + public string EntryId { get; } + + public double EntryRevision { get; } + + public string DisplayName { get; } + + public IReadOnlyList Identity { get; } + + public IReadOnlyList Variants { get; } + + public string Method { get; } + + public string SourceRevision { get; } + + public JsonObject Raw { get; } +} + +internal sealed class CatalogRevisionData +{ + public CatalogRevisionData(string schemaVersion, string catalogRevision, IReadOnlyList entries) + { + SchemaVersion = schemaVersion; + CatalogRevision = catalogRevision; + Entries = entries; + } + + public string SchemaVersion { get; } + + public string CatalogRevision { get; } + + public IReadOnlyList Entries { get; } +} + +internal sealed record VariantSelection(Variant Variant, bool Exact); + +internal sealed record ClosureNode(Entry Entry, Variant Variant, bool Exact); + +internal sealed record ClosureResult(List? Nodes, string? Reason, string? Detail) +{ + public bool Ok => Nodes is not null; +} + +/// Port of TypeScript catalog.ts. +internal static class Catalog +{ + public const string SchemaVersion = "1"; + + public static readonly string[] ComposableFields = { "deniedPaths", "readonlyPaths", "readwritePaths" }; + + private static readonly string[] BackendKeys = { "containment", "processContainer", "appContainer", "lxc", "seatbelt", "wslc", "hyperlight", "bwrap" }; + + private static readonly Regex RevisionPattern = new("^([0-9]{4}-[0-9]{2}-[0-9]{2})\\.([1-9][0-9]*)\\z", RegexOptions.CultureInvariant); + private static readonly Regex EntryIdPattern = new("^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*\\z", RegexOptions.CultureInvariant); + private static readonly Regex SymbolNamePattern = new("^[a-z][a-z0-9_]*\\z", RegexOptions.CultureInvariant); + public static readonly Regex SymbolPattern = new("\\$\\{([a-z][a-z0-9_]*)\\}", RegexOptions.CultureInvariant); + private static readonly Regex AnchoredSymbol = new("^\\$\\{([a-z][a-z0-9_]*)\\}(?:[\\\\/]|\\z)", RegexOptions.CultureInvariant); + private static readonly Regex PathSplit = new("[\\\\/]", RegexOptions.CultureInvariant); + private static readonly Regex CidrWildcard = new("/0\\z", RegexOptions.CultureInvariant); + + public static PolicyCatalogException Fail(string message) => new(PolicyCatalogErrorReason.InvalidCatalog, message); + + private static bool IsRecord(JsonValue? value) => value is JsonObject; + + private static void OnlyFields(JsonObject value, IReadOnlyCollection allowed, string at) + { + foreach (var key in value.Keys) + { + if (!allowed.Contains(key)) + { + throw Fail($"unsupported field '{at}.{key}'"); + } + } + } + + private static string NonEmptyString(JsonValue? value, string at) + { + if (value is not JsonString s || s.Value.Length == 0) + { + throw Fail($"'{at}' must be a non-empty string"); + } + + return s.Value; + } + + private static List StringArray(JsonValue? value, string at, int minItems = 0) + { + if (value is not JsonArray array || array.Count < minItems) + { + throw Fail($"'{at}' must be an array with at least {minItems} item(s)"); + } + + return array.Items.Select((item, index) => NonEmptyString(item, $"{at}[{index}]")).ToList(); + } + + private static bool IsString(JsonValue? value, string expected) => value is JsonString s && s.Value == expected; + + private static bool IsInteger(JsonValue? value, out double number) + { + number = value is JsonNumber n ? n.Value : double.NaN; + return value is JsonNumber && double.IsFinite(number) && Math.Floor(number) == number; + } + + /// Formats a raw value the way a JavaScript template literal would for the values that reach messages. + internal static string Js(JsonValue? value) => value switch + { + null => "undefined", + JsonString s => s.Value, + JsonNumber n => JsNumber.ToString(n.Value), + JsonBool b => b.Value ? "true" : "false", + JsonNull => "null", + JsonArray a => string.Join(",", a.Items.Select(item => item is JsonNull ? string.Empty : Js(item))), + _ => "[object Object]", + }; + + public static int CompareRevisions(string left, string right) + { + var a = RevisionPattern.Match(left); + var b = RevisionPattern.Match(right); + if (!a.Success || !b.Success) + { + throw Fail($"cannot compare malformed catalog revisions '{left}' and '{right}'"); + } + + if (a.Groups[1].Value != b.Groups[1].Value) + { + return string.CompareOrdinal(a.Groups[1].Value, b.Groups[1].Value) < 0 ? -1 : 1; + } + + var x = double.Parse(a.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture); + var y = double.Parse(b.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture); + return Math.Sign(x - y); + } + + public static bool IsRevisionId(string value) => RevisionPattern.IsMatch(value); + + // ----------------------------------------------------------------------- + // Contract + // ----------------------------------------------------------------------- + + public static CatalogContract ValidateContract(JsonValue raw) + { + if (raw is not JsonObject obj) + { + throw Fail("contract root must be an object"); + } + + OnlyFields(obj, new[] { "$comment", "catalogSchemaVersion", "sandboxPolicyVersions", "symbols" }, "contract"); + if (!IsString(obj.Get("catalogSchemaVersion"), SchemaVersion)) + { + throw Fail($"contract.catalogSchemaVersion must be '{SchemaVersion}'"); + } + + var versions = StringArray(obj.Get("sandboxPolicyVersions"), "contract.sandboxPolicyVersions", 1); + if (obj.Get("symbols") is not JsonObject symbolsRaw) + { + throw Fail("contract.symbols must be an object"); + } + + var symbols = new Dictionary(StringComparer.Ordinal); + foreach (var name in symbolsRaw.Keys) + { + var definition = symbolsRaw.Get(name); + if (!SymbolNamePattern.IsMatch(name) || definition is not JsonObject def) + { + throw Fail($"contract.symbols.{name} is malformed"); + } + + OnlyFields(def, new[] { "source", "description" }, $"contract.symbols.{name}"); + var source = def.Get("source"); + if (!IsString(source, "context") && !IsString(source, "caller") && !IsString(source, "host")) + { + throw Fail($"contract.symbols.{name}.source is unsupported"); + } + + symbols[name] = new SymbolDefinition(((JsonString)source!).Value, NonEmptyString(def.Get("description"), $"contract.symbols.{name}.description")); + } + + return new CatalogContract(SchemaVersion, versions, symbols); + } + + // ----------------------------------------------------------------------- + // Paths and symbols + // ----------------------------------------------------------------------- + + private static void ValidateTemplatePath(string value, string at, CatalogContract contract) + { + if (value.IndexOfAny(new[] { '*', '?' }) >= 0) + { + throw Fail($"'{at}' contains a wildcard"); + } + + if (SymbolPattern.Replace(value, string.Empty).Contains("${", StringComparison.Ordinal)) + { + throw Fail($"'{at}' contains malformed symbol syntax"); + } + + foreach (Match match in SymbolPattern.Matches(value)) + { + if (!contract.Symbols.ContainsKey(match.Groups[1].Value)) + { + throw Fail($"'{at}' references unknown symbol '{match.Groups[1].Value}'"); + } + } + + if (!AnchoredSymbol.IsMatch(value)) + { + throw Fail($"'{at}' must start with a declared symbol; literal paths are not allowed"); + } + + if (PathSplit.Split(value).Any(segment => segment == "..")) + { + throw Fail($"'{at}' must not contain '..' segments"); + } + } + + /// Symbols a policy references, in first-seen order. + public static List PolicySymbols(Variant variant) + { + var seen = new List(); + foreach (var field in ComposableFields) + { + foreach (var value in variant.Paths(field)) + { + foreach (Match match in SymbolPattern.Matches(value)) + { + if (!seen.Contains(match.Groups[1].Value)) + { + seen.Add(match.Groups[1].Value); + } + } + } + } + + return seen; + } + + private static bool IsSameOrNested(List left, List right) + { + var shorter = left.Count <= right.Count ? left : right; + var longer = ReferenceEquals(shorter, left) ? right : left; + for (var i = 0; i < shorter.Count; i++) + { + if (longer[i] != shorter[i]) + { + return false; + } + } + + return true; + } + + /// First equal or ancestor/descendant pair across access classes, or null. + public static string? FindCrossClassOverlap(IReadOnlyDictionary> classes, string platform) + { + var fields = ComposableFields.Where(field => classes.TryGetValue(field, out var list) && list.Count > 0).ToList(); + for (var i = 0; i < fields.Count; i++) + { + for (var j = i + 1; j < fields.Count; j++) + { + foreach (var left in classes[fields[i]]) + { + foreach (var right in classes[fields[j]]) + { + if (IsSameOrNested(Paths.PathKeySegments(left, platform), Paths.PathKeySegments(right, platform))) + { + return $"'{left}' ({fields[i]}) overlaps '{right}' ({fields[j]})"; + } + } + } + } + } + + return null; + } + + // ----------------------------------------------------------------------- + // Embedded SandboxPolicy + // ----------------------------------------------------------------------- + + private static void ValidateNetworkRules(JsonValue value, string at, bool denyList) + { + if (value is not JsonArray rules) + { + throw Fail($"'{at}' must be an array"); + } + + for (var index = 0; index < rules.Count; index++) + { + var ruleAt = $"{at}[{index}]"; + if (rules[index] is not JsonObject rule) + { + throw Fail($"'{ruleAt}' must be an object"); + } + + OnlyFields(rule, new[] { "to", "ports" }, ruleAt); + var to = rule.Get("to"); + if (to is null && !denyList) + { + throw Fail($"'{ruleAt}' has no 'to'; wildcard network grants are not allowed"); + } + + if (to is not null) + { + if (to is not JsonArray peers || peers.Count == 0) + { + throw Fail($"'{ruleAt}.to' must be a non-empty array"); + } + + for (var peerIndex = 0; peerIndex < peers.Count; peerIndex++) + { + var peerAt = $"{ruleAt}.to[{peerIndex}]"; + if (peers[peerIndex] is not JsonObject peer) + { + throw Fail($"'{peerAt}' must be an object"); + } + + OnlyFields(peer, new[] { "cidr", "except" }, peerAt); + var cidr = NonEmptyString(peer.Get("cidr"), $"{peerAt}.cidr"); + if (!denyList && CidrWildcard.IsMatch(cidr)) + { + throw Fail($"'{peerAt}.cidr' is a wildcard network grant"); + } + + if (peer.Get("except") is { } except) + { + StringArray(except, $"{peerAt}.except"); + } + } + } + + var portsValue = rule.Get("ports"); + if (portsValue is not null) + { + if (portsValue is not JsonArray ports || ports.Count == 0) + { + throw Fail($"'{ruleAt}.ports' must be a non-empty array"); + } + + for (var portIndex = 0; portIndex < ports.Count; portIndex++) + { + var portAt = $"{ruleAt}.ports[{portIndex}]"; + if (ports[portIndex] is not JsonObject port) + { + throw Fail($"'{portAt}' must be an object"); + } + + OnlyFields(port, new[] { "protocol", "port", "endPort" }, portAt); + var protocol = port.Get("protocol"); + if (protocol is not null && !(protocol is JsonString p && (p.Value is "tcp" or "udp" or "icmp" or "any"))) + { + throw Fail($"'{portAt}.protocol' is unsupported"); + } + + foreach (var key in new[] { "port", "endPort" }) + { + var n = port.Get(key); + if (n is not null && (!IsInteger(n, out var number) || number < 1 || number > 65535)) + { + throw Fail($"'{portAt}.{key}' must be an integer in 1..65535"); + } + } + + if (port.Get("endPort") is JsonNumber endPort + && (port.Get("port") is not JsonNumber start || endPort.Value < start.Value)) + { + throw Fail($"'{portAt}.endPort' requires a lower or equal 'port'"); + } + } + } + } + } + + private static JsonObject ValidateSandboxPolicy(JsonValue? raw, string at, CatalogContract contract) + { + if (raw is not JsonObject policy) + { + throw Fail($"'{at}' must be an object"); + } + + foreach (var key in BackendKeys) + { + if (policy.Has(key)) + { + throw Fail($"'{at}.{key}' names a containment backend; platform variants must stay backend-neutral"); + } + } + + OnlyFields(policy, new[] { "version", "filesystem", "network", "ui", "timeoutMs" }, at); + var version = NonEmptyString(policy.Get("version"), $"{at}.version"); + if (!contract.PolicyVersions.Contains(version)) + { + throw Fail($"'{at}.version' '{version}' is not a SandboxPolicy version registered in the catalog contract"); + } + + var filesystemValue = policy.Get("filesystem"); + if (filesystemValue is not null) + { + if (filesystemValue is not JsonObject filesystem) + { + throw Fail($"'{at}.filesystem' must be an object"); + } + + OnlyFields(filesystem, ComposableFields, $"{at}.filesystem"); + foreach (var field in ComposableFields) + { + var list = filesystem.Get(field); + if (list is not null) + { + var values = StringArray(list, $"{at}.filesystem.{field}"); + for (var index = 0; index < values.Count; index++) + { + ValidateTemplatePath(values[index], $"{at}.filesystem.{field}[{index}]", contract); + } + } + } + } + + var networkValue = policy.Get("network"); + if (networkValue is not null) + { + if (networkValue is not JsonObject network) + { + throw Fail($"'{at}.network' must be an object"); + } + + OnlyFields(network, new[] { "egress", "ingress" }, $"{at}.network"); + var egressValue = network.Get("egress"); + if (egressValue is not null) + { + if (egressValue is not JsonObject egress) + { + throw Fail($"'{at}.network.egress' must be an object"); + } + + OnlyFields(egress, new[] { "default", "allow", "deny" }, $"{at}.network.egress"); + if (egress.Get("default") is { } egressDefault && !IsString(egressDefault, "deny")) + { + throw Fail($"'{at}.network.egress.default' must be 'deny'; a default-allow grant is a wildcard"); + } + + if (egress.Get("allow") is { } allow) + { + ValidateNetworkRules(allow, $"{at}.network.egress.allow", false); + } + + if (egress.Get("deny") is { } deny) + { + ValidateNetworkRules(deny, $"{at}.network.egress.deny", true); + } + } + + var ingressValue = network.Get("ingress"); + if (ingressValue is not null) + { + if (ingressValue is not JsonObject ingress) + { + throw Fail($"'{at}.network.ingress' must be an object"); + } + + OnlyFields(ingress, new[] { "default", "hostLoopback" }, $"{at}.network.ingress"); + if (ingress.Get("default") is { } ingressDefault && !IsString(ingressDefault, "deny")) + { + throw Fail($"'{at}.network.ingress.default' must be 'deny'; a default-allow grant is a wildcard"); + } + + if (ingress.Get("hostLoopback") is { } loopback && !IsString(loopback, "allow") && !IsString(loopback, "deny")) + { + throw Fail($"'{at}.network.ingress.hostLoopback' is unsupported"); + } + } + } + + var uiValue = policy.Get("ui"); + if (uiValue is not null) + { + if (uiValue is not JsonObject ui) + { + throw Fail($"'{at}.ui' must be an object"); + } + + OnlyFields(ui, new[] { "allowWindows", "clipboard", "allowInputInjection" }, $"{at}.ui"); + foreach (var key in new[] { "allowWindows", "allowInputInjection" }) + { + if (ui.Get(key) is { } flag && flag is not JsonBool) + { + throw Fail($"'{at}.ui.{key}' must be a boolean"); + } + } + + if (ui.Get("clipboard") is { } clipboard && !(clipboard is JsonString c && (c.Value is "none" or "read" or "write" or "all"))) + { + throw Fail($"'{at}.ui.clipboard' is unsupported"); + } + } + + var timeout = policy.Get("timeoutMs"); + if (timeout is not null && (!IsInteger(timeout, out var ms) || ms < 1)) + { + throw Fail($"'{at}.timeoutMs' must be a positive integer"); + } + + return policy; + } + + // ----------------------------------------------------------------------- + // Entries + // ----------------------------------------------------------------------- + + private static List ValidateIdentity(JsonValue? raw, string at) + { + if (raw is not JsonArray items || items.Count == 0) + { + throw Fail($"'{at}' must be a non-empty array"); + } + + var predicates = new List(); + for (var index = 0; index < items.Count; index++) + { + var itemAt = $"{at}[{index}]"; + if (items[index] is not JsonObject item) + { + throw Fail($"'{itemAt}' must be an object"); + } + + var kind = item.Get("kind"); + if (IsString(kind, "purl")) + { + OnlyFields(item, new[] { "kind", "value", "versionRange" }, itemAt); + var value = NonEmptyString(item.Get("value"), $"{itemAt}.value"); + var parsed = Purl.Parse(value) ?? throw Fail($"'{itemAt}.value' is not a valid package URL"); + if (parsed.Version is not null) + { + throw Fail($"'{itemAt}.value' must not pin a version; use 'versionRange'"); + } + + var rangeValue = item.Get("versionRange"); + string? range = null; + if (rangeValue is not null) + { + range = NonEmptyString(rangeValue, $"{itemAt}.versionRange"); + if (!VersionRange.IsValid(range)) + { + throw Fail($"'{itemAt}.versionRange' is not a valid version range"); + } + } + + predicates.Add(new PurlPredicate(value, range)); + continue; + } + + if (IsString(kind, "invocation-name")) + { + OnlyFields(item, new[] { "kind", "names" }, itemAt); + var names = StringArray(item.Get("names"), $"{itemAt}.names", 1); + foreach (var name in names) + { + if (name.IndexOfAny(new[] { '\\', '/' }) >= 0) + { + throw Fail($"'{itemAt}.names' entry '{name}' must be a bare invocation name, not a path"); + } + } + + predicates.Add(new NamePredicate(names)); + continue; + } + + throw Fail($"'{itemAt}.kind' is not a supported identity kind"); + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var key in predicates.SelectMany(IdentityKeys)) + { + if (!seen.Add(key)) + { + throw Fail($"'{at}' repeats identity '{key}'"); + } + } + + return predicates; + } + + /// Stable comparison keys for one predicate; invocation names always fold case. + private static IEnumerable IdentityKeys(IdentityPredicate predicate) => predicate switch + { + PurlPredicate purl => new[] { $"purl:{Purl.Parse(purl.Value)!.Key}" }, + NamePredicate names => names.Names.Select(name => $"invocation-name:{JsString.ToLower(name)}"), + _ => Array.Empty(), + }; + + private static List ValidateVariants(JsonValue? raw, string at, CatalogContract contract) + { + if (raw is not JsonArray items || items.Count == 0) + { + throw Fail($"'{at}' must be a non-empty array"); + } + + var selectors = new HashSet(StringComparer.Ordinal); + var variants = new List(); + for (var index = 0; index < items.Count; index++) + { + var itemAt = $"{at}[{index}]"; + if (items[index] is not JsonObject item) + { + throw Fail($"'{itemAt}' must be an object"); + } + + OnlyFields(item, new[] { "when", "dependencies", "sandboxPolicy" }, itemAt); + if (item.Get("when") is not JsonObject when) + { + throw Fail($"'{itemAt}.when' must be an object"); + } + + OnlyFields(when, new[] { "platform", "architecture" }, $"{itemAt}.when"); + if (when.Get("platform") is not JsonString platformValue || !CatalogPlatforms.All.Contains(platformValue.Value)) + { + throw Fail($"'{itemAt}.when.platform' must be one of {string.Join(", ", CatalogPlatforms.All)}"); + } + + var platform = platformValue.Value; + var architectureValue = when.Get("architecture"); + string? architecture = null; + if (architectureValue is not null) + { + if (architectureValue is not JsonString a || !CatalogArchitectures.All.Contains(a.Value)) + { + throw Fail($"'{itemAt}.when.architecture' must be one of {string.Join(", ", CatalogArchitectures.All)}"); + } + + architecture = a.Value; + } + + var selector = $"{platform}/{architecture ?? "*"}"; + if (!selectors.Add(selector)) + { + throw Fail(architecture is null + ? $"'{itemAt}' is a second architecture-neutral variant for '{platform}'" + : $"'{itemAt}' duplicates selector '{selector}'"); + } + + List? dependencies = null; + var dependenciesValue = item.Get("dependencies"); + if (dependenciesValue is not null) + { + if (dependenciesValue is not JsonArray list) + { + throw Fail($"'{itemAt}.dependencies' must be an array"); + } + + var seen = new HashSet(StringComparer.Ordinal); + dependencies = new List(); + for (var depIndex = 0; depIndex < list.Count; depIndex++) + { + var depAt = $"{itemAt}.dependencies[{depIndex}]"; + if (list[depIndex] is not JsonObject dependency) + { + throw Fail($"'{depAt}' must be an object"); + } + + OnlyFields(dependency, new[] { "entryId", "versionRange" }, depAt); + var entryId = NonEmptyString(dependency.Get("entryId"), $"{depAt}.entryId"); + if (!seen.Add(entryId)) + { + throw Fail($"'{depAt}.entryId' '{entryId}' is listed twice"); + } + + string? range = null; + if (dependency.Get("versionRange") is { } rangeValue) + { + range = NonEmptyString(rangeValue, $"{depAt}.versionRange"); + if (!VersionRange.IsValid(range)) + { + throw Fail($"'{depAt}.versionRange' is not a valid version range"); + } + } + + dependencies.Add(new Dependency(entryId, range)); + } + } + + var policy = ValidateSandboxPolicy(item.Get("sandboxPolicy"), $"{itemAt}.sandboxPolicy", contract); + variants.Add(new Variant(platform, architecture, dependencies, policy)); + } + + return variants; + } + + private static Entry ValidateEntry(JsonValue raw, string at, CatalogContract contract) + { + if (raw is not JsonObject entry) + { + throw Fail($"'{at}' must be an object"); + } + + OnlyFields(entry, new[] { "entryId", "entryRevision", "displayName", "identity", "platformVariants", "provenance" }, at); + var entryId = NonEmptyString(entry.Get("entryId"), $"{at}.entryId"); + if (!EntryIdPattern.IsMatch(entryId)) + { + throw Fail($"'{at}.entryId' '{entryId}' must be namespaced, e.g. 'tool:name'"); + } + + if (!IsInteger(entry.Get("entryRevision"), out var entryRevision) || entryRevision < 1) + { + throw Fail($"'{at}.entryRevision' must be a positive integer"); + } + + if (entry.Get("provenance") is not JsonObject provenance) + { + throw Fail($"'{at}.provenance' must be an object"); + } + + OnlyFields(provenance, new[] { "method", "sourceRevision" }, $"{at}.provenance"); + var displayName = NonEmptyString(entry.Get("displayName"), $"{at}.displayName"); + var identity = ValidateIdentity(entry.Get("identity"), $"{at}.identity"); + var variants = ValidateVariants(entry.Get("platformVariants"), $"{at}.platformVariants", contract); + var method = NonEmptyString(provenance.Get("method"), $"{at}.provenance.method"); + var sourceRevision = NonEmptyString(provenance.Get("sourceRevision"), $"{at}.provenance.sourceRevision"); + return new Entry(entryId, entryRevision, displayName, identity, variants, method, sourceRevision, entry); + } + + // ----------------------------------------------------------------------- + // Variant selection and dependency closure + // ----------------------------------------------------------------------- + + public static VariantSelection? SelectVariant(Entry entry, string platform, string architecture) + { + var forPlatform = entry.Variants.Where(variant => variant.Platform == platform).ToList(); + var exact = forPlatform.FirstOrDefault(variant => variant.Architecture == architecture); + if (exact is not null) + { + return new VariantSelection(exact, true); + } + + var neutral = forPlatform.FirstOrDefault(variant => variant.Architecture is null); + return neutral is null ? null : new VariantSelection(neutral, false); + } + + /// Depth-first closure: root first, dependencies in declaration order, each once; cycles reported. + public static ClosureResult DependencyClosure(Entry root, VariantSelection rootSelection, IReadOnlyDictionary byId, string platform, string architecture) + { + var nodes = new List(); + var done = new HashSet(StringComparer.Ordinal); + var stack = new List(); + ClosureResult? failure = null; + + void Visit(Entry entry, VariantSelection selection) + { + if (failure is not null) + { + return; + } + + if (stack.Contains(entry.EntryId)) + { + failure = new ClosureResult(null, "cycle", string.Join(" -> ", stack.Append(entry.EntryId))); + return; + } + + if (done.Contains(entry.EntryId)) + { + return; + } + + stack.Add(entry.EntryId); + nodes.Add(new ClosureNode(entry, selection.Variant, selection.Exact)); + foreach (var dependency in selection.Variant.DependencyList) + { + if (!byId.TryGetValue(dependency.EntryId, out var target)) + { + failure = new ClosureResult(null, "missing-entry", $"{entry.EntryId} -> {dependency.EntryId}"); + return; + } + + var selected = SelectVariant(target, platform, architecture); + if (selected is null) + { + failure = new ClosureResult(null, "unsupported-dependency", $"{entry.EntryId} -> {dependency.EntryId} has no {platform}/{architecture} variant"); + return; + } + + Visit(target, selected); + if (failure is not null) + { + return; + } + } + + stack.RemoveAt(stack.Count - 1); + done.Add(entry.EntryId); + } + + Visit(root, rootSelection); + return failure ?? new ClosureResult(nodes, null, null); + } + + /// v1 composition limits (design §4.5); a violation description or null. + public static string? CompositionViolation(IReadOnlyList nodes) + { + var versions = new List(); + foreach (var node in nodes) + { + if (!versions.Contains(node.Variant.PolicyVersion)) + { + versions.Add(node.Variant.PolicyVersion); + } + } + + if (versions.Count > 1) + { + versions.Sort(string.CompareOrdinal); + return $"mixed sandboxPolicy.version values ({string.Join(", ", versions)})"; + } + + if (nodes.Count < 2) + { + return null; + } + + foreach (var node in nodes) + { + foreach (var key in node.Variant.Policy.Keys) + { + if (key != "version" && key != "filesystem") + { + return $"'{node.Entry.EntryId}' uses '{key}', which has no v1 cross-entry composition rule"; + } + } + } + + return null; + } + + // ----------------------------------------------------------------------- + // Revision-level validation + // ----------------------------------------------------------------------- + + public static CatalogRevisionData ValidateRevision(JsonValue raw, CatalogContract contract) + { + if (raw is not JsonObject obj) + { + throw Fail("catalog root must be an object"); + } + + OnlyFields(obj, new[] { "catalogSchemaVersion", "catalogRevision", "entries" }, "catalog"); + if (!IsString(obj.Get("catalogSchemaVersion"), contract.SchemaVersion)) + { + throw Fail($"catalog.catalogSchemaVersion must be '{contract.SchemaVersion}'"); + } + + var catalogRevision = NonEmptyString(obj.Get("catalogRevision"), "catalog.catalogRevision"); + if (!IsRevisionId(catalogRevision)) + { + throw Fail($"catalog.catalogRevision '{catalogRevision}' must match YYYY-MM-DD.N"); + } + + if (obj.Get("entries") is not JsonArray rawEntries) + { + throw Fail("catalog.entries must be an array"); + } + + var entries = rawEntries.Items.Select((entry, index) => ValidateEntry(entry, $"entries[{index}]", contract)).ToList(); + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var entry in entries) + { + if (byId.ContainsKey(entry.EntryId)) + { + throw Fail($"duplicate entryId '{entry.EntryId}'"); + } + + byId[entry.EntryId] = entry; + } + + foreach (var entry in entries) + { + foreach (var variant in entry.Variants) + { + foreach (var dependency in variant.DependencyList) + { + if (!byId.ContainsKey(dependency.EntryId)) + { + throw Fail($"'{entry.EntryId}' depends on unknown entry '{dependency.EntryId}' in this revision"); + } + + if (dependency.EntryId == entry.EntryId) + { + throw Fail($"'{entry.EntryId}' depends on itself"); + } + } + } + + foreach (var platform in CatalogPlatforms.All) + { + foreach (var architecture in CatalogArchitectures.All) + { + var selected = SelectVariant(entry, platform, architecture); + if (selected is null) + { + continue; + } + + var closure = DependencyClosure(entry, selected, byId, platform, architecture); + if (!closure.Ok) + { + throw Fail($"'{entry.EntryId}' on {platform}/{architecture}: {closure.Reason} ({closure.Detail})"); + } + + var violation = CompositionViolation(closure.Nodes!); + if (violation is not null) + { + throw Fail($"'{entry.EntryId}' on {platform}/{architecture}: {violation}"); + } + + var classes = new Dictionary>(StringComparer.Ordinal); + foreach (var field in ComposableFields) + { + classes[field] = closure.Nodes!.SelectMany(node => node.Variant.Paths(field)).ToList(); + } + + var overlap = FindCrossClassOverlap(classes, platform); + if (overlap is not null) + { + throw Fail($"'{entry.EntryId}' on {platform}/{architecture}: {overlap}"); + } + } + } + } + + return new CatalogRevisionData(contract.SchemaVersion, catalogRevision, entries); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs new file mode 100644 index 000000000..76cf39977 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// ECMAScript Number::toString for IEEE doubles. +internal static class JsNumber +{ + /// String(value) in JavaScript. + public static string ToString(double value) + { + if (double.IsNaN(value)) + { + return "NaN"; + } + + if (double.IsPositiveInfinity(value)) + { + return "Infinity"; + } + + if (double.IsNegativeInfinity(value)) + { + return "-Infinity"; + } + + if (value == 0) + { + return "0"; + } + + var negative = value < 0; + // "R" yields the shortest round-trip digits on .NET Core 3.0 and later. + var text = Math.Abs(value).ToString("R", CultureInfo.InvariantCulture); + var (digits, n) = Decompose(text); + var k = digits.Length; + var builder = new StringBuilder(); + if (negative) + { + builder.Append('-'); + } + + if (k <= n && n <= 21) + { + builder.Append(digits).Append('0', n - k); + } + else if (0 < n && n <= 21) + { + builder.Append(digits, 0, n).Append('.').Append(digits, n, k - n); + } + else if (-6 < n && n <= 0) + { + builder.Append("0.").Append('0', -n).Append(digits); + } + else + { + var exponent = n - 1; + builder.Append(digits[0]); + if (k > 1) + { + builder.Append('.').Append(digits, 1, k - 1); + } + + builder.Append('e').Append(exponent >= 0 ? '+' : '-').Append(Math.Abs(exponent).ToString(CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + + /// JSON.stringify(value) for a number: non-finite values are null. + public static string ToJson(double value) => double.IsFinite(value) ? ToString(value) : "null"; + + /// + /// Splits a positive .NET round-trip string into significant digits (no + /// leading/trailing zeros) and the decimal exponent n such that the + /// value is 0.digits × 10^n. + /// + private static (string Digits, int N) Decompose(string text) + { + var exponent = 0; + var e = text.IndexOfAny(new[] { 'E', 'e' }); + var mantissa = text; + if (e >= 0) + { + exponent = int.Parse(text.AsSpan(e + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + mantissa = text.Substring(0, e); + } + + var dot = mantissa.IndexOf('.'); + var integerPart = dot >= 0 ? mantissa.Substring(0, dot) : mantissa; + var fractionPart = dot >= 0 ? mantissa.Substring(dot + 1) : string.Empty; + var all = integerPart + fractionPart; + var pointPosition = integerPart.Length + exponent; + var leading = 0; + while (leading < all.Length - 1 && all[leading] == '0') + { + leading++; + } + + all = all.Substring(leading); + pointPosition -= leading; + all = all.TrimEnd('0'); + if (all.Length == 0) + { + all = "0"; + } + + return (all, pointPosition); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs new file mode 100644 index 000000000..258cefa04 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// JavaScript string semantics the TypeScript reference relies on. +internal static class JsString +{ + /// JavaScript \s / String.prototype.trim whitespace (WhiteSpace + LineTerminator). + public const string WhitespaceClass = "[\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]"; + + /// JavaScript . without the s flag: anything except a line terminator. + public const string AnyButLineTerminator = "[^\\n\\r\\u2028\\u2029]"; + + public static bool IsWhitespace(char c) => c switch + { + '\t' or '\n' or '\v' or '\f' or '\r' or ' ' or '\u00a0' or '\u1680' or '\u2028' or '\u2029' or '\u202f' or '\u205f' or '\u3000' or '\ufeff' => true, + _ => c >= '\u2000' && c <= '\u200a', + }; + + /// String.prototype.trim. + public static string Trim(string value) + { + var start = 0; + var end = value.Length; + while (start < end && IsWhitespace(value[start])) + { + start++; + } + + while (end > start && IsWhitespace(value[end - 1])) + { + end--; + } + + return value.Substring(start, end - start); + } + + // Code points whose lower-case mapping is newer than the Unicode data in + // .NET 8's invariant casing (Unicode 16 additions), taken from V8/ICU 78. + private static readonly Dictionary NewerLowerCase = BuildNewerLowerCase(); + + private static Dictionary BuildNewerLowerCase() + { + var map = new Dictionary + { + [0x1C89] = 0x1C8A, + [0xA7CB] = 0x0264, + [0xA7CC] = 0xA7CD, + [0xA7CE] = 0xA7CF, + [0xA7D2] = 0xA7D3, + [0xA7D4] = 0xA7D5, + [0xA7DA] = 0xA7DB, + [0xA7DC] = 0x019B, + }; + for (var cp = 0x10D50; cp <= 0x10D65; cp++) + { + map[cp] = cp + 0x20; + } + + for (var cp = 0x16EA0; cp <= 0x16EB8; cp++) + { + map[cp] = cp + 0x1B; + } + + return map; + } + + /// + /// String.prototype.toLowerCase: Unicode default (locale-independent) + /// lower-casing, including the unconditional U+0130 expansion and the + /// context-sensitive Greek final sigma, which invariant lower-casing omits. + /// + public static string ToLower(string value) + { + var simple = value.ToLowerInvariant(); + var needsFixup = false; + foreach (var c in value) + { + if (c == '\u0130' || c == '\u03a3' || char.IsSurrogate(c) || c == '\u1c89' || (c >= '\ua7cb' && c <= '\ua7dc')) + { + needsFixup = true; + break; + } + } + + if (!needsFixup) + { + return simple; + } + + var builder = new StringBuilder(value.Length + 4); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == '\u0130') + { + builder.Append("i\u0307"); + continue; + } + + if (c == '\u03a3') + { + builder.Append(IsFinalSigma(value, i) ? '\u03c2' : '\u03c3'); + continue; + } + + int cp = c; + var width = 1; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + cp = char.ConvertToUtf32(c, value[i + 1]); + width = 2; + } + + if (NewerLowerCase.TryGetValue(cp, out var lower)) + { + builder.Append(char.ConvertFromUtf32(lower)); + } + else + { + builder.Append(value.Substring(i, width).ToLowerInvariant()); + } + + i += width - 1; + } + + return builder.ToString(); + } + + // Unicode Final_Sigma: preceded by a cased letter (skipping case-ignorables) + // and not followed by one. + private static bool IsFinalSigma(string value, int index) + { + var before = false; + for (var i = index - 1; i >= 0; i--) + { + var (cp, start) = CodePointBefore(value, i); + i = start; + if (IsCaseIgnorable(cp)) + { + continue; + } + + before = IsCased(cp); + break; + } + + if (!before) + { + return false; + } + + for (var i = index + 1; i < value.Length; i++) + { + var cp = CodePointAt(value, i, out var width); + i += width - 1; + if (IsCaseIgnorable(cp)) + { + continue; + } + + return !IsCased(cp); + } + + return true; + } + + private static (int CodePoint, int Start) CodePointBefore(string value, int index) + { + if (char.IsLowSurrogate(value[index]) && index > 0 && char.IsHighSurrogate(value[index - 1])) + { + return (char.ConvertToUtf32(value[index - 1], value[index]), index - 1); + } + + return (value[index], index); + } + + private static int CodePointAt(string value, int index, out int width) + { + if (char.IsHighSurrogate(value[index]) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1])) + { + width = 2; + return char.ConvertToUtf32(value[index], value[index + 1]); + } + + width = 1; + return value[index]; + } + + private static bool IsCased(int cp) + { + var category = CharUnicodeInfo.GetUnicodeCategory(cp); + return category is UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter + || (cp >= 0x02B0 && cp <= 0x02B8) || (cp >= 0x02C0 && cp <= 0x02C1) || (cp >= 0x02E0 && cp <= 0x02E4) + || cp == 0x0345 || cp == 0x037A || (cp >= 0x2160 && cp <= 0x217F) || (cp >= 0x24B6 && cp <= 0x24E9); + } + + private static bool IsCaseIgnorable(int cp) + { + var category = CharUnicodeInfo.GetUnicodeCategory(cp); + return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format + or UnicodeCategory.ModifierLetter or UnicodeCategory.ModifierSymbol + || cp is 0x0027 or 0x002E or 0x003A or 0x005E or 0x0060 or 0x00A8 or 0x00AD or 0x00AF or 0x00B4 or 0x00B7 or 0x00B8 + or 0x2018 or 0x2019 or 0x2024 or 0x2027 or 0xFE13 or 0xFE52 or 0xFE55 or 0xFF07 or 0xFF0E or 0xFF1A; + } + + /// JavaScript < on strings: UTF-16 code unit order. + public static int Compare(string left, string right) => Math.Sign(string.CompareOrdinal(left, right)); +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs new file mode 100644 index 000000000..0e3ef52b8 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// +/// A JSON value with JavaScript semantics: every number is an IEEE double, and +/// object keys follow ECMAScript own-property order (array-index keys ascending, +/// then the remaining keys in insertion order; a repeated key keeps its first +/// position and takes the last value, as JSON.parse does). +/// +internal abstract class JsonValue +{ +} + +internal sealed class JsonNull : JsonValue +{ + public static readonly JsonNull Instance = new(); + + private JsonNull() + { + } +} + +internal sealed class JsonBool : JsonValue +{ + public static readonly JsonBool True = new(true); + public static readonly JsonBool False = new(false); + + private JsonBool(bool value) => Value = value; + + public bool Value { get; } + + public static JsonBool Of(bool value) => value ? True : False; +} + +internal sealed class JsonNumber : JsonValue +{ + public JsonNumber(double value) => Value = value; + + public double Value { get; } +} + +internal sealed class JsonString : JsonValue +{ + public JsonString(string value) => Value = value; + + public string Value { get; } +} + +internal sealed class JsonArray : JsonValue +{ + public JsonArray() + { + Items = new List(); + } + + public JsonArray(IEnumerable items) + { + Items = new List(items); + } + + public List Items { get; } + + public int Count => Items.Count; + + public JsonValue this[int index] => Items[index]; +} + +internal sealed class JsonObject : JsonValue +{ + private readonly List _insertion = new(); + private readonly Dictionary _values = new(StringComparer.Ordinal); + + /// Own keys in ECMAScript OrdinaryOwnPropertyKeys order. + public IReadOnlyList Keys + { + get + { + var indices = new List<(uint Index, string Key)>(); + var others = new List(); + foreach (var key in _insertion) + { + if (TryArrayIndex(key, out var index)) + { + indices.Add((index, key)); + } + else + { + others.Add(key); + } + } + + if (indices.Count == 0) + { + return others; + } + + indices.Sort((a, b) => a.Index.CompareTo(b.Index)); + var ordered = new List(_insertion.Count); + ordered.AddRange(indices.Select(i => i.Key)); + ordered.AddRange(others); + return ordered; + } + } + + public int Count => _insertion.Count; + + public bool Has(string key) => _values.ContainsKey(key); + + /// The own property value, or null when absent (JavaScript undefined). + public JsonValue? Get(string key) => _values.TryGetValue(key, out var value) ? value : null; + + public void Set(string key, JsonValue value) + { + if (!_values.ContainsKey(key)) + { + _insertion.Add(key); + } + + _values[key] = value; + } + + public JsonObject With(string key, JsonValue? value) + { + if (value is not null) + { + Set(key, value); + } + + return this; + } + + internal static bool TryArrayIndex(string key, out uint index) + { + index = 0; + if (key.Length == 0 || key.Length > 10) + { + return false; + } + + if (key.Length > 1 && key[0] == '0') + { + return false; + } + + foreach (var c in key) + { + if (c < '0' || c > '9') + { + return false; + } + } + + if (!ulong.TryParse(key, NumberStyles.None, CultureInfo.InvariantCulture, out var value) || value > 4294967294UL) + { + return false; + } + + index = (uint)value; + return true; + } +} + +/// Parsing and JSON.stringify-compatible serialization. +internal static class JsonText +{ + private static readonly JsonReaderOptions ReaderOptions = new() + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 4096, + }; + + /// Decodes UTF-8 bytes the way Node's readFileSync(..., 'utf8') does (a BOM is kept; invalid bytes become U+FFFD). + public static string DecodeUtf8(byte[] bytes) => Encoding.UTF8.GetString(bytes); + + /// Parses JSON text like JSON.parse. Throws on invalid input. + public static JsonValue Parse(string text) + { + // Re-encoding a .NET string can only produce well-formed UTF-8; lone + // surrogates in the text itself are replaced, which is also invalid JSON + // outside a string and rare inside one. + var bytes = Encoding.UTF8.GetBytes(text); + var reader = new Utf8JsonReader(bytes, ReaderOptions); + if (!reader.Read()) + { + throw new JsonException("Unexpected end of JSON input"); + } + + var value = ReadValue(ref reader); + if (reader.Read()) + { + throw new JsonException($"Unexpected non-whitespace character after JSON at byte {reader.TokenStartIndex}"); + } + + return value; + } + + private static JsonValue ReadValue(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return JsonNull.Instance; + case JsonTokenType.True: + return JsonBool.True; + case JsonTokenType.False: + return JsonBool.False; + case JsonTokenType.Number: + return new JsonNumber(double.Parse(Encoding.UTF8.GetString(reader.ValueSpan), NumberStyles.Float, CultureInfo.InvariantCulture)); + case JsonTokenType.String: + return new JsonString(ReadString(ref reader)); + case JsonTokenType.StartArray: + { + var array = new JsonArray(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + array.Items.Add(ReadValue(ref reader)); + } + + return array; + } + + case JsonTokenType.StartObject: + { + var obj = new JsonObject(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var key = ReadString(ref reader); + reader.Read(); + obj.Set(key, ReadValue(ref reader)); + } + + return obj; + } + + default: + throw new JsonException($"Unexpected token {reader.TokenType}"); + } + } + + /// Unescapes a string token without rejecting lone surrogate escapes (JavaScript strings may hold them). + private static string ReadString(ref Utf8JsonReader reader) + { + var span = reader.ValueSpan; + if (!reader.ValueIsEscaped) + { + return Encoding.UTF8.GetString(span); + } + + var builder = new StringBuilder(span.Length); + var runStart = 0; + var i = 0; + while (i < span.Length) + { + if (span[i] != (byte)'\\') + { + i++; + continue; + } + + builder.Append(Encoding.UTF8.GetString(span.Slice(runStart, i - runStart))); + var escape = (char)span[i + 1]; + switch (escape) + { + case '"': builder.Append('"'); i += 2; break; + case '\\': builder.Append('\\'); i += 2; break; + case '/': builder.Append('/'); i += 2; break; + case 'b': builder.Append('\b'); i += 2; break; + case 'f': builder.Append('\f'); i += 2; break; + case 'n': builder.Append('\n'); i += 2; break; + case 'r': builder.Append('\r'); i += 2; break; + case 't': builder.Append('\t'); i += 2; break; + case 'u': + builder.Append((char)int.Parse(Encoding.ASCII.GetString(span.Slice(i + 2, 4)), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + i += 6; + break; + default: + throw new JsonException($"Bad escaped character '{escape}'"); + } + + runStart = i; + } + + builder.Append(Encoding.UTF8.GetString(span.Slice(runStart))); + return builder.ToString(); + } + + /// JSON.stringify(value) or, with , JSON.stringify(value, null, indent). + public static string Stringify(JsonValue value, int indent = 0) + { + var builder = new StringBuilder(); + Write(builder, value, indent, 0); + return builder.ToString(); + } + + private static void Write(StringBuilder builder, JsonValue value, int indent, int depth) + { + switch (value) + { + case JsonNull: + builder.Append("null"); + break; + case JsonBool b: + builder.Append(b.Value ? "true" : "false"); + break; + case JsonNumber n: + builder.Append(JsNumber.ToJson(n.Value)); + break; + case JsonString s: + Quote(builder, s.Value); + break; + case JsonArray a: + if (a.Count == 0) + { + builder.Append("[]"); + break; + } + + builder.Append('['); + for (var i = 0; i < a.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + NewLine(builder, indent, depth + 1); + Write(builder, a[i], indent, depth + 1); + } + + NewLine(builder, indent, depth); + builder.Append(']'); + break; + case JsonObject o: + var keys = o.Keys; + if (keys.Count == 0) + { + builder.Append("{}"); + break; + } + + builder.Append('{'); + for (var i = 0; i < keys.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + NewLine(builder, indent, depth + 1); + Quote(builder, keys[i]); + builder.Append(indent > 0 ? ": " : ":"); + Write(builder, o.Get(keys[i])!, indent, depth + 1); + } + + NewLine(builder, indent, depth); + builder.Append('}'); + break; + default: + throw new InvalidOperationException("unknown JSON value"); + } + } + + private static void NewLine(StringBuilder builder, int indent, int depth) + { + if (indent > 0) + { + builder.Append('\n'); + builder.Append(' ', indent * depth); + } + } + + /// JSON.stringify(string): ECMAScript QuoteJSONString, including well-formed lone-surrogate escapes. + public static string Quote(string value) + { + var builder = new StringBuilder(value.Length + 2); + Quote(builder, value); + return builder.ToString(); + } + + public static void Quote(StringBuilder builder, string value) + { + builder.Append('"'); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '"': builder.Append("\\\""); continue; + case '\\': builder.Append("\\\\"); continue; + case '\b': builder.Append("\\b"); continue; + case '\f': builder.Append("\\f"); continue; + case '\n': builder.Append("\\n"); continue; + case '\r': builder.Append("\\r"); continue; + case '\t': builder.Append("\\t"); continue; + } + + if (c < 0x20) + { + AppendUnicodeEscape(builder, c); + } + else if (char.IsHighSurrogate(c)) + { + if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + builder.Append(c).Append(value[i + 1]); + i++; + } + else + { + AppendUnicodeEscape(builder, c); + } + } + else if (char.IsLowSurrogate(c)) + { + AppendUnicodeEscape(builder, c); + } + else + { + builder.Append(c); + } + } + + builder.Append('"'); + } + + private static void AppendUnicodeEscape(StringBuilder builder, char c) + { + builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs new file mode 100644 index 000000000..e935d32cd --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.RegularExpressions; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// +/// Ports of Node's path.win32 / path.posix normalize, isAbsolute, and +/// parse().root (node v24.20.0 lib/path.js), plus the catalog's path rules. +/// +internal static class Paths +{ + private static readonly string[] WindowsReservedNames = + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "COM\u00b9", "COM\u00b2", "COM\u00b3", + "LPT\u00b9", "LPT\u00b2", "LPT\u00b3", + }; + + private static readonly Regex TrailingSeparators = new("[\\\\/]+\\z", RegexOptions.CultureInvariant); + private static readonly Regex WindowsSeparators = new("[\\\\/]+", RegexOptions.CultureInvariant); + private static readonly Regex PosixSeparators = new("/+", RegexOptions.CultureInvariant); + + /// JavaScript String.prototype.slice. + internal static string Slice(string value, int start, int? end = null) + { + var length = value.Length; + var from = start < 0 ? Math.Max(length + start, 0) : Math.Min(start, length); + var e = end ?? length; + var to = e < 0 ? Math.Max(length + e, 0) : Math.Min(e, length); + return to <= from ? string.Empty : value.Substring(from, to - from); + } + + private static bool IsPathSeparator(char c) => c == '/' || c == '\\'; + + private static bool IsPosixPathSeparator(char c) => c == '/'; + + private static bool IsWindowsDeviceRoot(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + + private static bool IsWindowsReservedName(string path, int colonIndex) + { + var devicePart = Slice(path, 0, colonIndex).ToUpperInvariant(); + return Array.IndexOf(WindowsReservedNames, devicePart) >= 0; + } + + private static string NormalizeString(string path, bool allowAboveRoot, char separator, Func isPathSeparator) + { + var res = string.Empty; + var lastSegmentLength = 0; + var lastSlash = -1; + var dots = 0; + var code = '\0'; + for (var i = 0; i <= path.Length; ++i) + { + if (i < path.Length) + { + code = path[i]; + } + else if (isPathSeparator(code)) + { + break; + } + else + { + code = '/'; + } + + if (isPathSeparator(code)) + { + if (lastSlash == i - 1 || dots == 1) + { + // NOOP + } + else if (dots == 2) + { + if (res.Length < 2 || lastSegmentLength != 2 || res[res.Length - 1] != '.' || res[res.Length - 2] != '.') + { + if (res.Length > 2) + { + var lastSlashIndex = res.Length - lastSegmentLength - 1; + if (lastSlashIndex == -1) + { + res = string.Empty; + lastSegmentLength = 0; + } + else + { + res = Slice(res, 0, lastSlashIndex); + lastSegmentLength = res.Length - 1 - res.LastIndexOf(separator); + } + + lastSlash = i; + dots = 0; + continue; + } + else if (res.Length != 0) + { + res = string.Empty; + lastSegmentLength = 0; + lastSlash = i; + dots = 0; + continue; + } + } + + if (allowAboveRoot) + { + res += res.Length > 0 ? $"{separator}.." : ".."; + lastSegmentLength = 2; + } + } + else + { + if (res.Length > 0) + { + res += separator + Slice(path, lastSlash + 1, i); + } + else + { + res = Slice(path, lastSlash + 1, i); + } + + lastSegmentLength = i - lastSlash - 1; + } + + lastSlash = i; + dots = 0; + } + else if (code == '.' && dots != -1) + { + ++dots; + } + else + { + dots = -1; + } + } + + return res; + } + + /// Node path.win32.normalize. + public static string Win32Normalize(string path) + { + var len = path.Length; + if (len == 0) + { + return "."; + } + + var rootEnd = 0; + string? device = null; + var isAbsolute = false; + var code = path[0]; + + if (len == 1) + { + return IsPosixPathSeparator(code) ? "\\" : path; + } + + if (IsPathSeparator(code)) + { + isAbsolute = true; + if (IsPathSeparator(path[1])) + { + var j = 2; + var last = j; + while (j < len && !IsPathSeparator(path[j])) + { + j++; + } + + if (j < len && j != last) + { + var firstPart = Slice(path, last, j); + last = j; + while (j < len && IsPathSeparator(path[j])) + { + j++; + } + + if (j < len && j != last) + { + last = j; + while (j < len && !IsPathSeparator(path[j])) + { + j++; + } + + if (j == len || j != last) + { + if (firstPart == "." || firstPart == "?") + { + device = $"\\\\{firstPart}"; + rootEnd = 4; + var colon = path.IndexOf(':'); + var possibleDevice = Slice(path, 4, colon + 1); + if (IsWindowsReservedName(possibleDevice, possibleDevice.Length - 1)) + { + device = $"\\\\?\\{possibleDevice}"; + rootEnd = 4 + possibleDevice.Length; + } + } + else if (j == len) + { + return $"\\\\{firstPart}\\{Slice(path, last)}\\"; + } + else + { + device = $"\\\\{firstPart}\\{Slice(path, last, j)}"; + rootEnd = j; + } + } + } + } + } + else + { + rootEnd = 1; + } + } + else + { + var colon = path.IndexOf(':'); + if (colon > 0) + { + if (IsWindowsDeviceRoot(code) && colon == 1) + { + device = Slice(path, 0, 2); + rootEnd = 2; + if (len > 2 && IsPathSeparator(path[2])) + { + isAbsolute = true; + rootEnd = 3; + } + } + else if (IsWindowsReservedName(path, colon)) + { + device = Slice(path, 0, colon + 1); + rootEnd = colon + 1; + } + } + } + + var tail = rootEnd < len ? NormalizeString(Slice(path, rootEnd), !isAbsolute, '\\', IsPathSeparator) : string.Empty; + if (tail.Length == 0 && !isAbsolute) + { + tail = "."; + } + + if (tail.Length > 0 && IsPathSeparator(path[len - 1])) + { + tail += "\\"; + } + + if (!isAbsolute && device is null && path.Contains(':')) + { + if (tail.Length >= 2 && IsWindowsDeviceRoot(tail[0]) && tail[1] == ':') + { + return $".\\{tail}"; + } + + var index = path.IndexOf(':'); + do + { + if (index == len - 1 || IsPathSeparator(path[index + 1])) + { + return $".\\{tail}"; + } + } + while ((index = path.IndexOf(':', index + 1)) != -1); + } + + var colonIndex = path.IndexOf(':'); + if (IsWindowsReservedName(path, colonIndex)) + { + return $".\\{device ?? string.Empty}{tail}"; + } + + if (device is null) + { + return isAbsolute ? $"\\{tail}" : tail; + } + + return isAbsolute ? $"{device}\\{tail}" : $"{device}{tail}"; + } + + /// Node path.win32.isAbsolute. + public static bool Win32IsAbsolute(string path) + { + var len = path.Length; + if (len == 0) + { + return false; + } + + var code = path[0]; + return IsPathSeparator(code) || (len > 2 && IsWindowsDeviceRoot(code) && path[1] == ':' && IsPathSeparator(path[2])); + } + + /// Node path.win32.parse(path).root. + public static string Win32Root(string path) + { + if (path.Length == 0) + { + return string.Empty; + } + + var len = path.Length; + var rootEnd = 0; + var code = path[0]; + if (len == 1) + { + return IsPathSeparator(code) ? path : string.Empty; + } + + if (IsPathSeparator(code)) + { + rootEnd = 1; + if (IsPathSeparator(path[1])) + { + var j = 2; + var last = j; + while (j < len && !IsPathSeparator(path[j])) + { + j++; + } + + if (j < len && j != last) + { + last = j; + while (j < len && IsPathSeparator(path[j])) + { + j++; + } + + if (j < len && j != last) + { + last = j; + while (j < len && !IsPathSeparator(path[j])) + { + j++; + } + + if (j == len) + { + rootEnd = j; + } + else if (j != last) + { + rootEnd = j + 1; + } + } + } + } + } + else if (IsWindowsDeviceRoot(code) && path[1] == ':') + { + if (len <= 2) + { + return path; + } + + rootEnd = 2; + if (IsPathSeparator(path[2])) + { + if (len == 3) + { + return path; + } + + rootEnd = 3; + } + } + + return rootEnd > 0 ? Slice(path, 0, rootEnd) : string.Empty; + } + + /// Node path.posix.normalize. + public static string PosixNormalize(string path) + { + if (path.Length == 0) + { + return "."; + } + + var isAbsolute = path[0] == '/'; + var trailingSeparator = path[path.Length - 1] == '/'; + path = NormalizeString(path, !isAbsolute, '/', IsPosixPathSeparator); + if (path.Length == 0) + { + if (isAbsolute) + { + return "/"; + } + + return trailingSeparator ? "./" : "."; + } + + if (trailingSeparator) + { + path += "/"; + } + + return isAbsolute ? $"/{path}" : path; + } + + /// Node path.posix.isAbsolute. + public static bool PosixIsAbsolute(string path) => path.Length > 0 && path[0] == '/'; + + /// Node path.posix.parse(path).root. + public static string PosixRoot(string path) => path.Length > 0 && path[0] == '/' ? "/" : string.Empty; + + /// The one casing rule: Windows and macOS fold case, Linux is exact. + public static bool FoldsCase(string platform) => platform != CatalogPlatforms.Linux; + + /// Applies to one value. + public static string CaseKey(string value, string platform) => FoldsCase(platform) ? JsString.ToLower(value) : value; + + /// Catalog normalizePath: normalize, then drop trailing separators unless only the root remains. + public static string NormalizePath(string value, string platform) + { + var windows = platform == CatalogPlatforms.Windows; + var normalized = windows ? Win32Normalize(value) : PosixNormalize(value); + var root = windows ? Win32Root(normalized) : PosixRoot(normalized); + return normalized.Length > root.Length ? TrailingSeparators.Replace(normalized, string.Empty) : normalized; + } + + /// Catalog isAbsolutePath. + public static bool IsAbsolutePath(string value, string platform) => + platform == CatalogPlatforms.Windows ? Win32IsAbsolute(value) : PosixIsAbsolute(value); + + /// Catalog pathKeySegments. + public static List PathKeySegments(string value, string platform) + { + var separators = platform == CatalogPlatforms.Windows ? WindowsSeparators : PosixSeparators; + var raw = separators.Split(value); + var segments = new List(); + for (var index = 0; index < raw.Length; index++) + { + var segment = raw[index]; + if (segment != "." && (segment.Length != 0 || index == 0)) + { + segments.Add(segment); + } + } + + if (segments.Count > 1 && segments[segments.Count - 1].Length == 0) + { + segments.RemoveAt(segments.Count - 1); + } + + return segments.Select(segment => CaseKey(segment, platform)).ToList(); + } + + /// Joins key segments into one comparison key (TypeScript joins with U+0000). + public static string JoinKey(IEnumerable segments) + { + var builder = new StringBuilder(); + var first = true; + foreach (var segment in segments) + { + if (!first) + { + builder.Append('\0'); + } + + builder.Append(segment); + first = false; + } + + return builder.ToString(); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs new file mode 100644 index 000000000..02ec62f01 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.RegularExpressions; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// Parsed package URL, reduced to the parts identity matching uses (TypeScript purl.ts). +internal sealed record ParsedPurl(string Key, string? Version); + +internal static class Purl +{ + private static readonly Regex TypePattern = new("^[a-zA-Z][a-zA-Z0-9.+-]*\\z", RegexOptions.CultureInvariant); + + /// Parses pkg:type/namespace/name@version?qualifiers#subpath; null when invalid. + public static ParsedPurl? Parse(string value) + { + if (!value.StartsWith("pkg:", StringComparison.Ordinal)) + { + return null; + } + + var rest = value.Substring("pkg:".Length); + var hash = rest.IndexOf('#'); + if (hash >= 0) + { + rest = rest.Substring(0, hash); + } + + var query = rest.IndexOf('?'); + if (query >= 0) + { + rest = rest.Substring(0, query); + } + + var lastSlash = rest.LastIndexOf('/'); + var at = rest.LastIndexOf('@'); + string? version = null; + if (at > lastSlash) + { + version = DecodeUriComponent(rest.Substring(at + 1)); + if (version is null) + { + return null; + } + + rest = rest.Substring(0, at); + } + + var segments = rest.Split('/'); + if (segments.Length < 2 || segments.Any(segment => segment.Length == 0)) + { + return null; + } + + var type = segments[0]; + if (!TypePattern.IsMatch(type)) + { + return null; + } + + return new ParsedPurl( + $"{type.ToLowerInvariant()}/{string.Join("/", segments.Skip(1))}", + version is not null && version.Length > 0 ? version : null); + } + + /// ECMAScript decodeURIComponent; null where JavaScript throws URIError. + public static string? DecodeUriComponent(string value) + { + if (value.IndexOf('%') < 0) + { + return value; + } + + var builder = new StringBuilder(value.Length); + var i = 0; + while (i < value.Length) + { + var c = value[i]; + if (c != '%') + { + builder.Append(c); + i++; + continue; + } + + if (!TryHexByte(value, i, out var first)) + { + return null; + } + + i += 3; + if (first < 0x80) + { + builder.Append((char)first); + continue; + } + + int count; + int codePoint; + if ((first & 0xE0) == 0xC0) + { + count = 1; + codePoint = first & 0x1F; + } + else if ((first & 0xF0) == 0xE0) + { + count = 2; + codePoint = first & 0x0F; + } + else if ((first & 0xF8) == 0xF0) + { + count = 3; + codePoint = first & 0x07; + } + else + { + return null; + } + + for (var k = 0; k < count; k++) + { + if (i >= value.Length || value[i] != '%' || !TryHexByte(value, i, out var next) || (next & 0xC0) != 0x80) + { + return null; + } + + codePoint = (codePoint << 6) | (next & 0x3F); + i += 3; + } + + var minimum = count switch { 1 => 0x80, 2 => 0x800, _ => 0x10000 }; + if (codePoint < minimum || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) + { + return null; + } + + builder.Append(char.ConvertFromUtf32(codePoint)); + } + + return builder.ToString(); + } + + private static bool TryHexByte(string value, int percent, out int result) + { + result = 0; + if (percent + 2 >= value.Length) + { + return false; + } + + var high = HexValue(value[percent + 1]); + var low = HexValue(value[percent + 2]); + if (high < 0 || low < 0) + { + return false; + } + + result = (high << 4) | low; + return true; + } + + private static int HexValue(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => -1, + }; +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs new file mode 100644 index 000000000..483721705 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Microsoft.Mxc.PolicyCatalog.Internal; + +/// +/// Minimal version-range support for catalog v1 (TypeScript version-range.ts). +/// range := set ("||" set)*, set := comparator (" " comparator)*, +/// comparator := op? N(.N(.N)?)?. No operator is a prefix match. +/// Prerelease/build metadata on evidence versions is ignored. +/// +internal static class VersionRange +{ + private enum Op + { + GreaterOrEqual, + LessOrEqual, + Greater, + Less, + Equal, + Prefix, + } + + private sealed record Comparator(Op Op, double[] Version, int Parts); + + private static readonly Regex ComparatorPattern = new("^(>=|<=|>|<|=)?([0-9]+)(?:\\.([0-9]+)(?:\\.([0-9]+))?)?\\z", RegexOptions.CultureInvariant); + + private static readonly Regex EvidencePattern = new( + "^v?([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(?:[-+]" + JsString.AnyButLineTerminator + "*)?\\z", + RegexOptions.CultureInvariant); + + private static readonly Regex Whitespace = new(JsString.WhitespaceClass + "+", RegexOptions.CultureInvariant); + + private static double Num(Group group) => group.Success ? double.Parse(group.Value, NumberStyles.None, CultureInfo.InvariantCulture) : 0; + + private static Comparator? ParseComparator(string token) + { + var match = ComparatorPattern.Match(token); + if (!match.Success) + { + return null; + } + + var parts = match.Groups[4].Success ? 3 : match.Groups[3].Success ? 2 : 1; + var op = match.Groups[1].Success + ? match.Groups[1].Value switch + { + ">=" => Op.GreaterOrEqual, + "<=" => Op.LessOrEqual, + ">" => Op.Greater, + "<" => Op.Less, + _ => Op.Equal, + } + : Op.Prefix; + return new Comparator(op, new[] { Num(match.Groups[2]), Num(match.Groups[3]), Num(match.Groups[4]) }, parts); + } + + private static List>? ParseRange(string range) + { + if (JsString.Trim(range).Length == 0) + { + return null; + } + + var sets = new List>(); + foreach (var alternative in range.Split("||")) + { + var tokens = Whitespace.Split(JsString.Trim(alternative)).Where(token => token.Length > 0).ToList(); + if (tokens.Count == 0) + { + return null; + } + + var comparators = new List(); + foreach (var token in tokens) + { + var comparator = ParseComparator(token); + if (comparator is null) + { + return null; + } + + comparators.Add(comparator); + } + + sets.Add(comparators); + } + + return sets; + } + + /// True when uses the supported v1 range grammar. + public static bool IsValid(string range) => ParseRange(range) is not null; + + /// Parses tool version evidence such as v22.3.1 or 10.9.0-rc.1. + public static double[]? ParseEvidence(string value) + { + var match = EvidencePattern.Match(JsString.Trim(value)); + if (!match.Success) + { + return null; + } + + return new[] { Num(match.Groups[1]), Num(match.Groups[2]), Num(match.Groups[3]) }; + } + + private static int Compare(double[] left, double[] right) + { + for (var i = 0; i < 3; i++) + { + if (left[i] != right[i]) + { + return left[i] < right[i] ? -1 : 1; + } + } + + return 0; + } + + private static bool Satisfies(double[] version, Comparator comparator) + { + var order = Compare(version, comparator.Version); + return comparator.Op switch + { + Op.GreaterOrEqual => order >= 0, + Op.LessOrEqual => order <= 0, + Op.Greater => order > 0, + Op.Less => order < 0, + Op.Equal => order == 0, + _ => Enumerable.Range(0, comparator.Parts).All(i => version[i] == comparator.Version[i]), + }; + } + + /// Evaluates against ; null when either cannot be evaluated. + public static bool? Satisfies(string version, string range) + { + var parsedVersion = ParseEvidence(version); + var parsedRange = ParseRange(range); + if (parsedVersion is null || parsedRange is null) + { + return null; + } + + return parsedRange.Any(set => set.All(comparator => Satisfies(parsedVersion, comparator))); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj new file mode 100644 index 000000000..ba4c77930 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj @@ -0,0 +1,46 @@ + + + + net8.0 + enable + enable + latest + true + true + Microsoft.Mxc.PolicyCatalog + Microsoft.Mxc.PolicyCatalog + Microsoft.Mxc.PolicyCatalog + 0.0.0-prototype + Microsoft + PROTOTYPE - not supported for production use. Native .NET implementation of the MXC policy catalog: resolves tool inputs to a candidate lower-bound SandboxPolicy from an integrity-validated, bundled catalog. + MIT + true + + + + + + + + + + + + + + diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs new file mode 100644 index 000000000..03d028f36 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs @@ -0,0 +1,579 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using Microsoft.Mxc.PolicyCatalog.Internal; + +namespace Microsoft.Mxc.PolicyCatalog; + +/// +/// Read-only view over one catalog store. Runtime resolution and inspection are separate operations; +/// neither mutates catalog data or any consumer state. +/// +public sealed class PolicyCatalog +{ + private readonly CatalogStore _store; + private readonly IHostEnvironment _host; + + /// Creates a catalog over . + /// The catalog store. + /// Host facts; defaults to . + public PolicyCatalog(CatalogStore store, IHostEnvironment? host = null) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _host = host ?? SystemHostEnvironment.Instance; + } + + /// The store this catalog reads. + public CatalogStore Store => _store; + + /// The installed default revision's schema version and ID (design §5.2). + /// The catalog info. + /// The revision fails integrity or validation. + public CatalogInfo GetCatalogInfo() + { + var revision = _store.Revision(); + return new CatalogInfo(revision.SchemaVersion, revision.CatalogRevision); + } + + /// Metadata for every entry in the installed revision, ordered by entry ID. Never includes a policy body. + /// Entry metadata. + /// The revision fails integrity or validation. + public IReadOnlyList ListCatalogEntries() + { + var revision = _store.Revision(); + return revision.Entries + .OrderBy(entry => entry.EntryId, StringComparer.Ordinal) + .Select(entry => new CatalogEntryMetadata( + revision.CatalogRevision, + entry.EntryId, + entry.EntryRevision, + entry.DisplayName, + entry.Identity.Select(predicate => predicate switch + { + PurlPredicate purl => new CatalogIdentityMetadata("purl") { Value = purl.Value, VersionRange = purl.VersionRange }, + NamePredicate names => new CatalogIdentityMetadata("invocation-name") { Names = names.Names.ToList() }, + _ => throw new InvalidOperationException(), + }).ToList(), + entry.Variants.Select(variant => new CatalogVariantMetadata( + variant.Platform, + variant.Architecture, + variant.DependencyList.Select(dependency => dependency.EntryId).ToList(), + variant.PolicyVersion)).ToList(), + new CatalogProvenance(entry.Method, entry.SourceRevision))) + .ToList(); + } + + /// The composed candidate policy for several tools, or null when no policy can be resolved. + /// Tool inputs, resolved in one pass. + /// Lookup context; null means all defaults. + /// The policy or null. + /// A library failure; never reported as absence. + public CatalogSandboxPolicy? GetSandboxConfig(IReadOnlyList tools, ResolveContext? context = null) => + Resolve(tools, context).Policy; + + /// The composed candidate policy for one tool; exactly a one-element list. + /// The tool input (a string converts implicitly). + /// Lookup context. + /// The policy or null. + /// A library failure. + public CatalogSandboxPolicy? GetSandboxConfig(ToolInput tool, ResolveContext? context = null) => + Resolve(new[] { tool }, context).Policy; + + /// + /// Resolves several tools in one pass and returns the composed candidate policy with attribution and warnings. + /// is null for an empty input, when nothing matched, or + /// when a required symbol is unresolved. The result is a candidate lower bound, not authorization. + /// + /// Tool inputs. + /// Lookup context. + /// The resolution. + /// A library failure. + public SandboxConfigResolution GetSandboxConfigWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => + Resolve(tools, context); + + /// Resolves one tool; exactly a one-element list. + /// The tool input. + /// Lookup context. + /// The resolution. + /// A library failure. + public SandboxConfigResolution GetSandboxConfigWithDiagnostics(ToolInput tool, ResolveContext? context = null) => + Resolve(new[] { tool }, context); + + private sealed record EntryMatch(Entry Entry, VariantSelection Selection, List Satisfied); + + private static PolicyCatalogException InvalidContext(string message) => new(PolicyCatalogErrorReason.InvalidContext, message); + + private static ToolInput ToCandidate(ToolInput? input, int index) + { + if (input is null) + { + throw InvalidContext($"tool input {index} must be a string or a ToolCandidate"); + } + + if (string.IsNullOrEmpty(input.InvocationName)) + { + throw InvalidContext($"tool input {index}: invocationName must be a non-empty string"); + } + + if (input.InvocationName.IndexOfAny(new[] { '\\', '/' }) >= 0) + { + throw InvalidContext($"tool input {index}: invocationName must be a bare name, not a path"); + } + + if (input.PackageUrl is { Length: 0 }) + { + throw InvalidContext($"tool input {index}: packageUrl must be a non-empty string when present"); + } + + if (input.DetectedVersion is { Length: 0 }) + { + throw InvalidContext($"tool input {index}: detectedVersion must be a non-empty string when present"); + } + + return input; + } + + private static string DescribeInput(int index, ToolInput tool) => $"input {index} ('{tool.InvocationName}')"; + + /// Own keys in ECMAScript property order (array-index keys first, ascending). + internal static List JsKeyOrder(IEnumerable keys) + { + var obj = new JsonObject(); + foreach (var key in keys) + { + obj.Set(key, JsonNull.Instance); + } + + return obj.Keys.ToList(); + } + + private SandboxConfigResolution Resolve(IReadOnlyList tools, ResolveContext? context) + { + ArgumentNullException.ThrowIfNull(tools); + var ctx = context ?? new ResolveContext(); + var candidates = tools.Select((tool, index) => ToCandidate(tool, index)).ToList(); + ValidateContext(ctx); + var revision = _store.Revision(ctx.CatalogRevision); + var platform = ctx.Platform ?? _host.Platform(); + var allowWeak = ctx.AllowWeakIdentityFallback; + + // The native architecture is resolved lazily: a lookup that never needs + // variant selection must not fail on host detection (design §4.4). + var architecture = ctx.Architecture; + string EffectiveArchitecture() + { + architecture ??= _host.NativeArchitecture(); + return architecture; + } + + var warnings = new List(); + var toolRecords = new List(); + var selected = new List(); + var selectedIds = new HashSet(StringComparer.Ordinal); + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var entry in revision.Entries) + { + byId[entry.EntryId] = entry; + } + + var ordered = revision.Entries.OrderBy(entry => entry.EntryId, StringComparer.Ordinal).ToList(); + for (var inputIndex = 0; inputIndex < candidates.Count; inputIndex++) + { + var tool = candidates[inputIndex]; + var matches = MatchTool(ordered, tool, inputIndex, platform, allowWeak, EffectiveArchitecture, warnings); + toolRecords.Add(new ToolDiagnostics( + inputIndex, + matches.Select(match => new ToolMatch( + match.Entry.EntryId, + match.Entry.EntryRevision, + match.Satisfied.Select(predicate => new MatchedIdentity(predicate.Kind, predicate.Strength)).ToList())).ToList())); + if (matches.Count > 1) + { + warnings.Add($"{DescribeInput(inputIndex, tool)} matched {matches.Count} entries ({string.Join(", ", matches.Select(m => m.Entry.EntryId))}); all contribute"); + } + + foreach (var match in matches) + { + var closure = Catalog.DependencyClosure(match.Entry, match.Selection, byId, platform, EffectiveArchitecture()); + if (!closure.Ok) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"dependency resolution failed: {closure.Reason} ({closure.Detail})"); + } + + foreach (var node in closure.Nodes!) + { + if (selectedIds.Add(node.Entry.EntryId)) + { + selected.Add(node); + } + } + } + } + + var diagnostics = new ResolutionDiagnostics(revision.CatalogRevision, toolRecords, DependencyRecords(selected, byId), warnings); + if (selected.Count == 0) + { + return new SandboxConfigResolution(null, diagnostics); + } + + if (ctx.Architecture is null) + { + warnings.Add($"architecture was not specified; variants were selected for the native system architecture '{EffectiveArchitecture()}'; the tool's architecture was not verified"); + } + + foreach (var node in selected) + { + if (!node.Exact) + { + warnings.Add($"{node.Entry.EntryId} uses its architecture-neutral {platform} variant; no {EffectiveArchitecture()}-specific variant exists"); + } + } + + var violation = Catalog.CompositionViolation(selected); + if (violation is not null) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.CompositionConflict, $"selected entries cannot be composed: {violation}"); + } + + var symbols = ResolveSymbols(selected, ctx, platform, warnings); + if (symbols is null) + { + return new SandboxConfigResolution(null, diagnostics); + } + + return new SandboxConfigResolution(PolicyConversion.ToPolicy(ComposePolicy(selected, symbols, platform)), diagnostics); + } + + private static List MatchTool( + List ordered, + ToolInput tool, + int inputIndex, + string platform, + bool allowWeak, + Func architecture, + List warnings) + { + ParsedPurl? purl = null; + if (tool.PackageUrl is not null) + { + purl = Purl.Parse(tool.PackageUrl) + ?? throw InvalidContext($"{DescribeInput(inputIndex, tool)}: '{tool.PackageUrl}' is not a valid package URL"); + } + + var invocation = Paths.CaseKey(tool.InvocationName, platform); + var matches = new List(); + var skipped = new List(); + foreach (var entry in ordered) + { + var satisfied = entry.Identity.Where(predicate => predicate switch + { + PurlPredicate p => purl is not null && Purl.Parse(p.Value)?.Key == purl.Key, + NamePredicate n => n.Names.Any(name => Paths.CaseKey(name, platform) == invocation), + _ => false, + }).ToList(); + if (satisfied.Count == 0) + { + continue; + } + + var strong = satisfied.Any(predicate => predicate.Strength == "strong"); + if (!strong && !allowWeak) + { + skipped.Add($"{entry.EntryId} matched only by invocation name and allowWeakIdentityFallback is not enabled"); + continue; + } + + var selection = Catalog.SelectVariant(entry, platform, architecture()); + if (selection is null) + { + skipped.Add($"{entry.EntryId} has no variant for {platform}/{architecture()}"); + continue; + } + + foreach (var predicate in satisfied) + { + if (predicate is not PurlPredicate { VersionRange: { } range }) + { + continue; + } + + var evidence = tool.DetectedVersion ?? purl?.Version; + if (evidence is null) + { + continue; + } + + var inRange = VersionRange.Satisfies(evidence, range); + if (inRange != true) + { + warnings.Add($"{DescribeInput(inputIndex, tool)}: detected version '{evidence}' {(inRange == false ? "is outside" : "could not be compared with")} the reviewed range '{range}' for {entry.EntryId}"); + } + } + + if (!strong) + { + warnings.Add($"{DescribeInput(inputIndex, tool)} matched {entry.EntryId} only by invocation name (weak identity)"); + } + + matches.Add(new EntryMatch(entry, selection, satisfied)); + } + + if (matches.Count == 0) + { + warnings.Add($"{DescribeInput(inputIndex, tool)} matched no eligible catalog entry{(skipped.Count > 0 ? $": {string.Join("; ", skipped)}" : string.Empty)}"); + } + + return matches; + } + + private void ValidateContext(ResolveContext ctx) + { + if (ctx.Platform is not null && !CatalogPlatforms.All.Contains(ctx.Platform)) + { + throw InvalidContext($"ResolveContext.platform '{ctx.Platform}' is unsupported"); + } + + if (ctx.Architecture is not null && !CatalogArchitectures.All.Contains(ctx.Architecture)) + { + throw InvalidContext($"ResolveContext.architecture '{ctx.Architecture}' is unsupported"); + } + + if (ctx.ProjectRoot is { Length: 0 }) + { + throw InvalidContext("ResolveContext.projectRoot must be a non-empty string when present"); + } + + if (ctx.Symbols is null) + { + return; + } + + foreach (var name in JsKeyOrder(ctx.Symbols.Keys)) + { + if (!_store.Contract.Symbols.TryGetValue(name, out var definition)) + { + throw InvalidContext($"ResolveContext.symbols.{name} is not a catalog symbol"); + } + + if (definition.Source == "context") + { + throw InvalidContext($"symbol '{name}' is supplied through ResolveContext.projectRoot, not symbols"); + } + + if (ctx.Symbols[name] is null) + { + throw InvalidContext($"ResolveContext.symbols.{name} must be a string"); + } + } + } + + private Dictionary? ResolveSymbols(List nodes, ResolveContext ctx, string platform, List warnings) + { + var contract = _store.Contract; + var values = new Dictionary(StringComparer.Ordinal); + var missing = new Dictionary>(StringComparer.Ordinal); + var missingOrder = new List(); + foreach (var node in nodes) + { + foreach (var name in Catalog.PolicySymbols(node.Variant)) + { + if (values.ContainsKey(name)) + { + continue; + } + + var definition = contract.Symbols[name]; + string? value; + if (definition.Source == "context") + { + value = ctx.ProjectRoot; + } + else + { + value = ctx.Symbols is not null && ctx.Symbols.TryGetValue(name, out var supplied) ? supplied : null; + // Host-derived symbols describe the current host only. + if (value is null && definition.Source == "host" && platform == _host.Platform()) + { + value = _host.Symbol(name); + } + } + + if (value is null) + { + if (!missing.TryGetValue(name, out var ids)) + { + ids = new List(); + missing[name] = ids; + missingOrder.Add(name); + } + + ids.Add(node.Entry.EntryId); + continue; + } + + if (value.Contains("${", StringComparison.Ordinal) || !Paths.IsAbsolutePath(value, platform)) + { + throw InvalidContext($"symbol '{name}' must resolve to an absolute {platform} path"); + } + + values[name] = value; + } + } + + if (missing.Count > 0) + { + foreach (var name in missingOrder.OrderBy(n => n, StringComparer.Ordinal)) + { + var hint = contract.Symbols[name].Source == "context" ? "ResolveContext.projectRoot" : $"ResolveContext.symbols.{name}"; + warnings.Add($"required symbol '{name}' (needed by {string.Join(", ", missing[name])}) is unresolved; supply {hint}; no policy was returned"); + } + + return null; + } + + return values; + } + + private static List DependencyRecords(List nodes, Dictionary byId) + { + var records = new List(); + foreach (var node in nodes) + { + foreach (var dependency in node.Variant.DependencyList) + { + var target = byId[dependency.EntryId]; + var record = new ResolvedDependency(target.EntryId, target.EntryRevision, dependency.VersionRange); + if (!records.Contains(record)) + { + records.Add(record); + } + } + } + + records.Sort((a, b) => + { + var byEntry = string.CompareOrdinal(a.EntryId, b.EntryId); + if (byEntry != 0) + { + return Math.Sign(byEntry); + } + + if (a.EntryRevision != b.EntryRevision) + { + return a.EntryRevision < b.EntryRevision ? -1 : 1; + } + + if (a.RequiredVersionRange is null) + { + return b.RequiredVersionRange is null ? 0 : -1; + } + + return b.RequiredVersionRange is null ? 1 : Math.Sign(string.CompareOrdinal(a.RequiredVersionRange, b.RequiredVersionRange)); + }); + return records; + } + + private static JsonObject ComposePolicy(List nodes, Dictionary symbols, string platform) + { + var classes = new Dictionary>(StringComparer.Ordinal); + foreach (var field in Catalog.ComposableFields) + { + var seen = new HashSet(StringComparer.Ordinal); + var output = new List(); + foreach (var node in nodes) + { + foreach (var template in node.Variant.Paths(field)) + { + var substituted = Catalog.SymbolPattern.Replace(template, (Match match) => symbols[match.Groups[1].Value]); + var resolved = Paths.NormalizePath(substituted, platform); + var key = Paths.JoinKey(Paths.PathKeySegments(resolved, platform)); + if (seen.Add(key)) + { + output.Add(resolved); + } + } + } + + if (output.Count > 0) + { + classes[field] = output; + } + } + + var overlap = Catalog.FindCrossClassOverlap(classes, platform); + if (overlap is not null) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.CompositionConflict, $"resolved paths overlap across access classes: {overlap}"); + } + + var root = nodes[0].Variant.Policy; + var result = new JsonObject(); + result.Set("version", root.Get("version")!); + if (nodes.Any(node => node.Variant.Policy.Has("filesystem"))) + { + var filesystem = new JsonObject(); + foreach (var field in Catalog.ComposableFields) + { + if (classes.TryGetValue(field, out var list)) + { + filesystem.Set(field, new JsonArray(list.Select(path => (JsonValue)new JsonString(path)))); + } + } + + result.Set("filesystem", filesystem); + } + + // compositionViolation guarantees these exist only when exactly one entry is selected. + foreach (var key in new[] { "network", "ui", "timeoutMs" }) + { + if (root.Get(key) is { } value) + { + result.Set(key, value); + } + } + + return result; + } +} + +/// Convenience functions over the catalog embedded in this package. +public static class BundledPolicyCatalog +{ + private static readonly Lazy Default = new(() => new PolicyCatalog(CatalogStore.Bundled()), LazyThreadSafetyMode.ExecutionAndPublication); + + /// The bundled catalog with the system host environment. + public static PolicyCatalog Catalog => Default.Value; + + /// Composed candidate policy for several tools, from the bundled catalog. + /// Tool inputs. + /// Lookup context. + /// The policy or null. + public static CatalogSandboxPolicy? GetSandboxConfig(IReadOnlyList tools, ResolveContext? context = null) => Catalog.GetSandboxConfig(tools, context); + + /// Composed candidate policy for one tool, from the bundled catalog. + /// The tool input. + /// Lookup context. + /// The policy or null. + public static CatalogSandboxPolicy? GetSandboxConfig(ToolInput tool, ResolveContext? context = null) => Catalog.GetSandboxConfig(tool, context); + + /// Composed candidate policy plus attribution for several tools. + /// Tool inputs. + /// Lookup context. + /// The resolution. + public static SandboxConfigResolution GetSandboxConfigWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => Catalog.GetSandboxConfigWithDiagnostics(tools, context); + + /// Composed candidate policy plus attribution for one tool. + /// The tool input. + /// Lookup context. + /// The resolution. + public static SandboxConfigResolution GetSandboxConfigWithDiagnostics(ToolInput tool, ResolveContext? context = null) => Catalog.GetSandboxConfigWithDiagnostics(tool, context); + + /// Bundled catalog entry metadata. + /// Entry metadata ordered by entry ID. + public static IReadOnlyList ListCatalogEntries() => Catalog.ListCatalogEntries(); + + /// The bundled catalog schema version and revision. + /// The catalog info. + public static CatalogInfo GetCatalogInfo() => Catalog.GetCatalogInfo(); +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs new file mode 100644 index 000000000..5d38b1685 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.PolicyCatalog; + +/// +/// MXC error codes reused by the policy catalog (MXC's closed MxcError code set). +/// The library adds no code of its own. carries the +/// snake_case wire form. +/// +public enum PolicyCatalogErrorCode +{ + /// policy_validation: catalog data or the selected composition violates the catalog contract. + PolicyValidation, + + /// malformed_request: the caller's or is structurally invalid. + MalformedRequest, + + /// unsupported_containment: the host platform or native architecture has no catalog selector, or cannot be detected. + UnsupportedContainment, + + /// backend_error: installed catalog data is unavailable, unreadable, or fails integrity. + BackendError, +} + +/// +/// Stable, language-neutral sub-reason carried in details.reason. Every reason maps to exactly one +/// . carries the snake_case wire form. +/// +public enum PolicyCatalogErrorReason +{ + /// invalid_catalog (policy_validation): catalog, manifest, or contract data is invalid. + InvalidCatalog, + + /// composition_conflict (policy_validation): selected entries or resolved paths cannot be composed. + CompositionConflict, + + /// invalid_context (malformed_request): invalid resolve context or tool input. + InvalidContext, + + /// unsupported_host (unsupported_containment): host platform/architecture has no selector or cannot be detected. + UnsupportedHost, + + /// integrity (backend_error): catalog data does not match its published digest, or cannot be read. + Integrity, + + /// revision_unavailable (backend_error): an explicitly requested catalog revision is not installed. + RevisionUnavailable, +} + +/// A policy catalog library failure. A failure is never a "no match" result. +public sealed class PolicyCatalogException : Exception +{ + /// Creates a failure for ; becomes [code] message. + /// The stable sub-reason; it determines the code. + /// The message text without the code prefix. + public PolicyCatalogException(PolicyCatalogErrorReason reason, string message) + : base($"[{ToWire(CodeFor(reason))}] {message}") + { + ErrorReason = reason; + ErrorCode = CodeFor(reason); + } + + /// The MXC error code. + public PolicyCatalogErrorCode ErrorCode { get; } + + /// The stable sub-reason. + public PolicyCatalogErrorReason ErrorReason { get; } + + /// The MXC error code in its wire form, e.g. backend_error. + public string Code => ToWire(ErrorCode); + + /// The stable sub-reason in its wire form (details.reason), e.g. integrity. + public string Reason => ToWire(ErrorReason); + + /// The one code each reason maps to (TypeScript ERROR_CODE_FOR_REASON). + /// A sub-reason. + /// The code for . + public static PolicyCatalogErrorCode CodeFor(PolicyCatalogErrorReason reason) => reason switch + { + PolicyCatalogErrorReason.InvalidCatalog => PolicyCatalogErrorCode.PolicyValidation, + PolicyCatalogErrorReason.CompositionConflict => PolicyCatalogErrorCode.PolicyValidation, + PolicyCatalogErrorReason.InvalidContext => PolicyCatalogErrorCode.MalformedRequest, + PolicyCatalogErrorReason.UnsupportedHost => PolicyCatalogErrorCode.UnsupportedContainment, + PolicyCatalogErrorReason.Integrity => PolicyCatalogErrorCode.BackendError, + PolicyCatalogErrorReason.RevisionUnavailable => PolicyCatalogErrorCode.BackendError, + _ => throw new ArgumentOutOfRangeException(nameof(reason)), + }; + + /// The snake_case wire form of a code. + /// A code. + /// For example policy_validation. + public static string ToWire(PolicyCatalogErrorCode code) => code switch + { + PolicyCatalogErrorCode.PolicyValidation => "policy_validation", + PolicyCatalogErrorCode.MalformedRequest => "malformed_request", + PolicyCatalogErrorCode.UnsupportedContainment => "unsupported_containment", + PolicyCatalogErrorCode.BackendError => "backend_error", + _ => throw new ArgumentOutOfRangeException(nameof(code)), + }; + + /// The snake_case wire form of a reason. + /// A reason. + /// For example invalid_catalog. + public static string ToWire(PolicyCatalogErrorReason reason) => reason switch + { + PolicyCatalogErrorReason.InvalidCatalog => "invalid_catalog", + PolicyCatalogErrorReason.CompositionConflict => "composition_conflict", + PolicyCatalogErrorReason.InvalidContext => "invalid_context", + PolicyCatalogErrorReason.UnsupportedHost => "unsupported_host", + PolicyCatalogErrorReason.Integrity => "integrity", + PolicyCatalogErrorReason.RevisionUnavailable => "revision_unavailable", + _ => throw new ArgumentOutOfRangeException(nameof(reason)), + }; +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs new file mode 100644 index 000000000..51ccb7575 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.PolicyCatalog.Internal; + +namespace Microsoft.Mxc.PolicyCatalog.Internal +{ + /// Converts validated raw policy JSON into the typed policy records. + internal static class PolicyConversion + { + private static string? Str(JsonObject obj, string key) => obj.Get(key) is JsonString s ? s.Value : null; + + private static double? Num(JsonObject obj, string key) => obj.Get(key) is JsonNumber n ? n.Value : null; + + private static bool? Bool(JsonObject obj, string key) => obj.Get(key) is JsonBool b ? b.Value : null; + + private static List? Strings(JsonObject obj, string key) => + obj.Get(key) is JsonArray a ? a.Items.Select(item => ((JsonString)item).Value).ToList() : null; + + public static CatalogSandboxPolicy ToPolicy(JsonObject raw) + { + var policy = new CatalogSandboxPolicy(Str(raw, "version")!) { TimeoutMs = Num(raw, "timeoutMs") }; + if (raw.Get("filesystem") is JsonObject fs) + { + policy = policy with + { + Filesystem = new CatalogFilesystemPolicy + { + DeniedPaths = Strings(fs, "deniedPaths"), + ReadonlyPaths = Strings(fs, "readonlyPaths"), + ReadwritePaths = Strings(fs, "readwritePaths"), + }, + }; + } + + if (raw.Get("network") is JsonObject network) + { + CatalogEgressPolicy? egress = null; + if (network.Get("egress") is JsonObject e) + { + egress = new CatalogEgressPolicy { Default = Str(e, "default"), Allow = Rules(e.Get("allow")), Deny = Rules(e.Get("deny")) }; + } + + CatalogIngressPolicy? ingress = null; + if (network.Get("ingress") is JsonObject i) + { + ingress = new CatalogIngressPolicy { Default = Str(i, "default"), HostLoopback = Str(i, "hostLoopback") }; + } + + policy = policy with { Network = new CatalogNetworkPolicy { Egress = egress, Ingress = ingress } }; + } + + if (raw.Get("ui") is JsonObject ui) + { + policy = policy with + { + Ui = new CatalogUiPolicy { AllowWindows = Bool(ui, "allowWindows"), Clipboard = Str(ui, "clipboard"), AllowInputInjection = Bool(ui, "allowInputInjection") }, + }; + } + + return policy; + } + + private static List? Rules(JsonValue? value) + { + if (value is not JsonArray rules) + { + return null; + } + + return rules.Items.Cast().Select(rule => new CatalogNetworkRule + { + To = rule.Get("to") is JsonArray to + ? to.Items.Cast().Select(peer => new CatalogNetworkPeer(Str(peer, "cidr")!) { Except = Strings(peer, "except") }).ToList() + : null, + Ports = rule.Get("ports") is JsonArray ports + ? ports.Items.Cast().Select(port => new CatalogNetworkPort { Protocol = Str(port, "protocol"), Port = Num(port, "port"), EndPort = Num(port, "endPort") }).ToList() + : null, + }).ToList(); + } + } +} + +namespace Microsoft.Mxc.PolicyCatalog +{ + /// + /// Serializes results to the exact JSON shape the TypeScript reference emits (JSON.stringify): + /// absent optional fields are omitted, never null; numbers use ECMAScript formatting. + /// + public static class PolicyCatalogJson + { + /// Serializes a policy; null becomes null. + /// The policy or null. + /// Indentation (0 = compact, 2 = the CLI form). + /// JSON text without a trailing newline. + public static string Serialize(CatalogSandboxPolicy? policy, int indent = 0) => JsonText.Stringify(ToJson(policy), indent); + + /// Serializes a resolution; policy is omitted when absent. + /// The resolution. + /// Indentation. + /// JSON text. + public static string Serialize(SandboxConfigResolution resolution, int indent = 0) => JsonText.Stringify(ToJson(resolution), indent); + + /// Serializes catalog info. + /// The info. + /// Indentation. + /// JSON text. + public static string Serialize(CatalogInfo info, int indent = 0) => JsonText.Stringify(ToJson(info), indent); + + /// Serializes entry metadata as a JSON array. + /// The entries. + /// Indentation. + /// JSON text. + public static string Serialize(IReadOnlyList entries, int indent = 0) => JsonText.Stringify(ToJson(entries), indent); + + /// Serializes the CLI's inspect output, {"info": ..., "entries": [...]}. + /// The catalog info. + /// The entries. + /// Indentation. + /// JSON text. + public static string SerializeInspect(CatalogInfo info, IReadOnlyList entries, int indent = 0) => + JsonText.Stringify(new JsonObject().With("info", ToJson(info)).With("entries", ToJson(entries)), indent); + + /// Serializes a validation report. + /// The report. + /// Indentation. + /// JSON text. + public static string Serialize(CatalogValidationReport report, int indent = 0) => JsonText.Stringify(ToJson(report), indent); + + /// Serializes a failure as the CLI's {"error": {"code", "message", "details": {"reason"}}}. + /// The failure. + /// Indentation. + /// JSON text. + public static string Serialize(PolicyCatalogException error, int indent = 0) + { + ArgumentNullException.ThrowIfNull(error); + var body = new JsonObject() + .With("code", new JsonString(error.Code)) + .With("message", new JsonString(error.Message)) + .With("details", new JsonObject().With("reason", new JsonString(error.Reason))); + return JsonText.Stringify(new JsonObject().With("error", body), indent); + } + + /// The canonical JSON form (sorted keys, no whitespace) of JSON text, as used for revision digests. + /// JSON text. + /// Canonical JSON text. + public static string Canonicalize(string json) => CanonicalJson.Serialize(JsonText.Parse(json)); + + /// Lower-case hex SHA-256 over the canonical JSON form of JSON text. + /// JSON text. + /// The digest. + public static string CanonicalSha256(string json) => CanonicalJson.Sha256(JsonText.Parse(json)); + + private static JsonValue S(string? value) => value is null ? null! : new JsonString(value); + + private static JsonValue? OptS(string? value) => value is null ? null : new JsonString(value); + + private static JsonValue? OptN(double? value) => value is null ? null : new JsonNumber(value.Value); + + private static JsonValue? OptB(bool? value) => value is null ? null : JsonBool.Of(value.Value); + + private static JsonValue? OptList(IReadOnlyList? values) => values is null ? null : Strings(values); + + private static JsonArray Strings(IEnumerable values) => new(values.Select(value => (JsonValue)new JsonString(value))); + + internal static JsonValue ToJson(CatalogSandboxPolicy? policy) + { + if (policy is null) + { + return JsonNull.Instance; + } + + var obj = new JsonObject().With("version", S(policy.Version)); + if (policy.Filesystem is { } fs) + { + obj.Set("filesystem", new JsonObject() + .With("deniedPaths", OptList(fs.DeniedPaths)) + .With("readonlyPaths", OptList(fs.ReadonlyPaths)) + .With("readwritePaths", OptList(fs.ReadwritePaths))); + } + + if (policy.Network is { } network) + { + var n = new JsonObject(); + if (network.Egress is { } egress) + { + n.Set("egress", new JsonObject() + .With("default", OptS(egress.Default)) + .With("allow", Rules(egress.Allow)) + .With("deny", Rules(egress.Deny))); + } + + if (network.Ingress is { } ingress) + { + n.Set("ingress", new JsonObject() + .With("default", OptS(ingress.Default)) + .With("hostLoopback", OptS(ingress.HostLoopback))); + } + + obj.Set("network", n); + } + + if (policy.Ui is { } ui) + { + obj.Set("ui", new JsonObject() + .With("allowWindows", OptB(ui.AllowWindows)) + .With("clipboard", OptS(ui.Clipboard)) + .With("allowInputInjection", OptB(ui.AllowInputInjection))); + } + + return obj.With("timeoutMs", OptN(policy.TimeoutMs)); + } + + private static JsonValue? Rules(IReadOnlyList? rules) + { + if (rules is null) + { + return null; + } + + return new JsonArray(rules.Select(rule => (JsonValue)new JsonObject() + .With("to", rule.To is null ? null : new JsonArray(rule.To.Select(peer => (JsonValue)new JsonObject() + .With("cidr", S(peer.Cidr)) + .With("except", OptList(peer.Except))))) + .With("ports", rule.Ports is null ? null : new JsonArray(rule.Ports.Select(port => (JsonValue)new JsonObject() + .With("protocol", OptS(port.Protocol)) + .With("port", OptN(port.Port)) + .With("endPort", OptN(port.EndPort))))))); + } + + internal static JsonValue ToJson(SandboxConfigResolution resolution) + { + ArgumentNullException.ThrowIfNull(resolution); + var d = resolution.Diagnostics; + var diagnostics = new JsonObject() + .With("catalogRevision", S(d.CatalogRevision)) + .With("tools", new JsonArray(d.Tools.Select(tool => (JsonValue)new JsonObject() + .With("inputIndex", new JsonNumber(tool.InputIndex)) + .With("matches", new JsonArray(tool.Matches.Select(match => (JsonValue)new JsonObject() + .With("entryId", S(match.EntryId)) + .With("entryRevision", new JsonNumber(match.EntryRevision)) + .With("matchedIdentities", new JsonArray(match.MatchedIdentities.Select(identity => (JsonValue)new JsonObject() + .With("kind", S(identity.Kind)) + .With("strength", S(identity.Strength))))))))))) + .With("resolvedDependencies", new JsonArray(d.ResolvedDependencies.Select(dependency => (JsonValue)new JsonObject() + .With("entryId", S(dependency.EntryId)) + .With("entryRevision", new JsonNumber(dependency.EntryRevision)) + .With("requiredVersionRange", OptS(dependency.RequiredVersionRange))))) + .With("warnings", Strings(d.Warnings)); + return new JsonObject() + .With("policy", resolution.Policy is null ? null : ToJson(resolution.Policy)) + .With("diagnostics", diagnostics); + } + + internal static JsonValue ToJson(CatalogInfo info) => new JsonObject() + .With("catalogSchemaVersion", S(info.CatalogSchemaVersion)) + .With("catalogRevision", S(info.CatalogRevision)); + + internal static JsonValue ToJson(IReadOnlyList entries) => new JsonArray(entries.Select(entry => (JsonValue)new JsonObject() + .With("catalogRevision", S(entry.CatalogRevision)) + .With("entryId", S(entry.EntryId)) + .With("entryRevision", new JsonNumber(entry.EntryRevision)) + .With("displayName", S(entry.DisplayName)) + .With("identity", new JsonArray(entry.Identity.Select(identity => (JsonValue)(identity.Kind == "purl" + ? new JsonObject().With("kind", S(identity.Kind)).With("value", OptS(identity.Value)).With("versionRange", OptS(identity.VersionRange)) + : new JsonObject().With("kind", S(identity.Kind)).With("names", OptList(identity.Names)))))) + .With("platformVariants", new JsonArray(entry.PlatformVariants.Select(variant => (JsonValue)new JsonObject() + .With("platform", S(variant.Platform)) + .With("architecture", OptS(variant.Architecture)) + .With("dependencyEntryIds", Strings(variant.DependencyEntryIds)) + .With("sandboxPolicyVersion", S(variant.SandboxPolicyVersion))))) + .With("provenance", new JsonObject() + .With("method", S(entry.Provenance.Method)) + .With("sourceRevision", S(entry.Provenance.SourceRevision))))); + + internal static JsonValue ToJson(CatalogValidationReport report) => new JsonObject() + .With("ok", JsonBool.Of(report.Ok)) + .With("catalogDir", S(report.CatalogDir)) + .With("defaultRevision", OptS(report.DefaultRevision)) + .With("revisions", OptList(report.Revisions)) + .With("baseRef", report.BaseRef is null ? null : new JsonObject() + .With("ref", S(report.BaseRef.Ref)) + .With("comparedRevisions", new JsonNumber(report.BaseRef.ComparedRevisions))) + .With("errors", Strings(report.Errors)); + } +} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs new file mode 100644 index 000000000..68d9956ee --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.PolicyCatalog; + +// Numbers are JSON numbers with JavaScript semantics (IEEE doubles) so every +// value round-trips exactly as the TypeScript reference emits it. The catalog +// contract only admits integers where these appear. + +/// +/// The catalog-supported subset of MXC's SandboxPolicy authoring contract. Structurally compatible +/// with the MXC SDK policy; backend-specific keys are never part of it. A candidate lower bound, not authorization. +/// +/// The SandboxPolicy contract version. +public sealed record CatalogSandboxPolicy(string Version) +{ + /// Filesystem requirements, or null when absent. + public CatalogFilesystemPolicy? Filesystem { get; init; } + + /// Network requirements, or null when absent. + public CatalogNetworkPolicy? Network { get; init; } + + /// UI requirements, or null when absent. + public CatalogUiPolicy? Ui { get; init; } + + /// Timeout in milliseconds, or null when absent. + public double? TimeoutMs { get; init; } +} + +/// Filesystem access classes. A null list is absent from the JSON form. +public sealed record CatalogFilesystemPolicy +{ + /// Paths the tool must not access. + public IReadOnlyList? DeniedPaths { get; init; } + + /// Paths the tool reads. + public IReadOnlyList? ReadonlyPaths { get; init; } + + /// Paths the tool reads and writes. + public IReadOnlyList? ReadwritePaths { get; init; } +} + +/// Network requirements. +public sealed record CatalogNetworkPolicy +{ + /// Egress rules. + public CatalogEgressPolicy? Egress { get; init; } + + /// Ingress rules. + public CatalogIngressPolicy? Ingress { get; init; } +} + +/// Egress rules. +public sealed record CatalogEgressPolicy +{ + /// deny (the only default the catalog admits) or null. + public string? Default { get; init; } + + /// Allowed peers. + public IReadOnlyList? Allow { get; init; } + + /// Denied peers. + public IReadOnlyList? Deny { get; init; } +} + +/// Ingress rules. +public sealed record CatalogIngressPolicy +{ + /// deny or null. + public string? Default { get; init; } + + /// allow, deny, or null. + public string? HostLoopback { get; init; } +} + +/// One network rule. +public sealed record CatalogNetworkRule +{ + /// Peers. + public IReadOnlyList? To { get; init; } + + /// Ports. + public IReadOnlyList? Ports { get; init; } +} + +/// A network peer. +/// The peer CIDR. +public sealed record CatalogNetworkPeer(string Cidr) +{ + /// Excluded CIDRs. + public IReadOnlyList? Except { get; init; } +} + +/// A port or port range. +public sealed record CatalogNetworkPort +{ + /// tcp, udp, icmp, any, or null. + public string? Protocol { get; init; } + + /// First port. + public double? Port { get; init; } + + /// Last port of a range. + public double? EndPort { get; init; } +} + +/// UI requirements. +public sealed record CatalogUiPolicy +{ + /// Whether windows are allowed. + public bool? AllowWindows { get; init; } + + /// none, read, write, all, or null. + public string? Clipboard { get; init; } + + /// Whether input injection is allowed. + public bool? AllowInputInjection { get; init; } +} + +/// Strength of a matched identity predicate kind. +/// purl or invocation-name. +/// strong or weak. +public sealed record MatchedIdentity(string Kind, string Strength); + +/// One entry matched by one input. +/// The entry ID. +/// The entry revision. +/// Satisfied predicates, in declaration order. +public sealed record ToolMatch(string EntryId, double EntryRevision, IReadOnlyList MatchedIdentities); + +/// Per-input attribution, in input order. +/// Zero-based input index. +/// Matches ordered by entry ID. +public sealed record ToolDiagnostics(int InputIndex, IReadOnlyList Matches); + +/// A dependency edge among the selected entries. +/// The dependency's entry ID. +/// The dependency's entry revision. +/// The declared (unevaluated) version range, or null. +public sealed record ResolvedDependency(string EntryId, double EntryRevision, string? RequiredVersionRange); + +/// Attribution and warnings for one lookup (design §5.1). +/// The revision used. +/// Per-input records, in input order. +/// Distinct dependency edges, sorted. +/// Plain-text warnings. +public sealed record ResolutionDiagnostics( + string CatalogRevision, + IReadOnlyList Tools, + IReadOnlyList ResolvedDependencies, + IReadOnlyList Warnings); + +/// Result of . +/// The composed candidate policy, or null when none can be resolved. +/// Attribution and warnings. +public sealed record SandboxConfigResolution(CatalogSandboxPolicy? Policy, ResolutionDiagnostics Diagnostics); + +/// Result of . +/// The catalog schema version. +/// The installed default revision. +public sealed record CatalogInfo(string CatalogSchemaVersion, string CatalogRevision); + +/// Identity metadata: either a package URL () or invocation names (). +/// purl or invocation-name. +public sealed record CatalogIdentityMetadata(string Kind) +{ + /// The package URL for purl. + public string? Value { get; init; } + + /// The reviewed version range for purl, when declared. + public string? VersionRange { get; init; } + + /// The names for invocation-name. + public IReadOnlyList? Names { get; init; } +} + +/// Platform variant metadata. +/// The platform selector. +/// The architecture selector, or null for the architecture-neutral variant. +/// Declared dependencies. +/// The embedded policy's contract version. +public sealed record CatalogVariantMetadata(string Platform, string? Architecture, IReadOnlyList DependencyEntryIds, string SandboxPolicyVersion); + +/// Entry provenance. +/// How the entry was produced. +/// Where it came from. +public sealed record CatalogProvenance(string Method, string SourceRevision); + +/// Inspection metadata (design §5.2). It never exposes a policy body. +/// The revision. +/// The entry ID. +/// The entry revision. +/// The display name. +/// Identity predicates, in declaration order. +/// Variants, in declaration order. +/// Provenance. +public sealed record CatalogEntryMetadata( + string CatalogRevision, + string EntryId, + double EntryRevision, + string DisplayName, + IReadOnlyList Identity, + IReadOnlyList PlatformVariants, + CatalogProvenance Provenance); + +/// Result of catalog-directory validation (policy-catalog validate). +/// True only when every requested check ran and passed. +/// The absolute catalog directory. +/// The default revision, when the manifest loaded. +/// Listed revisions, when the manifest loaded. +/// The base-ref comparison, when requested. +/// Error strings. +public sealed record CatalogValidationReport( + bool Ok, + string CatalogDir, + string? DefaultRevision, + IReadOnlyList? Revisions, + BaseRefReport? BaseRef, + IReadOnlyList Errors); + +/// The base-ref part of a validation report. +/// The requested ref. +/// Published revisions compared; 0 when the base has no catalog. +public sealed record BaseRefReport(string Ref, int ComparedRevisions); diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs new file mode 100644 index 000000000..9c12111b8 --- /dev/null +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace Microsoft.Mxc.PolicyCatalog; + +/// +/// The default host environment (TypeScript nodeHostEnvironment). The native architecture is detected +/// once per process, on first need, and never taken from the process or compile-time architecture. +/// +public sealed class SystemHostEnvironment : IHostEnvironment +{ + private static readonly Lazy NativeArchitectureCache = new(DetectNativeArchitecture, LazyThreadSafetyMode.ExecutionAndPublication); + + private SystemHostEnvironment() + { + } + + /// The shared instance. + public static SystemHostEnvironment Instance { get; } = new(); + + /// + public string Platform() + { + if (OperatingSystem.IsWindows()) + { + return CatalogPlatforms.Windows; + } + + if (OperatingSystem.IsMacOS()) + { + return CatalogPlatforms.MacOS; + } + + if (OperatingSystem.IsLinux()) + { + return CatalogPlatforms.Linux; + } + + throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, $"host platform '{NodePlatformName()}' has no catalog selector"); + } + + /// + public string NativeArchitecture() + { + // A failed detection is not cached as a value; Lazy caches the exception, matching a + // deterministic host fact for the process lifetime. + return NativeArchitectureCache.Value; + } + + /// + public string? Symbol(string name) + { + switch (name) + { + case "user_home": + return NonEmpty(HomeDirectory()); + case "temp_dir": + return NonEmpty(TempDirectory()); + default: + return null; + } + } + + private static string? NonEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; + + // Node os.homedir(): USERPROFILE on Windows, HOME on POSIX, then the account database. + private static string? HomeDirectory() + { + var fromEnvironment = Environment.GetEnvironmentVariable(OperatingSystem.IsWindows() ? "USERPROFILE" : "HOME"); + return string.IsNullOrEmpty(fromEnvironment) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : fromEnvironment; + } + + // Node os.tmpdir(): Windows TEMP/TMP/%SystemRoot%\temp with a trailing '\' removed unless after ':'; + // POSIX TMPDIR/TMP/TEMP or /tmp with a trailing '/' removed. + private static string TempDirectory() + { + if (OperatingSystem.IsWindows()) + { + var path = FirstEnv("TEMP", "TMP") ?? ((Environment.GetEnvironmentVariable("SystemRoot") ?? Environment.GetEnvironmentVariable("windir")) + "\\temp"); + if (path.Length > 1 && path[path.Length - 1] == '\\' && path[path.Length - 2] != ':') + { + path = path.Substring(0, path.Length - 1); + } + + return path; + } + + var posix = FirstEnv("TMPDIR", "TMP", "TEMP") ?? "/tmp"; + if (posix.Length > 1 && posix[posix.Length - 1] == '/') + { + posix = posix.Substring(0, posix.Length - 1); + } + + return posix; + } + + private static string? FirstEnv(params string[] names) + { + foreach (var name in names) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(value)) + { + return value; + } + } + + return null; + } + + private static string NodePlatformName() + { + if (OperatingSystem.IsFreeBSD()) + { + return "freebsd"; + } + + return RuntimeInformation.OSDescription; + } + + /// Maps an OS-reported machine/architecture string to a catalog selector (TypeScript architectureFromMachine). + /// For example AMD64, x86_64, ARM64, aarch64. + /// x64, arm64, or null when unknown. + public static string? ArchitectureFromMachine(string machine) + { + switch (Internal.JsString.ToLower(Internal.JsString.Trim(machine))) + { + case "x86_64": + case "amd64": + case "x64": + return CatalogArchitectures.X64; + case "arm64": + case "aarch64": + return CatalogArchitectures.Arm64; + default: + return null; + } + } + + /// + /// Windows: the machine-wide PROCESSOR_ARCHITECTURE under + /// HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment (registry API; an emulated + /// process still reads the native value). macOS: /usr/sbin/sysctl -n hw.optional.arm64 (absent key + /// = Intel), then /usr/bin/uname -m. Linux: /usr/bin/uname -m (or /bin/uname), the + /// kernel machine type. Executables are invoked by absolute path. + /// + private static string DetectNativeArchitecture() + { + string? reported; + try + { + if (OperatingSystem.IsWindows()) + { + reported = ReadWindowsRegistryArchitecture(); + } + else if (OperatingSystem.IsMacOS()) + { + var appleSilicon = false; + try + { + appleSilicon = Run("/usr/sbin/sysctl", "-n", "hw.optional.arm64").Trim() == "1"; + } + catch (Exception) + { + // The key is absent on Intel Macs. + } + + reported = appleSilicon ? "arm64" : Run("/usr/bin/uname", "-m").Trim(); + } + else + { + var uname = File.Exists("/usr/bin/uname") ? "/usr/bin/uname" : "/bin/uname"; + reported = Run(uname, "-m").Trim(); + } + } + catch (Exception error) + { + throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, $"native system architecture could not be determined: {error.Message}"); + } + + var architecture = reported is null ? null : ArchitectureFromMachine(reported); + return architecture + ?? throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, $"native system architecture '{reported ?? "unknown"}' has no catalog selector"); + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static string? ReadWindowsRegistryArchitecture() + { + // RegistryView.Default from a 64-bit process, and Registry64 from a 32-bit one, both see the + // machine-wide value; this key is not redirected by WOW64 anyway. + using var hive = Microsoft.Win32.RegistryKey.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryView.Registry64); + using var key = hive.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Environment") + ?? throw new InvalidOperationException(@"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment could not be opened"); + var value = key.GetValue("PROCESSOR_ARCHITECTURE", null, Microsoft.Win32.RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + // reg.exe output is matched with \S+ in the TypeScript reference; keep the first token. + return value is null ? null : Regex.Match(value, "^\\S*").Value; + } + + private static string Run(string file, params string[] args) + { + var start = new ProcessStartInfo(file) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start) ?? throw new InvalidOperationException($"{file} could not be started"); + process.StandardInput.Close(); + var stderr = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + _ = stderr.Result; + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Command failed: {file} {string.Join(" ", args)}"); + } + + return output; + } +} diff --git a/policy-catalog/dotnet/README.md b/policy-catalog/dotnet/README.md new file mode 100644 index 000000000..65d956da5 --- /dev/null +++ b/policy-catalog/dotnet/README.md @@ -0,0 +1,48 @@ +# Microsoft.Mxc.PolicyCatalog (.NET) — PROTOTYPE + +Native C# implementation of the policy catalog library and the `policy-catalog` +CLI, behavior-equivalent to the TypeScript reference in `../src`. It targets +`net8.0` and depends only on inbox libraries (`System.Text.Json`, +`Microsoft.Win32.Registry` on Windows). The bundled catalog is embedded from +`../catalog/` (the single source of truth; nothing is copied into this folder). + +`global.json` pins SDK `10.0.401` (`rollForward: latestFeature`) with the +Microsoft.Testing.Platform test runner. SDK 8.0.x cannot run MTP-mode +`dotnet test` or read `.slnx` solutions. The projects still target `net8.0`. + +Run everything from this directory (`dotnet/`): + +```sh +# Build (warnings are errors) +dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release + +# Unit, conformance (../conformance/fixtures), and vector (../conformance/vectors) tests +dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release + +# Pack the library +dotnet pack Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj -c Release -o + +# CLI (from source) +dotnet Microsoft.Mxc.PolicyCatalog.Cli/bin/Release/net8.0/policy-catalog.dll inspect +``` + +## Functional tests (packaged artifact) + +```sh +# from policy-catalog/ +node scripts/dotnet-functional.mjs +``` + +The driver packs the library into a local feed under `os.tmpdir()`, then +builds two consumers outside the repository against that package: + +- the CLI (`functional/policy-catalog-cli-consumer` + `Microsoft.Mxc.PolicyCatalog.Cli/Program.cs`) +- the functional tests (`functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests`) + +Each consumer gets a generated `nuget.config` and an isolated `NUGET_PACKAGES` +directory. The tests call the packaged library directly and run the packaged +CLI in a separate process. The functional project is not part of the solution. + +Third-party packages (xunit.v3) come from nuget.org. Where nuget.org is not +directly reachable, set `POLICY_CATALOG_NUGET_UPSTREAM` to a mirror feed URL. +Set `POLICY_CATALOG_KEEP_TEMP=1` to keep the temporary directory. diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs new file mode 100644 index 000000000..90449ca47 --- /dev/null +++ b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// validate --base-ref (published-revision immutability) through the packaged CLI. +using System.Diagnostics; +using System.Text.Json.Nodes; +using Xunit; +using static Microsoft.Mxc.PolicyCatalog.FunctionalTests.Fx; + +namespace Microsoft.Mxc.PolicyCatalog.FunctionalTests; + +public sealed class BaseRefTests : WorkDirTest +{ + private static string Git(string cwd, params string[] args) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var arg in new[] { "-c", "user.name=functional-test", "-c", "user.email=functional-test@invalid", "-c", "core.autocrlf=false", "-c", "commit.gpgsign=false" }.Concat(args)) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start)!; + var stderr = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, $"git {string.Join(" ", args)}: {stderr.Result}"); + return output; + } + + private (string Repo, string Dir) PublishedRepo() + { + var repo = NewDir("repo-"); + Git(repo, "init", "-q"); + var dir = Path.Combine(repo, "catalog"); + WriteCatalog(dir, new[] { Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1") }); + Git(repo, "add", "-A"); + Git(repo, "commit", "-q", "-m", "publish"); + return (repo, dir); + } + + [Fact] + public void UnchangedOrAppendedPasses() + { + var (_, dir) = PublishedRepo(); + var unchanged = Cli("validate", "--catalog", dir, "--base-ref", "HEAD"); + Assert.Equal(0, unchanged.Status); + Assert.Equal("""{"ref":"HEAD","comparedRevisions":1}""", unchanged.Json!["baseRef"]!.ToJsonString(Relaxed)); + WriteCatalog(dir, new[] + { + Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1"), + Revision(new JsonNode[] { Entry("tool:a"), Entry("tool:b") }, "2000-01-02.1"), + }); + Assert.Equal(0, Cli("validate", "--catalog", dir, "--base-ref", "HEAD").Status); + } + + [Fact] + public void RewritingAPublishedRevisionFails() + { + var (_, dir) = PublishedRepo(); + WriteCatalog(dir, new[] { Revision(new JsonNode[] { Entry("tool:a", """{ "entryRevision": 2, "displayName": "changed" }""") }, "2000-01-01.1") }); + Assert.Equal(0, Cli("validate", "--catalog", dir).Status); + var result = Cli("validate", "--catalog", dir, "--base-ref", "HEAD"); + Assert.Equal(1, result.Status); + Assert.Equal( + """["[immutability] published revision '2000-01-01.1' manifest entry was modified","[immutability] published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead"]""", + result.Json!["errors"]!.ToJsonString(Relaxed)); + } + + [Fact] + public void BadRefsAndNonGitDirectoriesAreErrors() + { + var (repo, dir) = PublishedRepo(); + Assert.Contains("base-ref check: 'no-such-ref' does not name a commit", Cli("validate", "--catalog", dir, "--base-ref", "no-such-ref").Stdout, StringComparison.Ordinal); + var optionLike = Cli("validate", "--catalog", dir, "--base-ref", "-h"); + Assert.Equal(1, optionLike.Status); + Assert.Contains("base-ref check: '-h' is not a valid git ref", optionLike.Stdout, StringComparison.Ordinal); + var outside = WriteCatalog(Path.Combine(Path.GetTempPath(), "policy-catalog-dotnet-nogit-" + Guid.NewGuid().ToString("N")), new[] { Revision(new JsonNode[] { Entry("tool:a") }) }); + try + { + var noGit = Cli("validate", "--catalog", outside, "--base-ref", "HEAD"); + Assert.Equal(1, noGit.Status); + Assert.Contains("is not inside a git work tree", noGit.Stdout, StringComparison.Ordinal); + } + finally + { + Directory.Delete(outside, true); + } + + var emptyTree = Git(repo, "mktree").Trim(); + var orphan = Git(repo, "commit-tree", emptyTree, "-m", "empty").Trim(); + var empty = Cli("validate", "--catalog", dir, "--base-ref", orphan); + Assert.Equal(0, empty.Status); + Assert.Equal(0, empty.Json!["baseRef"]!["comparedRevisions"]!.GetValue()); + } +} diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs new file mode 100644 index 000000000..27af8af6c --- /dev/null +++ b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Functional tests through the packaged CLI and library: unknown tools, weak +// opt-in, composition conflicts, unavailable revisions, architecture +// selection, multi-match, integrity tamper, and dependency cycles. +using System.Text.Json.Nodes; +using Xunit; +using static Microsoft.Mxc.PolicyCatalog.FunctionalTests.Fx; + +namespace Microsoft.Mxc.PolicyCatalog.FunctionalTests; + +public sealed class CliCommandTests +{ + private static string[] Split(string joined) => joined.Length == 0 ? Array.Empty() : joined.Split('|'); + + [Fact] + public void InspectReportsMetadataOnly() + { + var result = Cli("inspect"); + Assert.Equal(0, result.Status); + Assert.Equal(InstalledManifest["defaultRevision"]!.GetValue(), result.Json!["info"]!["catalogRevision"]!.GetValue()); + Assert.Equal(new[] { "tool:git", "tool:node", "tool:npm" }, result.Json["entries"]!.AsArray().Select(e => e!["entryId"]!.GetValue())); + Assert.DoesNotContain("readwritePaths", result.Stdout, StringComparison.Ordinal); + Assert.DoesNotContain("${", result.Stdout, StringComparison.Ordinal); + Assert.EndsWith("}\n", result.Stdout, StringComparison.Ordinal); + } + + [Fact] + public void ValidateBundledCatalog() + { + var result = Cli("validate"); + Assert.Equal(0, result.Status); + Assert.True(result.Json!["ok"]!.GetValue()); + Assert.Empty(result.Json["errors"]!.AsArray()); + Assert.Null(result.Json["baseRef"]); + } + + [Theory] + [InlineData("")] + [InlineData("bogus")] + [InlineData("info")] + [InlineData("resolve|--platform")] + [InlineData("resolve|--symbol|noequals|git")] + [InlineData("resolve|--symbol|=x|git")] + [InlineData("resolve|--purl|pkg:npm/npm")] + [InlineData("resolve|--base-ref|HEAD|git")] + [InlineData("resolve|--platform|--allow-weak|git")] + [InlineData("inspect|extra")] + [InlineData("validate|extra")] + [InlineData("validate|--base-ref")] + [InlineData("resolve|--catalog")] + public void UsageErrorsExit2WithoutStdout(string joined) + { + var result = Cli(Split(joined)); + Assert.Equal(2, result.Status); + Assert.Equal(string.Empty, result.Stdout); + Assert.StartsWith("policy-catalog: ", result.Stderr, StringComparison.Ordinal); + Assert.EndsWith("usage: policy-catalog [options]\n", result.Stderr.Replace("\r\n", "\n"), StringComparison.Ordinal); + } + + [Theory] + [InlineData("resolve|--platform|plan9|git")] + [InlineData("resolve|--architecture|mips|git")] + [InlineData("resolve|--platform|linux|--architecture|x64|--allow-weak|--symbol|node_prefix=relative|node")] + [InlineData("resolve|--platform|linux|--architecture|x64|--symbol|unknown_symbol=/x|git")] + [InlineData("resolve|--platform|linux|--architecture|x64|--symbol|__proto__=/x|git")] + [InlineData("resolve|--platform|linux|--architecture|x64|--purl|not-a-purl|npm")] + [InlineData("resolve|--platform|linux|--architecture|x64|./bin/git")] + public void InvalidCallerInputIsInvalidContext(string joined) + { + var result = Cli(Split(joined)); + Assert.Equal(1, result.Status); + Assert.Equal("malformed_request", result.Json!["error"]!["code"]!.GetValue()); + Assert.Equal("invalid_context", result.ErrorReason); + } + + public static TheoryData PlatformArch() + { + var data = new TheoryData(); + foreach (var p in Platforms) + { + foreach (var a in Architectures) + { + data.Add(p, a); + } + } + + return data; + } + + [Theory] + [MemberData(nameof(PlatformArch))] + public void ResolvesFullToolSetDeterministically(string platform, string architecture) + { + var c = PlatformContext[platform]; + var first = Cli(Args("resolve", "--diagnostics", FullContext(platform, architecture), "git", "npm", "node")); + var second = Cli(Args("resolve", "--diagnostics", FullContext(platform, architecture), "git", "npm", "node")); + Assert.Equal(0, first.Status); + Assert.Equal(first.Stdout, second.Stdout); + var policy = first.Json!["policy"]!; + Assert.Equal(new[] { c.Root, c.Cache }, policy["filesystem"]!["readwritePaths"]!.AsArray().Select(p => p!.GetValue())); + Assert.Equal(new[] { c.Prefix }, policy["filesystem"]!["readonlyPaths"]!.AsArray().Select(p => p!.GetValue())); + Assert.Null(policy["network"]); + Assert.Equal("""[{"entryId":"tool:node","entryRevision":1}]""", first.Json["diagnostics"]!["resolvedDependencies"]!.ToJsonString(Relaxed)); + Assert.Contains($"tool:git uses its architecture-neutral {platform} variant; no {architecture}-specific variant exists", first.Warnings, StringComparison.Ordinal); + Assert.DoesNotContain("architecture was not specified", first.Warnings, StringComparison.Ordinal); + var plain = Cli(Args("resolve", FullContext(platform, architecture), "git", "npm", "node")); + Assert.Equal(policy.ToJsonString(Relaxed), plain.Json!.ToJsonString(Relaxed)); + } + + [Fact] + public void EmptyInputAndUnresolvedSymbolYieldNoPolicy() + { + Assert.Equal("null\n", Cli("resolve", "--platform", "linux", "--architecture", "x64").Stdout); + var empty = Cli("resolve", "--diagnostics", "--platform", "linux", "--architecture", "x64"); + Assert.Equal(0, empty.Status); + Assert.Null(empty.Json!["policy"]); + Assert.DoesNotContain("\"policy\"", empty.Stdout, StringComparison.Ordinal); + var unresolved = Cli("resolve", "--diagnostics", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/usr/bin", "git"); + Assert.Contains("required symbol 'project_root' (needed by tool:git) is unresolved; supply ResolveContext.projectRoot; no policy was returned", unresolved.Warnings, StringComparison.Ordinal); + } +} + +public sealed class ResolutionTests : WorkDirTest +{ + private string MultiCatalog() => WriteCatalog(NewDir("multi-"), new[] + { + Revision(new JsonNode[] + { + Entry("tool:app", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/app" }, { "kind": "invocation-name", "names": ["app"] }] }"""), + Entry("tool:app-plugin", """{ "identity": [{ "kind": "invocation-name", "names": ["app"] }] }"""), + }), + }); + + [Fact] + public void UnknownToolYieldsNoPolicyWithWarning() + { + var result = Cli(Args("resolve", "--diagnostics", FullContext("linux", "x64"), "cargo")); + Assert.Equal(0, result.Status); + Assert.Null(result.Json!["policy"]); + Assert.Equal("""[{"inputIndex":0,"matches":[]}]""", result.Json["diagnostics"]!["tools"]!.ToJsonString(Relaxed)); + Assert.Equal("input 0 ('cargo') matched no eligible catalog entry", result.Warnings); + Assert.Equal("null\n", Cli(Args("resolve", FullContext("linux", "x64"), "cargo")).Stdout); + var library = BundledPolicyCatalog.GetSandboxConfigWithDiagnostics("cargo", new ResolveContext { Platform = "linux", Architecture = "x64" }); + Assert.Null(library.Policy); + Assert.Equal(new[] { "input 0 ('cargo') matched no eligible catalog entry" }, library.Diagnostics.Warnings); + } + + [Fact] + public void WeakIdentityRequiresOptIn() + { + var common = Args("--diagnostics", "--platform", "linux", "--architecture", "x64", SymbolArgs("linux")); + var off = Cli(Args("resolve", common, "git")); + Assert.Equal(0, off.Status); + Assert.Null(off.Json!["policy"]); + Assert.Equal("input 0 ('git') matched no eligible catalog entry: tool:git matched only by invocation name and allowWeakIdentityFallback is not enabled", off.Warnings); + var on = Cli(Args("resolve", common, "--allow-weak", "git")); + Assert.NotNull(on.Json!["policy"]); + Assert.Contains("input 0 ('git') matched tool:git only by invocation name (weak identity)", on.Warnings, StringComparison.Ordinal); + + var catalog = new PolicyCatalog(CatalogStore.Bundled()); + var ctx = new ResolveContext { Platform = "linux", Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["git_prefix"] = "/usr/bin" } }; + Assert.Null(catalog.GetSandboxConfig("git", ctx)); + Assert.NotNull(catalog.GetSandboxConfig(new ToolInput("git"), ctx with { AllowWeakIdentityFallback = true })); + + var strong = Cli(Args("resolve", common, "--purl", "pkg:npm/npm@11.0.0", "npm")); + Assert.NotNull(strong.Json!["policy"]); + Assert.DoesNotContain("weak identity", strong.Warnings, StringComparison.Ordinal); + } + + [Fact] + public void MultiMatchWarnsAndComposesEveryEntry() + { + var dir = MultiCatalog(); + var result = Cli("resolve", "--catalog", dir, "--diagnostics", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "app"); + Assert.Equal(0, result.Status); + Assert.Contains("input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute", result.Warnings, StringComparison.Ordinal); + Assert.Equal("""["/opt/app","/opt/app-plugin"]""", result.Json!["policy"]!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); + var strongOnly = Cli("resolve", "--catalog", dir, "--diagnostics", "--platform", "linux", "--architecture", "x64", "--symbol", "git_prefix=/opt", "--purl", "pkg:npm/app", "app"); + Assert.DoesNotContain("matched 2 entries", strongOnly.Warnings, StringComparison.Ordinal); + } + + [Fact] + public void CompositionConflicts() + { + var overlap = Cli("resolve", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--project-root", "/opt", "--symbol", "git_prefix=/usr/bin", "--symbol", "node_prefix=/opt/node", "git", "node"); + Assert.Equal(1, overlap.Status); + Assert.Equal("composition_conflict", overlap.ErrorReason); + Assert.Contains("overlap across access classes", overlap.ErrorMessage, StringComparison.Ordinal); + + var dir = WriteCatalog(NewDir("compose-"), new[] + { + Revision(new JsonNode[] + { + Entry("tool:a"), + Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "network": { "egress": { "default": "deny" } } } }] }"""), + }), + }); + Assert.Equal(0, Cli("validate", "--catalog", dir).Status); + var alone = Cli("resolve", "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "b"); + Assert.Equal("""{"egress":{"default":"deny"}}""", alone.Json!["network"]!.ToJsonString(Relaxed)); + var both = Cli("resolve", "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a", "b"); + Assert.Equal(1, both.Status); + Assert.Equal("[policy_validation] selected entries cannot be composed: 'tool:b' uses 'network', which has no v1 cross-entry composition rule", both.ErrorMessage); + } + + [Fact] + public void UnavailableRevisionIsNeverSubstituted() + { + var result = Cli(Args("resolve", "--revision", "2099-01-01.1", FullContext("linux", "x64"), "git")); + Assert.Equal(1, result.Status); + Assert.Equal("""{"code":"backend_error","message":"[backend_error] catalog revision '2099-01-01.1' is not installed","details":{"reason":"revision_unavailable"}}""", result.Json!["error"]!.ToJsonString(Relaxed)); + var error = Assert.Throws(() => BundledPolicyCatalog.GetSandboxConfig("git", new ResolveContext { CatalogRevision = "2099-01-01.1", Platform = "linux", Architecture = "x64" })); + Assert.Equal("revision_unavailable", error.Reason); + + var r1 = Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1"); + var r2 = Revision(new JsonNode[] { Entry("tool:a", """{ "entryRevision": 2, "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/v2"] } } }] }""") }, "2000-01-02.1"); + var dir = WriteCatalog(NewDir("revisions-"), new[] { r1, r2 }); + Assert.Equal(0, Cli("validate", "--catalog", dir).Status); + var common = new[] { "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a" }; + Assert.Equal("""["/opt/v2"]""", Cli(Args("resolve", common)).Json!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); + Assert.Equal("""["/opt/a"]""", Cli(Args("resolve", "--revision", "2000-01-01.1", common)).Json!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); + } + + [Theory] + [MemberData(nameof(CliCommandTests.PlatformArch), MemberType = typeof(CliCommandTests))] + public void HostDefaultArchitectureAndNeutralFallbackWarnings(string platform, string architecture) + { + var catalog = new PolicyCatalog(CatalogStore.Bundled(), new FixedHost(platform, architecture)); + var c = PlatformContext[platform]; + var ctx = new ResolveContext { AllowWeakIdentityFallback = true, ProjectRoot = c.Root, Symbols = new Dictionary { ["git_prefix"] = c.Prefix } }; + var omitted = catalog.GetSandboxConfigWithDiagnostics("git", ctx); + Assert.Equal( + new[] + { + "input 0 ('git') matched tool:git only by invocation name (weak identity)", + $"architecture was not specified; variants were selected for the native system architecture '{architecture}'; the tool's architecture was not verified", + $"tool:git uses its architecture-neutral {platform} variant; no {architecture}-specific variant exists", + }, + omitted.Diagnostics.Warnings); + var explicitArch = catalog.GetSandboxConfigWithDiagnostics("git", ctx with { Platform = platform, Architecture = architecture }); + Assert.DoesNotContain(explicitArch.Diagnostics.Warnings, w => w.StartsWith("architecture was not specified", StringComparison.Ordinal)); + Assert.Equal(PolicyCatalogJson.Serialize(omitted.Policy), PolicyCatalogJson.Serialize(explicitArch.Policy)); + + // The packaged CLI detects this machine's native architecture. + var cli = Cli(Args("resolve", "--diagnostics", "--platform", platform, "--allow-weak", SymbolArgs(platform), "git")); + Assert.Equal(0, cli.Status); + Assert.Matches("architecture was not specified; variants were selected for the native system architecture '(x64|arm64)'; the tool's architecture was not verified", cli.Warnings); + } + + [Fact] + public void ArchitectureMismatchIsNoMatchWithSkippedReason() + { + var dir = WriteCatalog(NewDir("arch-"), new[] + { + Revision(new JsonNode[] + { + Entry("tool:a", """ + { "platformVariants": [ + { "when": { "platform": "windows", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\arm64"] } } }, + { "when": { "platform": "windows", "architecture": "x64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\x64"] } } }, + { "when": { "platform": "linux", "architecture": "x64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/x64"] } } } ] } + """), + }), + }); + foreach (var architecture in Architectures) + { + var result = Cli("resolve", "--catalog", dir, "--diagnostics", "--allow-weak", "--platform", "windows", "--architecture", architecture, "--symbol", "git_prefix=C:\\t", "a"); + Assert.Equal($"[\"C:\\\\t\\\\{architecture}\"]", result.Json!["policy"]!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); + Assert.DoesNotContain("architecture-neutral", result.Warnings, StringComparison.Ordinal); + } + + var noArm = Cli("resolve", "--catalog", dir, "--diagnostics", "--allow-weak", "--platform", "linux", "--architecture", "arm64", "--symbol", "git_prefix=/t", "a"); + Assert.Equal(0, noArm.Status); + Assert.Null(noArm.Json!["policy"]); + Assert.Equal("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/arm64", noArm.Warnings); + } +} + +public sealed class IntegrityTests : WorkDirTest +{ + private string CopyInstalled() + { + var dir = NewDir("catalog-"); + CopyDirectory(InstalledCatalogDir, dir); + return dir; + } + + private static string LatestFile(string dir) => + Path.Combine(dir, InstalledManifest["revisions"]!.AsArray()[^1]!["file"]!.GetValue()); + + [Fact] + public void UntouchedCopyValidatesAndFormattingOnlyEditsKeepTheDigest() + { + var dir = CopyInstalled(); + Assert.Equal(0, Cli("validate", "--catalog", dir).Status); + var ctx = FullContext("linux", "x64"); + Assert.Equal(Cli(Args("resolve", ctx, "git")).Stdout, Cli(Args("resolve", "--catalog", dir, ctx, "git")).Stdout); + var file = LatestFile(dir); + File.WriteAllText(file, JsonNode.Parse(File.ReadAllText(file))!.ToJsonString(Relaxed).Replace(",", ",\r\n")); + Assert.Equal(0, Cli("validate", "--catalog", dir).Status); + } + + [Fact] + public void TamperedRevisionFailsIntegrityEverywhere() + { + var dir = CopyInstalled(); + var file = LatestFile(dir); + var data = JsonNode.Parse(File.ReadAllText(file))!; + data["entries"]![0]!["platformVariants"]![0]!["sandboxPolicy"]!["filesystem"]!["readwritePaths"]!.AsArray().Add("${user_home}"); + File.WriteAllText(file, data.ToJsonString(Relaxed)); + + var validate = Cli("validate", "--catalog", dir); + Assert.Equal(1, validate.Status); + Assert.False(validate.Json!["ok"]!.GetValue()); + Assert.Matches("\\[backend_error\\] catalog revision '[^']+' digest [0-9a-f]{64} does not match the published digest", validate.Json["errors"]!.ToJsonString(Relaxed)); + + var resolve = Cli(Args("resolve", "--catalog", dir, FullContext("windows", "x64"), "git")); + Assert.Equal(1, resolve.Status); + Assert.Equal("integrity", resolve.ErrorReason); + Assert.Equal("backend_error", resolve.Json!["error"]!["code"]!.GetValue()); + Assert.Equal("integrity", Cli("inspect", "--catalog", dir).ErrorReason); + + var error = Assert.Throws(() => new PolicyCatalog(CatalogStore.FromDirectory(dir)).GetCatalogInfo()); + Assert.Equal(("backend_error", "integrity"), (error.Code, error.Reason)); + } + + [Fact] + public void MissingRevisionFileAndBadManifest() + { + var dir = CopyInstalled(); + File.Delete(LatestFile(dir)); + Assert.Equal("integrity", Cli("inspect", "--catalog", dir).ErrorReason); + Assert.Contains("could not be read", Cli("validate", "--catalog", dir).Json!["errors"]!.ToJsonString(Relaxed), StringComparison.Ordinal); + + var bad = CopyInstalled(); + var manifest = InstalledManifest; + manifest["defaultRevision"] = "2099-01-01.1"; + File.WriteAllText(Path.Combine(bad, "manifest.json"), manifest.ToJsonString(Relaxed)); + Assert.Equal("invalid_catalog", Cli("inspect", "--catalog", bad).ErrorReason); + Assert.Equal( + """["[policy_validation] manifest.defaultRevision must name a listed revision"]""", + Cli("validate", "--catalog", bad).Json!["errors"]!.ToJsonString(Relaxed)); + } + + [Fact] + public void ValidateRejectsADependencyCycle() + { + var dir = WriteCatalog(NewDir("cycle-"), new[] + { + Revision(new JsonNode[] { DependsOn("tool:a", "tool:b"), DependsOn("tool:b", "tool:c"), DependsOn("tool:c", "tool:a") }), + }); + var result = Cli("validate", "--catalog", dir); + Assert.Equal(1, result.Status); + Assert.False(result.Json!["ok"]!.GetValue()); + Assert.Contains("[policy_validation] 'tool:a' on linux/x64: cycle (tool:a -> tool:b -> tool:c -> tool:a)", result.Json["errors"]![0]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public void ResolveNeverReturnsAPolicyFromACyclicCatalog() + { + var dir = WriteCatalog(NewDir("cycle-"), new[] { Revision(new JsonNode[] { DependsOn("tool:a", "tool:b"), DependsOn("tool:b", "tool:a") }) }); + var result = Cli("resolve", "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a"); + Assert.Equal(1, result.Status); + Assert.Equal("policy_validation", result.Json?["error"]?["code"]?.GetValue()); + Assert.Equal("invalid_catalog", result.ErrorReason); + Assert.Contains("cycle (tool:a -> tool:b -> tool:a)", result.ErrorMessage, StringComparison.Ordinal); + var error = Assert.Throws(() => new PolicyCatalog(CatalogStore.FromDirectory(dir)).GetSandboxConfig("a", new ResolveContext { AllowWeakIdentityFallback = true, Platform = "linux", Architecture = "x64" })); + Assert.Equal("invalid_catalog", error.Reason); + } + + [Fact] + public void DiamondIsValidAndContributesTheSharedEntryOnce() + { + var dir = WriteCatalog(NewDir("diamond-"), new[] + { + Revision(new JsonNode[] + { + Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:b" }, { "entryId": "tool:c" }], "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/a"] } } }] }"""), + DependsOn("tool:b", "tool:d"), + DependsOn("tool:c", "tool:d"), + Entry("tool:d"), + }), + }); + Assert.Equal(0, Cli("validate", "--catalog", dir).Status); + var result = Cli("resolve", "--catalog", dir, "--diagnostics", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a"); + Assert.Equal("""["/opt/a","/opt/d-dep","/opt/d"]""", result.Json!["policy"]!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); + } +} diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs new file mode 100644 index 000000000..3531f3364 --- /dev/null +++ b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Shared helpers for the functional suite. The library is the packed +// Microsoft.Mxc.PolicyCatalog package restored from a local feed, and the CLI +// is a consumer build of policy-catalog against that same package; the driver +// (scripts/dotnet-functional.mjs) sets POLICY_CATALOG_CLI_DLL and +// POLICY_CATALOG_REPO_ROOT. Nothing here reads the repository's sources. +using System.Diagnostics; +using System.Text; +using System.Text.Json.Nodes; +using Xunit; + +namespace Microsoft.Mxc.PolicyCatalog.FunctionalTests; + +internal sealed record CliResult(int Status, string Stdout, string Stderr) +{ + public JsonNode? Json => string.IsNullOrWhiteSpace(Stdout) ? null : JsonNode.Parse(Stdout); + + public string Warnings => string.Join("\n", Json?["diagnostics"]?["warnings"]?.AsArray().Select(w => w!.GetValue()) ?? Array.Empty()); + + public string? ErrorReason => Json?["error"]?["details"]?["reason"]?.GetValue(); + + public string ErrorMessage => Json?["error"]?["message"]?.GetValue() ?? string.Empty; +} + +internal sealed class FixedHost : IHostEnvironment +{ + private readonly string _platform; + private readonly string _architecture; + + public FixedHost(string platform, string architecture) + { + _platform = platform; + _architecture = architecture; + } + + public string Platform() => _platform; + + public string NativeArchitecture() => _architecture; + + public string? Symbol(string name) => null; +} + +internal static class Fx +{ + /// JSON.stringify-like escaping for comparisons (System.Text.Json escapes quotes by default). + public static readonly System.Text.Json.JsonSerializerOptions Relaxed = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver() }; + + public static readonly string[] Platforms = { "windows", "linux", "macos" }; + public static readonly string[] Architectures = { "x64", "arm64" }; + + public static string CliDll => Environment.GetEnvironmentVariable("POLICY_CATALOG_CLI_DLL") + ?? throw new InvalidOperationException("POLICY_CATALOG_CLI_DLL is not set; run the functional suite through scripts/dotnet-functional.mjs"); + + /// The catalog directory shipped in the package (contentFiles copied to output). + public static string InstalledCatalogDir => Path.Combine(AppContext.BaseDirectory, "catalog"); + + public static JsonNode InstalledManifest => JsonNode.Parse(File.ReadAllText(Path.Combine(InstalledCatalogDir, "manifest.json")))!; + + public static CliResult Cli(params string[] args) + { + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + StandardOutputEncoding = new UTF8Encoding(false), + StandardErrorEncoding = new UTF8Encoding(false), + WorkingDirectory = Path.GetTempPath(), + }; + start.ArgumentList.Add(CliDll); + foreach (var arg in args) + { + start.ArgumentList.Add(arg); + } + + using var process = Process.Start(start)!; + var stderr = process.StandardError.ReadToEndAsync(); + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return new CliResult(process.ExitCode, stdout, stderr.Result); + } + + public static readonly Dictionary PlatformContext = new() + { + ["windows"] = ("C:\\work\\app", "C:\\tools", "C:\\cache\\npm"), + ["linux"] = ("/work/app", "/opt/tools", "/var/cache/npm"), + ["macos"] = ("/Users/dev/app", "/opt/homebrew/bin", "/Users/dev/.npm"), + }; + + public static string[] SymbolArgs(string platform) + { + var c = PlatformContext[platform]; + return new[] + { + "--project-root", c.Root, + "--symbol", $"git_prefix={c.Prefix}", + "--symbol", $"node_prefix={c.Prefix}", + "--symbol", $"npm_prefix={c.Prefix}", + "--symbol", $"npm_cache={c.Cache}", + }; + } + + public static string[] FullContext(string platform, string architecture) => + new[] { "--platform", platform, "--architecture", architecture, "--allow-weak" }.Concat(SymbolArgs(platform)).ToArray(); + + public static string[] Args(params object[] parts) => + parts.SelectMany(part => part is string s ? new[] { s } : (IEnumerable)part).ToArray(); + + public static JsonObject Entry(string entryId, string? overrides = null) + { + var name = entryId.Split(':')[1]; + var entry = new JsonObject + { + ["entryId"] = entryId, + ["entryRevision"] = 1, + ["displayName"] = name, + ["identity"] = JsonNode.Parse($$"""[{ "kind": "invocation-name", "names": ["{{name}}"] }]"""), + ["platformVariants"] = JsonNode.Parse($$$"""[{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/{{{name}}}"] } } }]"""), + ["provenance"] = JsonNode.Parse("""{ "method": "functional-test", "sourceRevision": "functional-test" }"""), + }; + if (overrides is not null) + { + foreach (var (key, value) in JsonNode.Parse(overrides)!.AsObject().ToList()) + { + entry[key] = value?.DeepClone(); + } + } + + return entry; + } + + public static JsonObject DependsOn(string entryId, string target) + { + var name = target.Split(':')[1]; + return Entry(entryId, $$$"""{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "{{{target}}}" }], "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/{{{name}}}-dep"] } } }] }"""); + } + + public static JsonObject Revision(IEnumerable entries, string catalogRevision = "2000-01-01.1") => new() + { + ["catalogSchemaVersion"] = "1", + ["catalogRevision"] = catalogRevision, + ["entries"] = new JsonArray(entries.ToArray()), + }; + + /// A complete catalog directory with correct digests, so the only defect is the one a test adds. + public static string WriteCatalog(string dir, IReadOnlyList revisions, string? defaultRevision = null) + { + Directory.CreateDirectory(Path.Combine(dir, "revisions")); + File.Copy(Path.Combine(InstalledCatalogDir, "contract.v1.json"), Path.Combine(dir, "contract.v1.json"), true); + var list = new JsonArray(); + foreach (var revision in revisions) + { + var id = revision["catalogRevision"]!.GetValue(); + var file = $"revisions/{id}.json"; + var text = revision.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(dir, file), text + "\n"); + list.Add(new JsonObject { ["catalogRevision"] = id, ["file"] = file, ["sha256"] = PolicyCatalogJson.CanonicalSha256(text) }); + } + + var manifest = new JsonObject + { + ["catalogSchemaVersion"] = "1", + ["defaultRevision"] = defaultRevision ?? revisions[^1]["catalogRevision"]!.GetValue(), + ["revisions"] = list, + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), manifest.ToJsonString() + "\n"); + return dir; + } + + public static void CopyDirectory(string from, string to) + { + Directory.CreateDirectory(to); + foreach (var file in Directory.GetFiles(from, "*", SearchOption.AllDirectories)) + { + var target = Path.Combine(to, Path.GetRelativePath(from, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, true); + } + } +} + +/// A per-test temporary directory outside the repository. +public abstract class WorkDirTest : IDisposable +{ + protected WorkDirTest() + { + Work = Path.Combine(Path.GetTempPath(), "policy-catalog-dotnet-functional-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Work); + } + + protected string Work { get; } + + protected string NewDir(string prefix) + { + var dir = Path.Combine(Work, prefix + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(dir); + return dir; + } + + public void Dispose() + { + try + { + foreach (var file in Directory.GetFiles(Work, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(Work, true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + GC.SuppressFinalize(this); + } +} + +public sealed class PackageTests +{ + [Fact] + public void LibraryComesFromTheRestoredPackageNotTheSourceTree() + { + var location = typeof(PolicyCatalog).Assembly.Location; + var repo = Environment.GetEnvironmentVariable("POLICY_CATALOG_REPO_ROOT"); + Assert.False(string.IsNullOrEmpty(repo), "POLICY_CATALOG_REPO_ROOT is not set"); + Assert.False(location.StartsWith(Path.GetFullPath(repo!), StringComparison.OrdinalIgnoreCase), $"{location} is inside the repository {repo}"); + Assert.False(Fx.CliDll.StartsWith(Path.GetFullPath(repo!), StringComparison.OrdinalIgnoreCase), $"{Fx.CliDll} is inside the repository"); + var packages = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + Assert.False(string.IsNullOrEmpty(packages)); + Assert.True(File.Exists(Path.Combine(packages!, "microsoft.mxc.policycatalog", "0.0.0-prototype", "lib", "net8.0", "Microsoft.Mxc.PolicyCatalog.dll"))); + Assert.True(File.Exists(Path.Combine(Fx.InstalledCatalogDir, "manifest.json"))); + } +} diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj new file mode 100644 index 000000000..6cb94cb64 --- /dev/null +++ b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj @@ -0,0 +1,24 @@ + + + + + net8.0 + Exe + enable + enable + latest + false + true + 0.0.0-prototype + + + + + + + + diff --git a/policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj b/policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj new file mode 100644 index 000000000..b7808fe22 --- /dev/null +++ b/policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj @@ -0,0 +1,26 @@ + + + + + net8.0 + Exe + enable + enable + latest + true + policy-catalog + false + false + 0.0.0-prototype + + + + + + + diff --git a/policy-catalog/dotnet/global.json b/policy-catalog/dotnet/global.json new file mode 100644 index 000000000..0777cf772 --- /dev/null +++ b/policy-catalog/dotnet/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/policy-catalog/dotnet/tools/compare-cli.mjs b/policy-catalog/dotnet/tools/compare-cli.mjs new file mode 100644 index 000000000..73573284a --- /dev/null +++ b/policy-catalog/dotnet/tools/compare-cli.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Spot comparison of the .NET CLI against the TypeScript CLI (node dist/cli.js). +// Feeds identical arguments to both, then compares exit codes, stderr (usage +// errors), and stdout JSON canonicalized with sorted keys, after replacing the +// absolute catalog directory with a placeholder and truncating OS I/O error +// text after "could not be read: ". +// +// npm run build (once, from policy-catalog/) +// dotnet build dotnet/Microsoft.Mxc.PolicyCatalog.slnx -c Release +// node dotnet/tools/compare-cli.mjs +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { canonicalJson, canonicalSha256 } from '../../dist/canonical-json.js'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const tsCli = join(root, 'dist', 'cli.js'); +const csCli = join(root, 'dotnet', 'Microsoft.Mxc.PolicyCatalog.Cli', 'bin', 'Release', 'net8.0', 'policy-catalog.dll'); +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-compare-')); + +function writeCatalog(dir, revisions) { + mkdirSync(join(dir, 'revisions'), { recursive: true }); + cpSync(join(root, 'catalog', 'contract.v1.json'), join(dir, 'contract.v1.json')); + const manifest = { catalogSchemaVersion: '1', defaultRevision: revisions.at(-1).catalogRevision, revisions: [] }; + for (const r of revisions) { + const file = `revisions/${r.catalogRevision}.json`; + writeFileSync(join(dir, file), JSON.stringify(r, null, 2)); + manifest.revisions.push({ catalogRevision: r.catalogRevision, file, sha256: canonicalSha256(r) }); + } + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + return dir; +} +const entry = (id, deps = []) => ({ + entryId: id, entryRevision: 1, displayName: id, identity: [{ kind: 'invocation-name', names: [id.split(':')[1]] }], + platformVariants: [{ when: { platform: 'linux' }, ...(deps.length ? { dependencies: deps.map(entryId => ({ entryId })) } : {}), sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${id.split(':')[1]}`] } } }], + provenance: { method: 'cmp', sourceRevision: 'cmp' }, +}); + +const tampered = join(work, 'tampered'); +cpSync(join(root, 'catalog'), tampered, { recursive: true }); +const revFile = join(tampered, 'revisions', '2026-09-29.1.json'); +const data = JSON.parse(readFileSync(revFile, 'utf8')); +data.entries[0].platformVariants[0].sandboxPolicy.filesystem.readwritePaths.push('${user_home}'); +writeFileSync(revFile, JSON.stringify(data)); +const cyclic = writeCatalog(join(work, 'cyclic'), [{ catalogSchemaVersion: '1', catalogRevision: '2000-01-01.1', entries: [entry('tool:a', ['tool:b']), entry('tool:b', ['tool:c']), entry('tool:c', ['tool:a'])] }]); +const missing = join(work, 'missing'); +cpSync(join(root, 'catalog'), missing, { recursive: true }); +rmSync(join(missing, 'revisions', '2026-09-29.1.json')); + +const ctx = { + windows: ['--project-root', 'C:\\work\\app', '--symbol', 'git_prefix=C:\\tools', '--symbol', 'node_prefix=C:\\tools', '--symbol', 'npm_prefix=C:\\tools', '--symbol', 'npm_cache=C:\\cache\\npm', '--symbol', 'user_home=C:\\Users\\u', '--symbol', 'temp_dir=C:\\t'], + linux: ['--project-root', '/work/app', '--symbol', 'git_prefix=/opt/tools', '--symbol', 'node_prefix=/opt/tools', '--symbol', 'npm_prefix=/opt/tools', '--symbol', 'npm_cache=/var/cache/npm', '--symbol', 'user_home=/home/u', '--symbol', 'temp_dir=/tmp'], + macos: ['--project-root', '/Users/dev/app', '--symbol', 'git_prefix=/opt/homebrew/bin', '--symbol', 'node_prefix=/opt/homebrew/bin', '--symbol', 'npm_prefix=/opt/homebrew/bin', '--symbol', 'npm_cache=/Users/dev/.npm', '--symbol', 'user_home=/Users/dev', '--symbol', 'temp_dir=/tmp'], +}; +const cases = [ + ['inspect'], ['validate'], ['validate', '--catalog', join(root, 'catalog')], + ['validate', '--catalog', tampered], ['inspect', '--catalog', tampered], ['resolve', '--catalog', tampered, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', ...ctx.linux, 'git'], + ['validate', '--catalog', cyclic], ['resolve', '--catalog', cyclic, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'], + ['validate', '--catalog', missing], ['inspect', '--catalog', missing], ['inspect', '--catalog', join(work, 'nope')], ['validate', '--catalog', join(work, 'nope')], + ['validate', '--base-ref', 'HEAD'], ['validate', '--base-ref', '-h'], ['validate', '--base-ref', 'no-such-ref-xyz'], ['validate', '--catalog', cyclic, '--base-ref', 'HEAD'], +]; +for (const platform of ['windows', 'linux', 'macos']) { + for (const architecture of ['x64', 'arm64']) { + for (const diag of [[], ['--diagnostics']]) { + cases.push(['resolve', ...diag, '--platform', platform, '--architecture', architecture, '--allow-weak', ...ctx[platform], 'git', 'npm', 'node']); + cases.push(['resolve', ...diag, '--platform', platform, '--architecture', architecture, ...ctx[platform], 'git', 'npm', 'node']); + } + } + cases.push(['resolve', '--diagnostics', '--platform', platform, '--allow-weak', ...ctx[platform], 'git', 'npm', 'node']); +} +for (const diag of [[], ['--diagnostics']]) { + cases.push( + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', ...ctx.linux, '--purl', 'pkg:npm/npm@11.0.0', 'npm'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', ...ctx.linux, '--purl', 'pkg:npm/npm@11.0.0', '--detected-version', 'nightly', 'npm'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', ...ctx.linux, 'cargo'], + ['resolve', ...diag, '--revision', '2099-01-01.1', '--platform', 'linux', '--architecture', 'x64', 'git'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'mips', 'git'], + ['resolve', ...diag, '--platform', 'plan9', 'git'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'node_prefix=bin', 'node'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', '__proto__=/x', 'node'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'project_root=/x', 'git'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/g', 'git'], + ['resolve', ...diag, '--allow-weak', ...ctx.windows, 'git', 'npm', 'node'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], + ['resolve', ...diag, '--platform', 'windows', '--architecture', 'x64', '--allow-weak', '--project-root', 'C:\\W', '--symbol', 'git_prefix=c:\\w\\bin', 'GIT.EXE'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', ...ctx.linux, 'GIT'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--purl', 'not-a-purl', 'npm'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--purl', 'pkg:npm/npm@%E0%A4%A', 'npm'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', './bin/git'], + ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64'], + ); +} +cases.push( + [], ['bogus'], ['info'], ['resolve', '--platform'], ['resolve', '--symbol', 'noequals', 'git'], ['resolve', '--symbol', '=x', 'git'], + ['resolve', '--purl', 'pkg:npm/npm'], ['resolve', '--base-ref', 'HEAD', 'git'], ['inspect', 'extra'], ['validate', 'extra'], + ['validate', '--base-ref'], ['resolve', '--catalog'], ['resolve', '--platform', '--allow-weak', 'git'], ['resolve', '--bogus'], + ['inspect', '--base-ref', 'HEAD'], +); + +function normalize(stdout, dirs) { + if (stdout.trim() === '') { + return ''; + } + let text = stdout; + for (const dir of dirs) { + text = text.split(JSON.stringify(dir).slice(1, -1)).join(''); + } + const value = JSON.parse(text); + const truncate = v => (typeof v === 'string' && v.includes('could not be read: ') ? v.slice(0, v.indexOf('could not be read: ') + 19) : v); + const walk = v => (Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(truncate(x))])) : truncate(v)); + return canonicalJson(walk(value)); +} + +let mismatches = 0; +const bundledDirs = [join(root, 'catalog'), join(root, 'dotnet', 'Microsoft.Mxc.PolicyCatalog.Cli', 'bin', 'Release', 'net8.0', 'catalog')]; +for (const args of cases) { + const ts = spawnSync(process.execPath, [tsCli, ...args], { cwd: root, encoding: 'utf8' }); + const cs = spawnSync('dotnet', [csCli, ...args], { cwd: root, encoding: 'utf8' }); + const dirs = [...bundledDirs, ...args.filter((_, i) => args[i - 1] === '--catalog').map(d => join(d))].map(d => d.replace(/[\\/]+$/, '')); + const a = { status: ts.status, out: normalize(ts.stdout, dirs), err: ts.stderr }; + const b = { status: cs.status, out: normalize(cs.stdout, dirs), err: cs.stderr.replace(/\r\n/g, '\n') }; + const same = a.status === b.status && a.out === b.out && a.err === b.err; + if (!same) { + mismatches += 1; + console.log(`MISMATCH: ${args.join(' ')}\n ts: ${a.status} ${a.out} ${a.err}\n cs: ${b.status} ${b.out} ${b.err}`); + } +} +console.log(`${cases.length} cases, ${cases.length - mismatches} identical, ${mismatches} mismatched`); +rmSync(work, { recursive: true, force: true }); +process.exit(mismatches === 0 ? 0 : 1); diff --git a/policy-catalog/scripts/dotnet-functional.mjs b/policy-catalog/scripts/dotnet-functional.mjs new file mode 100644 index 000000000..d9ce20ce3 --- /dev/null +++ b/policy-catalog/scripts/dotnet-functional.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// .NET functional test driver. Tests the package a consumer would install, +// not the source tree: +// +// 1. `dotnet pack` Microsoft.Mxc.PolicyCatalog into a local feed in a +// temporary directory outside the repository (os.tmpdir()). +// 2. Copy the CLI (dotnet/functional/policy-catalog-cli-consumer + the CLI +// Program.cs) and the functional test project +// (dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests) into +// temporary consumer directories with a generated nuget.config +// (local feed + nuget.org) and an isolated NUGET_PACKAGES directory, so a +// stale cached package can never be used. +// 3. Build the CLI against the package, then build and run the tests with +// POLICY_CATALOG_CLI_DLL pointing at that CLI build. +// +// node scripts/dotnet-functional.mjs (from policy-catalog/) +// +// Exits nonzero on any failure. Set POLICY_CATALOG_KEEP_TEMP=1 to keep the +// temporary directory for inspection. Third-party packages (xunit.v3) come +// from nuget.org; set POLICY_CATALOG_NUGET_UPSTREAM to a nuget.org mirror URL +// where nuget.org is not directly reachable. +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const dotnetDir = join(root, 'dotnet'); +const version = '0.0.0-prototype'; +const upstream = process.env.POLICY_CATALOG_NUGET_UPSTREAM || 'https://api.nuget.org/v3/index.json'; + +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-dotnet-functional-')); +const outside = relative(root, work); +if (!(outside.startsWith('..') || isAbsolute(outside))) { + console.error(`temporary directory ${work} is inside the repository`); + process.exit(1); +} +const feed = join(work, 'feed'); +const packages = join(work, 'nuget-packages'); +mkdirSync(feed); +mkdirSync(packages); + +const env = { + ...process.env, + NUGET_PACKAGES: packages, + DOTNET_CLI_TELEMETRY_OPTOUT: '1', + DOTNET_NOLOGO: '1', + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1', + // Consumers must not inherit the repository's global.json or Directory.* files. + MSBUILDDISABLENODEREUSE: '1', +}; + +function dotnet(args, cwd, extraEnv = {}) { + console.log(`\n> dotnet ${args.join(' ')} (cwd ${cwd})`); + const result = spawnSync('dotnet', args, { cwd, env: { ...env, ...extraEnv }, stdio: 'inherit' }); + if (result.error) { + throw result.error; + } + return result.status ?? 1; +} + +function must(status, what) { + if (status !== 0) { + throw new Error(`${what} failed with exit code ${status}`); + } +} + +const nugetConfig = ` + + + + + + + + + + + + + + + +`; +// The consumers pin the same SDK policy as the repository (MTP test runner). +const globalJson = `${JSON.stringify({ sdk: { version: '10.0.401', rollForward: 'latestFeature' }, test: { runner: 'Microsoft.Testing.Platform' } }, null, 2)}\n`; + +function consumer(name, template) { + const dir = join(work, name); + cpSync(template, dir, { + recursive: true, + filter: source => !/[\\/](bin|obj|TestResults)([\\/]|$)/.test(relative(template, source)), + }); + writeFileSync(join(dir, 'nuget.config'), nugetConfig); + writeFileSync(join(dir, 'global.json'), globalJson); + // Stop MSBuild from importing Directory.Build.* from parent directories. + writeFileSync(join(dir, 'Directory.Build.props'), '\n'); + writeFileSync(join(dir, 'Directory.Build.targets'), '\n'); + return dir; +} + +let status = 0; +try { + // 1. Pack. + must(dotnet(['pack', join(dotnetDir, 'Microsoft.Mxc.PolicyCatalog', 'Microsoft.Mxc.PolicyCatalog.csproj'), '-c', 'Release', '-o', feed, '-p:ContinuousIntegrationBuild=true'], dotnetDir), 'dotnet pack'); + const nupkgs = readdirSync(feed).filter(f => f.endsWith('.nupkg')); + if (!nupkgs.includes(`Microsoft.Mxc.PolicyCatalog.${version}.nupkg`)) { + throw new Error(`expected Microsoft.Mxc.PolicyCatalog.${version}.nupkg in ${feed}, found ${nupkgs.join(', ')}`); + } + console.log(`\nPacked ${nupkgs.join(', ')} into ${feed}`); + + // 2a. CLI consumer, built against the package. + const cliDir = consumer('cli', join(dotnetDir, 'functional', 'policy-catalog-cli-consumer')); + cpSync(join(dotnetDir, 'Microsoft.Mxc.PolicyCatalog.Cli', 'Program.cs'), join(cliDir, 'Program.cs')); + must(dotnet(['build', '-c', 'Release', '-o', join(cliDir, 'out')], cliDir), 'CLI consumer build'); + const cliDll = join(cliDir, 'out', 'policy-catalog.dll'); + if (!existsSync(cliDll) || !existsSync(join(cliDir, 'out', 'catalog', 'manifest.json'))) { + throw new Error(`CLI consumer build did not produce ${cliDll} with its catalog directory`); + } + + // 2b. Functional tests consumer. + const testDir = consumer('tests', join(dotnetDir, 'functional', 'Microsoft.Mxc.PolicyCatalog.FunctionalTests')); + must(dotnet(['build', '-c', 'Release'], testDir), 'functional test build'); + + // 3. Run. xUnit v3 test projects are executables; MTP prints the counts. + status = dotnet(['test', '--project', join(testDir, 'Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj'), '-c', 'Release', '--no-build'], testDir, { + POLICY_CATALOG_CLI_DLL: cliDll, + POLICY_CATALOG_REPO_ROOT: root, + }); + console.log(status === 0 ? '\n.NET functional tests passed.' : `\n.NET functional tests FAILED (exit ${status}).`); +} catch (error) { + console.error(`\n${error.message}`); + status = 1; +} finally { + if (process.env.POLICY_CATALOG_KEEP_TEMP === '1') { + console.log(`kept ${work}`); + } else { + rmSync(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } +} +process.exit(status); From 44b223969e43045a42c92e9a03362ac394fb247f Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 12:30:48 -0700 Subject: [PATCH 14/50] ci(policy-catalog): check TS, Rust, and .NET parity across OS and architecture Adds scripts/cross-language-check.mjs (73 cases: resolve, inspect, validate, library failures, usage errors; byte-identical normalized JSON across the three CLIs), runs every language plus the check on linux/macos/windows x64 and arm64 GitHub-hosted runners in both the in-repo and standalone workflows, extends check:extract to the Rust and .NET toolchains, and documents the bindings. --- .github/workflows/PolicyCatalog.Prototype.yml | 74 +++- policy-catalog/.github/workflows/ci.yml | 75 ++++- policy-catalog/README.md | 32 +- policy-catalog/docs/architecture.md | 38 ++- policy-catalog/docs/contributing.md | 43 ++- policy-catalog/scripts/check-extractable.mjs | 20 +- policy-catalog/scripts/ci-ensure-rustup.mjs | 44 +++ .../scripts/cross-language-check.mjs | 315 ++++++++++++++++++ 8 files changed, 604 insertions(+), 37 deletions(-) create mode 100644 policy-catalog/scripts/ci-ensure-rustup.mjs create mode 100644 policy-catalog/scripts/cross-language-check.mjs diff --git a/.github/workflows/PolicyCatalog.Prototype.yml b/.github/workflows/PolicyCatalog.Prototype.yml index 4f60d6512..09f98c5bd 100644 --- a/.github/workflows/PolicyCatalog.Prototype.yml +++ b/.github/workflows/PolicyCatalog.Prototype.yml @@ -1,6 +1,7 @@ # Runs the self-contained policy-catalog prototype checks while it is nested # in this repository. After extraction, the directory's own workflow -# (policy-catalog/.github/workflows/ci.yml) takes over. +# (policy-catalog/.github/workflows/ci.yml) takes over. The two workflows run +# the same steps; only the working directory differs. name: Policy Catalog Prototype on: @@ -14,22 +15,31 @@ permissions: contents: read jobs: - policy-catalog: - name: Policy catalog (${{ matrix.os_label }}) + check: + # One job per OS x architecture runs every language and the cross-language + # check on that host, so the comparison uses binaries built on it. + name: Policy catalog (${{ matrix.label }}) strategy: fail-fast: false matrix: include: - - os_label: windows - runner: windows-latest - - os_label: linux + - label: linux-x64 runner: ubuntu-latest - - os_label: macos + - label: linux-arm64 + runner: ubuntu-24.04-arm + - label: macos-arm64 runner: macos-latest + - label: macos-x64 + runner: macos-13 + - label: windows-x64 + runner: windows-latest + - label: windows-arm64 + runner: windows-11-arm runs-on: ${{ matrix.runner }} defaults: run: working-directory: policy-catalog + shell: bash steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -41,18 +51,60 @@ jobs: cache: npm cache-dependency-path: policy-catalog/package-lock.json - - run: npm ci + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: policy-catalog/dotnet/global.json + + # rust/rust-toolchain.toml pins the channel; rustup installs it on first + # use. Some images (windows-11-arm) ship without rustup. + - name: Rust toolchain + run: node scripts/ci-ensure-rustup.mjs - - run: npm run check + - name: Host facts + run: | + echo "runner.arch=${{ runner.arch }} uname=$(uname -m)" + node -p "process.arch + ' ' + require('os').machine()" + (cd rust && rustc -vV) + dotnet --info | head -n 20 + # --- TypeScript ------------------------------------------------------- + - run: npm ci + - name: TypeScript check (typecheck, unit, functional from the tarball, validate, pack, smoke) + run: npm run check - name: Published-revision immutability if: github.event_name == 'pull_request' - shell: bash # Passed through env, never interpolated into the script. env: POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} run: npm run validate -- "--base-ref=$POLICY_CATALOG_BASE_REF" + # --- Rust ------------------------------------------------------------- + - name: Rust fmt + working-directory: policy-catalog/rust + run: cargo fmt --check + - name: Rust clippy + working-directory: policy-catalog/rust + run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Rust test + working-directory: policy-catalog/rust + run: cargo test --locked + - name: Rust functional (packaged .crate in a consumer outside the repo) + run: node scripts/rust-functional.mjs + + # --- .NET ------------------------------------------------------------- + - name: .NET build + working-directory: policy-catalog/dotnet + run: dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release + - name: .NET test + working-directory: policy-catalog/dotnet + run: dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release --no-build + - name: .NET functional (packed .nupkg from a local feed, consumer outside the repo) + run: node scripts/dotnet-functional.mjs + + # --- Cross-language --------------------------------------------------- + - name: Cross-language check (TS, Rust, C# CLIs byte-identical) + run: node scripts/cross-language-check.mjs + - name: Extractability (policy-catalog/ as a standalone repository) - if: matrix.os_label == 'linux' + if: matrix.label == 'linux-x64' run: npm run check:extract diff --git a/policy-catalog/.github/workflows/ci.yml b/policy-catalog/.github/workflows/ci.yml index 3ceadf9a6..e500c1637 100644 --- a/policy-catalog/.github/workflows/ci.yml +++ b/policy-catalog/.github/workflows/ci.yml @@ -1,5 +1,7 @@ -# CI for the standalone policy catalog repository. Mirrors the MXC SDK unit -# and integration test jobs: Node 24, npm ci, and a per-OS matrix. +# CI for the standalone policy catalog repository. Mirrors the in-repo +# prototype workflow (.github/workflows/PolicyCatalog.Prototype.yml in MXC): +# one job per OS x architecture runs TypeScript, Rust, and .NET, then the +# cross-language check with the binaries built on that host. name: Policy Catalog CI on: @@ -12,18 +14,27 @@ permissions: jobs: check: - name: Check (${{ matrix.os_label }}) + name: Check (${{ matrix.label }}) strategy: fail-fast: false matrix: include: - - os_label: windows - runner: windows-latest - - os_label: linux + - label: linux-x64 runner: ubuntu-latest - - os_label: macos + - label: linux-arm64 + runner: ubuntu-24.04-arm + - label: macos-arm64 runner: macos-latest + - label: macos-x64 + runner: macos-13 + - label: windows-x64 + runner: windows-latest + - label: windows-arm64 + runner: windows-11-arm runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -35,20 +46,58 @@ jobs: cache: npm cache-dependency-path: package-lock.json - - run: npm ci + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: dotnet/global.json + + - name: Rust toolchain + run: node scripts/ci-ensure-rustup.mjs - # typecheck, unit, functional (from the packed tarball), catalog validation, - # pack contents, install smoke - - run: npm run check + - name: Host facts + run: | + echo "runner.arch=${{ runner.arch }} uname=$(uname -m)" + node -p "process.arch + ' ' + require('os').machine()" + (cd rust && rustc -vV) + dotnet --info | head -n 20 + # --- TypeScript ------------------------------------------------------- + - run: npm ci + - name: TypeScript check (typecheck, unit, functional from the tarball, validate, pack, smoke) + run: npm run check - name: Published-revision immutability if: github.event_name == 'pull_request' - shell: bash # Passed through env, never interpolated into the script. env: POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} run: npm run validate -- "--base-ref=$POLICY_CATALOG_BASE_REF" + # --- Rust ------------------------------------------------------------- + - name: Rust fmt + working-directory: rust + run: cargo fmt --check + - name: Rust clippy + working-directory: rust + run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Rust test + working-directory: rust + run: cargo test --locked + - name: Rust functional (packaged .crate in a consumer outside the repo) + run: node scripts/rust-functional.mjs + + # --- .NET ------------------------------------------------------------- + - name: .NET build + working-directory: dotnet + run: dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release + - name: .NET test + working-directory: dotnet + run: dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release --no-build + - name: .NET functional (packed .nupkg from a local feed, consumer outside the repo) + run: node scripts/dotnet-functional.mjs + + # --- Cross-language --------------------------------------------------- + - name: Cross-language check (TS, Rust, C# CLIs byte-identical) + run: node scripts/cross-language-check.mjs + - name: Extractability (HEAD tree as a standalone repository) - if: matrix.os_label == 'linux' + if: matrix.label == 'linux-x64' run: npm run check:extract diff --git a/policy-catalog/README.md b/policy-catalog/README.md index e9b4b6e6b..fa47e300f 100644 --- a/policy-catalog/README.md +++ b/policy-catalog/README.md @@ -30,10 +30,13 @@ git filter-repo --subdirectory-filter policy-catalog | `catalog/revisions/.json` | Immutable published catalog revisions. | | `schema/` | JSON Schemas for revisions and the manifest. | | `conformance/fixtures/` | Language-neutral conformance cases that every language binding must pass. | +| `conformance/vectors/` | Generated path-normalization and canonical-JSON vectors (`scripts/generate-vectors.mjs`) shared by every language. | | `src/` | TypeScript/JavaScript library and the `policy-catalog` CLI. | +| `rust/` | Rust crate `mxc-policy-catalog` (library and `policy-catalog` binary). Its own Cargo `[workspace]`. | +| `dotnet/` | .NET library `Microsoft.Mxc.PolicyCatalog` (net8.0), its CLI, and tests. | | `tests/unit/` | Unit and conformance tests (`node:test`), with their own `tsconfig.json` and `run-tests.js`. | | `tests/functional/` | End-to-end tests against the packed and installed tarball: the installed CLI and library, the real catalog, and tampered or synthetic catalogs. Own `tsconfig.json` and `run-tests.js`. | -| `scripts/` | Validation, digest, package, and extractability checks. | +| `scripts/` | Validation, digest, package, extractability, per-language functional, and cross-language checks. | | `.github/workflows/ci.yml` | CI for the standalone repository. | ## Library API @@ -136,11 +139,21 @@ npm run check:pack npm run check:extract # build and test HEAD's copy of this directory as a standalone repository ``` +The Rust and .NET libraries are developed with their own toolchains; see +[docs/contributing.md](docs/contributing.md) for their commands. The +cross-language check builds all three CLIs and requires identical normalized +JSON output from each: + +```text +npm run build +node scripts/cross-language-check.mjs +``` + See [docs/contributing.md](docs/contributing.md) before changing catalog data. ## Status: implemented and deferred -**Implemented (TypeScript):** +**Implemented (TypeScript, Rust, and C#/.NET):** - the four version dimensions - canonical-digest integrity @@ -157,20 +170,27 @@ See [docs/contributing.md](docs/contributing.md) before changing catalog data. - the contribution validation pipeline - package inclusion and offline install checks - the extractability check +- native Rust (crate `mxc-policy-catalog`, `rust/`) and C#/.NET (NuGet + `Microsoft.Mxc.PolicyCatalog`, net8.0, `dotnet/`) libraries and CLIs, each + with unit, conformance, and packaged-artifact functional tests +- a cross-language check that requires byte-identical normalized CLI output + from all three languages, run on Linux, macOS, and Windows (x64 and arm64) + in CI **Deferred:** -- Rust (Cargo) and C#/.NET (NuGet) libraries +- Publishing to crates.io or NuGet, signing, and versioning policy for the + Rust and .NET packages ([design §6.1](docs/design.md#61-library-distribution-and-consumption)). - `conformance/fixtures/` is the shared contract they must pass. + Both are packaged and consumed only from local artifacts here. - Validation of embedded policies against MXC's real released `SandboxPolicy` schemas ([design §4.2](docs/design.md#42-entry-shape)). The prototype validates a catalog-supported subset and pins `0.8.0-alpha` and `0.9.0-alpha` in `catalog/contract.v1.json`. - Integration tests that run a real tool under an MXC sandbox ([design §12](docs/design.md#12-test-plan), "Integration"). -- Verification on real ARM64 and emulated hosts. Native-architecture detection - is covered with an injected host. +- Verification on emulated hosts (x64 code on ARM64). CI runs natively on + ARM64 runners; emulation is covered only with an injected host. - Private or enterprise overlays ([design §13](docs/design.md#13-open-questions)). - Named-role review enforcement. That belongs to repository governance, not diff --git a/policy-catalog/docs/architecture.md b/policy-catalog/docs/architecture.md index 364fd2f40..9f9b1b47d 100644 --- a/policy-catalog/docs/architecture.md +++ b/policy-catalog/docs/architecture.md @@ -1,8 +1,10 @@ # Architecture -This page describes how the TypeScript prototype implements the -[design proposal](design.md). The design is authoritative. Where this page and -the design disagree, the design wins and this page is a bug. +This page describes how the prototype implements the +[design proposal](design.md): the TypeScript library in `src/`, and the native +Rust (`rust/`) and .NET (`dotnet/`) libraries that match it (see +[Language bindings](#language-bindings)). The design is authoritative. Where +this page and the design disagree, the design wins and this page is a bug. ## Boundary @@ -168,3 +170,33 @@ and exits 1. Absence is not a failure. `getSandboxConfig` returns `undefined`, and `getSandboxConfigWithDiagnostics` returns `policy: undefined` with its diagnostics. + +## Language bindings + +"The policy catalog library" has three native implementations with one +behavior ([design §6.2](design.md#62-cross-language-consistency-and-support)): + +| Language | Location | Package | Entry points | +|---|---|---|---| +| TypeScript | `src/` | npm `@mxc-prototype/policy-catalog` | `getSandboxConfig`, `getSandboxConfigWithDiagnostics` | +| Rust | `rust/` (own `[workspace]`) | crate `mxc-policy-catalog` | `get_sandbox_config`, `get_sandbox_config_with_diagnostics` | +| C# | `dotnet/` (net8.0) | NuGet `Microsoft.Mxc.PolicyCatalog` | `GetSandboxConfig`, `GetSandboxConfigWithDiagnostics` | + +The Rust and .NET libraries are not wrappers. Each one embeds the bundled +catalog and reimplements canonical JSON, digest checks, path normalization +(Node `path.win32`/`path.posix` semantics), purl and version-range parsing, +variant selection, dependency closure, composition, and the error mapping +above. Parity is enforced at three levels: + +- `conformance/fixtures/*.json`: resolution cases, including expected + warnings and `{code, reason}` errors, run by every language's unit tests. +- `conformance/vectors/*.json`: path-normalization and canonical-JSON + vectors generated from the TypeScript implementation. +- `scripts/cross-language-check.mjs`: runs the three `policy-catalog` CLIs + with identical arguments (resolve, inspect, validate, library failures, + and usage errors) and requires byte-identical JSON after sorting keys and + replacing temporary directory paths. CI runs it on every OS and + architecture in the matrix. + +Warning and error message text is part of that contract, so a wording change +in one language is a change in all three. diff --git a/policy-catalog/docs/contributing.md b/policy-catalog/docs/contributing.md index 9783a9cb5..3339e9650 100644 --- a/policy-catalog/docs/contributing.md +++ b/policy-catalog/docs/contributing.md @@ -86,7 +86,43 @@ Changes to the library API or behavior must keep the shared contract intact: - Error codes, `details.reason` values, and absence semantics are part of the contract. Changing them is a breaking change. - Run `npm run check` and `npm run check:extract -- --worktree` before - opening a pull request. + opening a pull request. If you touch `rust/`, `dotnet/`, or anything the + CLIs print, also run the Rust and .NET commands below and + `node scripts/cross-language-check.mjs` (after `npm run build`). + +## Rust and .NET bindings + +The Rust crate `mxc-policy-catalog` (`rust/`, its own Cargo `[workspace]`) +and the .NET package `Microsoft.Mxc.PolicyCatalog` (`dotnet/`, net8.0) are +native implementations of the same behavior, not wrappers around the +TypeScript library. Each one embeds the bundled catalog, passes every case in +`conformance/fixtures/` and `conformance/vectors/`, and ships a +`policy-catalog` CLI whose output must match the TypeScript CLI. + +```text +# Rust (toolchain pinned by rust/rust-toolchain.toml) +cd rust +cargo fmt --check +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked +cd .. +node scripts/rust-functional.mjs # cargo package, then a consumer crate outside the repo + +# .NET (SDK pinned by dotnet/global.json) +cd dotnet +dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release +dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release --no-build +cd .. +node scripts/dotnet-functional.mjs # dotnet pack, then a consumer project on a local feed outside the repo + +# All three CLIs must print byte-identical normalized JSON +npm run build +node scripts/cross-language-check.mjs +``` + +When `src/` behavior changes, change the Rust and .NET code in the same pull +request, add or update a conformance fixture, and add a cross-language case +if the CLI output changes. ## Test layout @@ -98,6 +134,11 @@ Changes to the library API or behavior must keep the shared contract intact: | Package contents | `scripts/check-pack.mjs` | `npm run check:pack` | | Offline install smoke test | `scripts/package-smoke.mjs` | `npm run smoke:package` | | Standalone extraction | `scripts/check-extractable.mjs` | `npm run check:extract` | +| Rust unit and conformance | `rust/src`, `rust/tests` | `cargo test --locked` (in `rust/`) | +| Rust functional (packaged `.crate`) | `scripts/rust-functional.mjs` | `node scripts/rust-functional.mjs` | +| .NET unit and conformance | `dotnet/Microsoft.Mxc.PolicyCatalog.Tests` | `dotnet test` (in `dotnet/`) | +| .NET functional (packed `.nupkg`) | `scripts/dotnet-functional.mjs` | `node scripts/dotnet-functional.mjs` | +| Cross-language CLI parity | `scripts/cross-language-check.mjs` | `node scripts/cross-language-check.mjs` | The layout mirrors MXC's `sdk/node/tests/integration`. Each suite directory has its own `tsconfig.json` that compiles `*.test.ts` into that directory's diff --git a/policy-catalog/scripts/check-extractable.mjs b/policy-catalog/scripts/check-extractable.mjs index 687c2c641..d4ca5d9ac 100644 --- a/policy-catalog/scripts/check-extractable.mjs +++ b/policy-catalog/scripts/check-extractable.mjs @@ -26,13 +26,14 @@ const work = mkdtempSync(join(tmpdir(), 'policy-catalog-extract-')); const target = join(work, 'repo'); try { if (useWorktree) { - const files = git(['ls-files', '-z', '--', '.'], packageDir).split('\0').filter(Boolean); + // Tracked plus untracked-but-not-ignored: exactly what the next commit holds. + const files = git(['ls-files', '-co', '--exclude-standard', '-z', '--', '.'], packageDir).split('\0').filter(Boolean); for (const file of files) { const destination = join(target, file); mkdirSync(dirname(destination), { recursive: true }); copyFileSync(join(packageDir, file), destination); } - console.log(`Copied ${files.length} tracked files from the working tree.`); + console.log(`Copied ${files.length} files from the working tree.`); } else { // `git archive HEAD:` yields exactly the subdirectory-filtered tree. const tar = join(work, 'tree.tar'); @@ -97,7 +98,20 @@ try { // `check` includes typecheck, unit, functional (from the packed tarball), // validation, pack contents, and the install smoke test. npm(['run', 'check']); - console.log('\nExtractability check OK: the directory builds, tests, validates, and packs as a standalone repository.'); + // The Rust and .NET libraries, their packaged-artifact functional tests, + // and the cross-language check must also work from the extracted tree. + const run = (file, args, cwd = target) => execFileSync(file, args, { cwd, stdio: 'inherit' }); + const rustDir = join(target, 'rust'); + run('cargo', ['fmt', '--check'], rustDir); + run('cargo', ['clippy', '--locked', '--all-targets', '--all-features', '--', '-D', 'warnings'], rustDir); + run('cargo', ['test', '--locked'], rustDir); + run(process.execPath, ['scripts/rust-functional.mjs']); + const dotnetDir = join(target, 'dotnet'); + run('dotnet', ['build', 'Microsoft.Mxc.PolicyCatalog.slnx', '-c', 'Release'], dotnetDir); + run('dotnet', ['test', '--solution', 'Microsoft.Mxc.PolicyCatalog.slnx', '-c', 'Release', '--no-build'], dotnetDir); + run(process.execPath, ['scripts/dotnet-functional.mjs']); + run(process.execPath, ['scripts/cross-language-check.mjs']); + console.log('\nExtractability check OK: the directory builds, tests, validates, and packs as a standalone repository (TypeScript, Rust, .NET, cross-language).'); } } finally { rmSync(work, { recursive: true, force: true }); diff --git a/policy-catalog/scripts/ci-ensure-rustup.mjs b/policy-catalog/scripts/ci-ensure-rustup.mjs new file mode 100644 index 000000000..58ba32735 --- /dev/null +++ b/policy-catalog/scripts/ci-ensure-rustup.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// CI helper: makes sure `rustup` and the toolchain pinned by +// rust/rust-toolchain.toml are available. Most GitHub-hosted images ship +// rustup; some (for example windows-11-arm) do not, and then this installs it +// from the official rustup-init for the runner's architecture. Local +// developers install Rust themselves; this script only prints what it finds +// when rustup is already present. +import { execFileSync, spawnSync } from 'node:child_process'; +import { appendFileSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { homedir, machine, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rustDir = fileURLToPath(new URL('../rust/', import.meta.url)); +const has = cmd => spawnSync(cmd, ['--version'], { stdio: 'ignore' }).status === 0; + +if (!has('rustup')) { + if (!process.env.CI) { + console.error('rustup is not installed; install Rust from https://rustup.rs and rerun.'); + process.exit(1); + } + const arch = machine() === 'arm64' || machine() === 'aarch64' ? 'aarch64' : 'x86_64'; + const target = process.platform === 'win32' ? `${arch}-pc-windows-msvc` + : process.platform === 'darwin' ? `${arch}-apple-darwin` : `${arch}-unknown-linux-gnu`; + const exe = process.platform === 'win32' ? '.exe' : ''; + const url = `https://static.rust-lang.org/rustup/dist/${target}/rustup-init${exe}`; + console.log(`rustup not found; installing from ${url}`); + const response = await fetch(url); + if (!response.ok) throw new Error(`download failed: ${response.status}`); + const init = join(mkdtempSync(join(tmpdir(), 'rustup-')), `rustup-init${exe}`); + writeFileSync(init, Buffer.from(await response.arrayBuffer()), { mode: 0o755 }); + execFileSync(init, ['-y', '--profile', 'minimal', '--default-toolchain', 'none', '--no-modify-path'], { stdio: 'inherit' }); + const bin = join(process.env.CARGO_HOME ?? join(homedir(), '.cargo'), 'bin'); + if (process.env.GITHUB_PATH) appendFileSync(process.env.GITHUB_PATH, `${bin}\n`); + process.env.PATH = `${bin}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`; +} + +// Resolving the active toolchain in rust/ installs the pinned channel and its +// components (rustup >= 1.28 no longer installs it implicitly on `show`). +execFileSync('rustup', ['toolchain', 'install'], { cwd: rustDir, stdio: 'inherit' }); +execFileSync('rustc', ['-vV'], { cwd: rustDir, stdio: 'inherit' }); diff --git a/policy-catalog/scripts/cross-language-check.mjs b/policy-catalog/scripts/cross-language-check.mjs new file mode 100644 index 000000000..df0d07ea5 --- /dev/null +++ b/policy-catalog/scripts/cross-language-check.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Cross-language check: identical inputs must give byte-identical normalized +// JSON output from the TypeScript, Rust, and C# `policy-catalog` CLIs, for +// resolve, inspect, validate, and the error cases. +// +// npm run build +// node scripts/cross-language-check.mjs [--no-build] [--only ts,rust,dotnet] +// +// By default the Rust and .NET CLIs are built from rust/ and dotnet/ first +// (release configuration, their own output directories). Override the +// commands with POLICY_CATALOG_RUST_CLI / POLICY_CATALOG_DOTNET_CLI (a path to +// an executable, or to a .dll that is run with `dotnet`). +// +// Normalization, applied identically to every language: +// - stdout is parsed as JSON and re-serialized with object keys sorted +// (key order is not part of the contract; everything else is); +// - absolute paths of the temporary catalog directories are replaced with +// placeholders; +// - OS I/O error text after "could not be read: " is truncated, because it +// comes from each runtime's file API; +// - `validate` without --catalog reports where each package installed its +// bundled catalog (npm package dir, Rust source tree, .NET output dir), so +// that `catalogDir` must end in `catalog` and is then replaced with +// `/catalog`; +// - for usage errors (exit 2) stdout must be empty and stderr must end with +// the shared usage line. +// Exit code must match exactly. +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { canonicalSha256 } from '../dist/canonical-json.js'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const args = process.argv.slice(2); +const noBuild = args.includes('--no-build'); +const onlyAt = args.indexOf('--only'); +const only = onlyAt >= 0 ? new Set(args[onlyAt + 1].split(',')) : new Set(['ts', 'rust', 'dotnet']); +const exe = process.platform === 'win32' ? '.exe' : ''; + +function run(file, argv, cwd = root) { + execFileSync(file, argv, { cwd, stdio: 'inherit' }); +} + +/** Language -> function(args) => { status, stdout, stderr }. */ +const clis = {}; +if (only.has('ts')) { + clis.ts = [process.execPath, join(root, 'dist', 'cli.js')]; +} +if (only.has('rust')) { + let cli = process.env.POLICY_CATALOG_RUST_CLI; + if (!cli) { + const targetDir = join(root, 'rust', 'target'); + if (!noBuild) { + run('cargo', ['build', '--release', '--locked', '--bin', 'policy-catalog', '--target-dir', targetDir], join(root, 'rust')); + } + cli = join(targetDir, 'release', `policy-catalog${exe}`); + } + clis.rust = cli.endsWith('.dll') ? ['dotnet', cli] : [cli]; +} +if (only.has('dotnet')) { + let cli = process.env.POLICY_CATALOG_DOTNET_CLI; + if (!cli) { + const project = join(root, 'dotnet', 'Microsoft.Mxc.PolicyCatalog.Cli'); + if (!noBuild) { + run('dotnet', ['build', join(project, 'Microsoft.Mxc.PolicyCatalog.Cli.csproj'), '-c', 'Release', '--nologo', '-v', 'quiet'], root); + } + cli = join(project, 'bin', 'Release', 'net8.0', 'policy-catalog.dll'); + } + clis.dotnet = cli.endsWith('.dll') ? ['dotnet', cli] : [cli]; +} +for (const [lang, [cmd]] of Object.entries(clis)) { + if (cmd !== 'dotnet' && !existsSync(cmd)) { + console.error(`${lang}: CLI not found at ${cmd}`); + process.exit(2); + } +} + +// --------------------------------------------------------------------------- +// Synthetic catalogs (written once, shared by every language) +// --------------------------------------------------------------------------- + +const work = mkdtempSync(join(tmpdir(), 'policy-catalog-xlang-')); +const placeholders = new Map(); +const contract = readFileSync(join(root, 'catalog', 'contract.v1.json'), 'utf8'); + +function writeCatalog(name, revisions, { tamper, dropFile } = {}) { + const dir = join(work, name); + const files = {}; + const manifest = { + catalogSchemaVersion: '1', + defaultRevision: revisions[revisions.length - 1].catalogRevision, + revisions: revisions.map(r => { + const file = `revisions/${r.catalogRevision}.json`; + files[file] = r; + return { catalogRevision: r.catalogRevision, file, sha256: canonicalSha256(r) }; + }), + }; + mkdirSync(join(dir, 'revisions'), { recursive: true }); + writeFileSync(join(dir, 'contract.v1.json'), contract); + writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + for (const [file, value] of Object.entries(files)) { + if (file === dropFile) continue; + const body = tamper ? tamper(structuredClone(value)) : value; + writeFileSync(join(dir, file), `${JSON.stringify(body, null, 2)}\n`); + } + placeholders.set(dir, `<${name}>`); + return dir; +} + +const entry = (entryId, overrides = {}) => { + const name = entryId.split(':')[1]; + return { + entryId, + entryRevision: 1, + displayName: name, + identity: [{ kind: 'invocation-name', names: [name] }], + platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${name}`] } } }], + provenance: { method: 'xlang', sourceRevision: 'xlang' }, + ...overrides, + }; +}; +const revision = (entries, catalogRevision = '2000-01-01.1') => ({ catalogSchemaVersion: '1', catalogRevision, entries }); +const dependsOn = (id, dep) => entry(id, { + platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: dep }], sandboxPolicy: { version: '0.9.0-alpha' } }], +}); + +const bundledRevision = JSON.parse(readFileSync(join(root, 'catalog', 'revisions', '2026-09-29.1.json'), 'utf8')); +const good = writeCatalog('good', [revision([entry('tool:a'), entry('tool:b')]), revision([entry('tool:a', { entryRevision: 2, displayName: 'a2' }), entry('tool:b')], '2000-01-02.1')]); +const tampered = writeCatalog('tampered', [bundledRevision], { tamper: r => { r.entries[0].displayName = 'Tampered'; return r; } }); +const missing = writeCatalog('missing', [bundledRevision], { dropFile: 'revisions/2026-09-29.1.json' }); +const cyclic = writeCatalog('cyclic', [revision([dependsOn('tool:a', 'tool:b'), dependsOn('tool:b', 'tool:c'), dependsOn('tool:c', 'tool:a')])]); +const multi = writeCatalog('multi', [revision([ + entry('tool:app', { identity: [{ kind: 'purl', value: 'pkg:npm/app', versionRange: '>=2 <3' }, { kind: 'invocation-name', names: ['app'] }] }), + entry('tool:app-plugin', { identity: [{ kind: 'invocation-name', names: ['app'] }] }), + entry('tool:net', { + identity: [{ kind: 'invocation-name', names: ['net'] }], + platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', network: { egress: { default: 'deny', allow: [{ to: [{ cidr: '192.0.2.0/24' }] }] } } } }], + }), + entry('tool:arm', { + identity: [{ kind: 'invocation-name', names: ['arm'] }], + platformVariants: [ + { when: { platform: 'linux', architecture: 'arm64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/arm64'] } } }, + { when: { platform: 'windows' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}\\neutral'] } } }, + ], + }), +])]); + +// --------------------------------------------------------------------------- +// Cases +// --------------------------------------------------------------------------- + +const syms = { + windows: ['--project-root', 'C:\\work\\app', '--symbol', 'git_prefix=C:\\tools', '--symbol', 'node_prefix=C:\\tools', '--symbol', 'npm_prefix=C:\\tools', '--symbol', 'npm_cache=C:\\cache\\npm'], + linux: ['--project-root', '/work/app', '--symbol', 'git_prefix=/opt/tools', '--symbol', 'node_prefix=/opt/tools', '--symbol', 'npm_prefix=/opt/tools', '--symbol', 'npm_cache=/var/cache/npm'], + macos: ['--project-root', '/Users/dev/app', '--symbol', 'git_prefix=/opt/homebrew/bin', '--symbol', 'node_prefix=/opt/homebrew/bin', '--symbol', 'npm_prefix=/opt/homebrew/bin', '--symbol', 'npm_cache=/Users/dev/.npm'], +}; +const cases = [ + ['inspect'], + ['inspect', '--catalog', good], + ['validate'], + ['validate', '--catalog', good], + ['validate', '--catalog', tampered], + ['validate', '--catalog', missing], + ['validate', '--catalog', cyclic], + ['inspect', '--catalog', tampered], + ['inspect', '--catalog', cyclic], +]; +for (const platform of ['windows', 'linux', 'macos']) { + for (const architecture of ['x64', 'arm64']) { + const ctx = ['--platform', platform, '--architecture', architecture, '--allow-weak', ...syms[platform]]; + cases.push(['resolve', ...ctx, 'git', 'npm', 'node']); + cases.push(['resolve', '--diagnostics', ...ctx, 'git', 'npm', 'node', 'cargo']); + cases.push(['resolve', '--diagnostics', ...ctx, 'GIT', 'Npm.CMD']); + } +} +const linux = ['--platform', 'linux', '--architecture', 'x64']; +cases.push( + // weak opt-in off / on + ['resolve', '--diagnostics', ...linux, ...syms.linux, 'npm'], + ['resolve', ...linux, ...syms.linux, 'npm'], + ['resolve', '--diagnostics', ...linux, '--allow-weak', ...syms.linux, 'npm'], + // strong identity and version-range warnings + ['resolve', '--diagnostics', ...linux, ...syms.linux, '--purl', 'pkg:npm/npm@10.9.0', 'npx'], + ['resolve', '--diagnostics', ...linux, ...syms.linux, '--purl', 'pkg:npm/npm', '--detected-version', 'v11.0.0-rc.1', 'npm'], + // unknown tool, empty input, unresolved symbol + ['resolve', '--diagnostics', ...linux, '--allow-weak', ...syms.linux, 'no-such-tool'], + ['resolve', ...linux], + ['resolve', '--diagnostics', ...linux], + ['resolve', '--diagnostics', ...linux, '--allow-weak', '--symbol', 'git_prefix=/usr/bin', 'git'], + // casing: Linux exact, Windows/macOS fold + ['resolve', '--diagnostics', ...linux, '--allow-weak', ...syms.linux, 'GIT'], + ['resolve', '--diagnostics', '--platform', 'macos', '--architecture', 'arm64', '--allow-weak', '--project-root', '/Tools/w', '--symbol', 'git_prefix=/tools', 'Git'], + ['resolve', '--diagnostics', '--platform', 'windows', '--architecture', 'x64', '--allow-weak', '--project-root', 'c:\\tools', '--symbol', 'git_prefix=C:\\Tools\\', 'git'], + // revisions + ['resolve', '--diagnostics', '--catalog', good, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'a'], + ['resolve', '--diagnostics', '--catalog', good, '--revision', '2000-01-01.1', ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'a'], + ['resolve', ...linux, '--revision', '2099-01-01.1', 'git'], + // integrity and cycles + ['resolve', '--catalog', tampered, ...linux, 'git'], + ['resolve', '--catalog', missing, ...linux, 'git'], + ['resolve', '--catalog', cyclic, ...linux, '--allow-weak', 'a'], + // multi-match, composition conflicts, architecture + ['resolve', '--diagnostics', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'app'], + ['resolve', '--diagnostics', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', '--purl', 'pkg:npm/app@3.1.0', 'app'], + ['resolve', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'net', 'app'], + ['resolve', ...linux, '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], + ['resolve', '--diagnostics', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'arm'], + ['resolve', '--diagnostics', '--catalog', multi, '--platform', 'linux', '--architecture', 'arm64', '--allow-weak', '--symbol', 'git_prefix=/g', 'arm'], + ['resolve', '--diagnostics', '--catalog', multi, '--platform', 'windows', '--architecture', 'arm64', '--allow-weak', '--symbol', 'git_prefix=C:\\g', 'arm'], + // invalid context (malformed_request) + ['resolve', '--platform', 'plan9', 'git'], + ['resolve', '--architecture', 'mips', 'git'], + ['resolve', ...linux, '--allow-weak', '--symbol', 'node_prefix=relative', 'node'], + ['resolve', ...linux, '--symbol', 'unknown_symbol=/x', 'git'], + ['resolve', ...linux, '--symbol', '__proto__=/x', 'git'], + ['resolve', ...linux, '--symbol', 'project_root=/x', 'git'], + ['resolve', ...linux, '--purl', 'not-a-purl', 'npm'], + ['resolve', ...linux, '--purl', 'pkg:npm/npm@%E0%A4%A', 'npm'], + ['resolve', ...linux, './bin/git'], + // usage errors + [], + ['bogus'], + ['info'], + ['resolve', '--platform'], + ['resolve', '--symbol', 'noequals', 'git'], + ['resolve', '--purl', 'pkg:npm/npm'], + ['resolve', '--base-ref', 'HEAD', 'git'], + ['resolve', '--nope'], + ['inspect', 'extra'], + ['validate', 'extra'], + ['validate', '--base-ref'], + ['resolve', '--catalog'], +); + +// --------------------------------------------------------------------------- +// Run and compare +// --------------------------------------------------------------------------- + +function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map(key => [key, sortKeys(value[key])])); + } + return value; +} + +function normalizeText(text) { + let out = text; + for (const [dir, name] of placeholders) { + for (const form of [dir, dir.replaceAll('\\', '/'), JSON.stringify(dir).slice(1, -1)]) { + out = out.split(form).join(name); + } + } + return out; +} + +function normalize(result, argv) { + const status = result.status; + if (status === 2) { + const lines = result.stderr.trim().split(/\r?\n/); + return { + status, + stdout: result.stdout, + stderrUsage: lines[lines.length - 1], + stderrMessage: lines[0].startsWith('policy-catalog: ') ? lines[0] : `(bad prefix) ${lines[0]}`, + }; + } + let json; + try { + json = JSON.parse(result.stdout); + } catch { + return { status, stdout: `(not JSON) ${result.stdout}`, stderr: result.stderr }; + } + if (argv[0] === 'validate' && !argv.includes('--catalog') && typeof json?.catalogDir === 'string') { + json.catalogDir = /[\\/]catalog$/.test(json.catalogDir) ? '/catalog' : `(unexpected) ${json.catalogDir}`; + } + const text = normalizeText(JSON.stringify(sortKeys(json), null, 2)) + .replace(/(could not be read: )[^"]*/g, '$1'); + return { status, stdout: text }; +} + +const languages = Object.keys(clis); +let failures = 0; +let compared = 0; +try { + for (const argv of cases) { + const results = {}; + for (const lang of languages) { + const [cmd, ...pre] = clis[lang]; + const raw = spawnSync(cmd, [...pre, ...argv], { cwd: work, encoding: 'utf8' }); + if (raw.error) throw raw.error; + results[lang] = JSON.stringify(normalize(raw, argv), null, 2); + } + compared += 1; + const reference = results[languages[0]]; + const differing = languages.filter(lang => results[lang] !== reference); + const label = normalizeText(argv.join(' ')) || '(no arguments)'; + if (differing.length === 0) { + console.log(`ok ${label}`); + } else { + failures += 1; + console.log(`FAIL ${label}`); + for (const lang of languages) console.log(` --- ${lang}\n${results[lang].replace(/^/gm, ' ')}`); + } + } +} finally { + rmSync(work, { recursive: true, force: true }); +} +console.log(`\ncross-language check: ${compared} cases x ${languages.length} languages (${languages.join(', ')}), ${compared - failures} identical, ${failures} different`); +process.exit(failures === 0 ? 0 : 1); From 435d8a8aba97120b7b0d750f53550ea8a6756226 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 13:00:08 -0700 Subject: [PATCH 15/50] fix(policy-catalog): extract archives with inbox bsdtar on Windows GitHub-hosted Windows runners put Git Bash's GNU tar first on PATH; it treats C:/... as a remote host and cannot exec gzip, which failed the Rust functional step on windows-x64 and windows-arm64. Use %SystemRoot%\System32\tar.exe on Windows in rust-functional.mjs and check-extractable.mjs. --- policy-catalog/scripts/check-extractable.mjs | 4 +++- policy-catalog/scripts/rust-functional.mjs | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/policy-catalog/scripts/check-extractable.mjs b/policy-catalog/scripts/check-extractable.mjs index d4ca5d9ac..0d7ad596f 100644 --- a/policy-catalog/scripts/check-extractable.mjs +++ b/policy-catalog/scripts/check-extractable.mjs @@ -39,7 +39,9 @@ try { const tar = join(work, 'tree.tar'); git(['archive', '--format=tar', '-o', tar, `HEAD:${prefix}`], repoRoot); mkdirSync(target); - execFileSync('tar', ['-xf', tar, '-C', target], { stdio: 'inherit' }); + // Inbox bsdtar on Windows; a GNU tar earlier on PATH misreads `C:/...`. + const tarCmd = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; + execFileSync(tarCmd, ['-xf', tar, '-C', target], { stdio: 'inherit' }); console.log(`Extracted HEAD:${prefix}.`); } diff --git a/policy-catalog/scripts/rust-functional.mjs b/policy-catalog/scripts/rust-functional.mjs index d03bbffd4..97beacfcf 100644 --- a/policy-catalog/scripts/rust-functional.mjs +++ b/policy-catalog/scripts/rust-functional.mjs @@ -27,6 +27,9 @@ const keep = process.argv.includes('--keep'); const crateDir = fileURLToPath(new URL('../rust/', import.meta.url)); const repoDir = fileURLToPath(new URL('../', import.meta.url)); const exe = process.platform === 'win32' ? '.exe' : ''; +// On Windows, use the inbox bsdtar: a GNU tar earlier on PATH (Git Bash, as on +// GitHub-hosted runners) reads `C:/...` as a remote host and cannot gunzip. +const tarCmd = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; function run(cmd, args, options = {}) { console.log(`> ${cmd} ${args.join(' ')}${options.cwd ? ` (in ${options.cwd})` : ''}`); @@ -61,7 +64,7 @@ try { // A .crate is a gzip'd tarball with one top-level `-/` directory. const extractRoot = join(work, 'extracted'); rmSync(extractRoot, { recursive: true, force: true }); - execFileSync('tar', ['-xzf', crate, '-C', work.replaceAll('\\', '/')], { stdio: 'inherit', cwd: work }); + execFileSync(tarCmd, ['-xzf', crate, '-C', work], { stdio: 'inherit', cwd: work }); const extracted = readdirSync(work) .filter(name => name.startsWith('mxc-policy-catalog-') && statSync(join(work, name)).isDirectory()) .map(name => join(work, name))[0]; From dfa598f0ebf3cd7808bacaa19860bd22fa8596fa Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 13:06:39 -0700 Subject: [PATCH 16/50] fix(policy-catalog): normalize both spellings of temp catalog paths in the cross-language check On windows-latest the runner TEMP is an 8.3 short path; .NET's Path.GetFullPath keeps it while Node and Rust report the same directory differently, so validate --catalog cases differed only in catalogDir. Register the realpath of each synthetic catalog directory as a second placeholder spelling. --- policy-catalog/scripts/cross-language-check.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policy-catalog/scripts/cross-language-check.mjs b/policy-catalog/scripts/cross-language-check.mjs index df0d07ea5..ec5238a0c 100644 --- a/policy-catalog/scripts/cross-language-check.mjs +++ b/policy-catalog/scripts/cross-language-check.mjs @@ -29,7 +29,7 @@ // the shared usage line. // Exit code must match exactly. import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -109,6 +109,10 @@ function writeCatalog(name, revisions, { tamper, dropFile } = {}) { writeFileSync(join(dir, file), `${JSON.stringify(body, null, 2)}\n`); } placeholders.set(dir, `<${name}>`); + // Runtimes may report the same directory in another spelling (on Windows, + // an 8.3 short temp path such as RUNNER~1 versus its long form). + const real = realpathSync.native(dir); + if (real !== dir) placeholders.set(real, `<${name}>`); return dir; } From b2fee47e57d592c2636979360eb75521c4bc2ad0 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Wed, 30 Sep 2026 13:06:40 -0700 Subject: [PATCH 17/50] ci(policy-catalog): run macOS x64 on macos-15-intel The macos-13 image is no longer offered by GitHub-hosted runners, so those jobs stayed queued. macos-15-intel is the current x64 macOS label. --- .github/workflows/PolicyCatalog.Prototype.yml | 2 +- policy-catalog/.github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/PolicyCatalog.Prototype.yml b/.github/workflows/PolicyCatalog.Prototype.yml index 09f98c5bd..fbcf4edef 100644 --- a/.github/workflows/PolicyCatalog.Prototype.yml +++ b/.github/workflows/PolicyCatalog.Prototype.yml @@ -30,7 +30,7 @@ jobs: - label: macos-arm64 runner: macos-latest - label: macos-x64 - runner: macos-13 + runner: macos-15-intel - label: windows-x64 runner: windows-latest - label: windows-arm64 diff --git a/policy-catalog/.github/workflows/ci.yml b/policy-catalog/.github/workflows/ci.yml index e500c1637..e13e7220a 100644 --- a/policy-catalog/.github/workflows/ci.yml +++ b/policy-catalog/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - label: macos-arm64 runner: macos-latest - label: macos-x64 - runner: macos-13 + runner: macos-15-intel - label: windows-x64 runner: windows-latest - label: windows-arm64 From 59da70f4cc35b334b3f1e3dba3c0d95f1f338b8b Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 10:59:12 -0700 Subject: [PATCH 18/50] refactor(policy-catalog)!: rename getSandboxConfig to resolveSandboxPolicy Rename the public resolver API to match the policy-store spec across all three implementations, with no behavior change: - TypeScript: resolveSandboxPolicy / resolveSandboxPolicyWithDiagnostics - Rust: resolve_sandbox_policy / resolve_sandbox_policy_with_diagnostics - .NET: ResolveSandboxPolicy / ResolveSandboxPolicyWithDiagnostics Tests, READMEs, scripts, conformance fixture comment, architecture doc and the copied design doc are updated. The design doc keeps the historical getSandboxConfigForTool reference in its prior-API table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- policy-catalog/README.md | 18 ++-- .../conformance/fixtures/bundled-catalog.json | 2 +- policy-catalog/docs/architecture.md | 12 +-- policy-catalog/docs/design.md | 36 +++---- .../Program.cs | 4 +- .../ConformanceTests.cs | 12 +-- .../ResolverTests.cs | 74 +++++++-------- .../StoreTests.cs | 10 +- .../PolicyCatalog.cs | 16 ++-- .../Microsoft.Mxc.PolicyCatalog/Results.cs | 2 +- .../FunctionalTests.cs | 14 +-- policy-catalog/rust/README.md | 2 +- .../rust/functional/tests/functional.rs | 20 ++-- policy-catalog/rust/src/cli.rs | 4 +- policy-catalog/rust/src/lib.rs | 7 +- policy-catalog/rust/src/model.rs | 2 +- policy-catalog/rust/src/resolver.rs | 12 +-- policy-catalog/rust/tests/conformance.rs | 10 +- policy-catalog/rust/tests/library.rs | 94 +++++++++++-------- policy-catalog/scripts/package-smoke.mjs | 10 +- policy-catalog/scripts/validate-catalog.mjs | 6 +- policy-catalog/src/cli.ts | 6 +- policy-catalog/src/index.ts | 4 +- policy-catalog/src/resolver.ts | 22 ++--- policy-catalog/src/types.ts | 2 +- .../tests/functional/resolution.test.ts | 14 +-- policy-catalog/tests/unit/conformance.test.ts | 10 +- policy-catalog/tests/unit/resolver.test.ts | 70 +++++++------- policy-catalog/tests/unit/store.test.ts | 8 +- 29 files changed, 258 insertions(+), 245 deletions(-) diff --git a/policy-catalog/README.md b/policy-catalog/README.md index fa47e300f..b6fe9317e 100644 --- a/policy-catalog/README.md +++ b/policy-catalog/README.md @@ -46,8 +46,8 @@ Runtime lookup is kept separate from inspection ```ts import { - getSandboxConfig, - getSandboxConfigWithDiagnostics, + resolveSandboxPolicy, + resolveSandboxPolicyWithDiagnostics, getCatalogInfo, listCatalogEntries, } from '@mxc-prototype/policy-catalog'; @@ -58,10 +58,10 @@ const ctx = { symbols: { git_prefix: '/usr/bin', npm_prefix: '/usr/local/bin', npm_cache: '/home/me/.npm', node_prefix: '/usr/local/bin' }, }; -getSandboxConfig('npm', ctx); // one tool -> SandboxPolicy | undefined -getSandboxConfig(['git', 'npm'], ctx); // several tools -> one composed policy -getSandboxConfigWithDiagnostics(['git', 'npm'], ctx); // same policy plus attribution and warnings -getSandboxConfig({ invocationName: 'npx', packageUrl: 'pkg:npm/npm@10.9.0' }, ctx); +resolveSandboxPolicy('npm', ctx); // one tool -> SandboxPolicy | undefined +resolveSandboxPolicy(['git', 'npm'], ctx); // several tools -> one composed policy +resolveSandboxPolicyWithDiagnostics(['git', 'npm'], ctx); // same policy plus attribution and warnings +resolveSandboxPolicy({ invocationName: 'npx', packageUrl: 'pkg:npm/npm@10.9.0' }, ctx); getCatalogInfo(); // { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' } listCatalogEntries(); // metadata only, never policy bodies @@ -72,7 +72,7 @@ exactly three commands: | Command | Purpose | |---|---| -| `policy-catalog resolve [options] ...` | `getSandboxConfig`, or `getSandboxConfigWithDiagnostics` with `--diagnostics`. Options: `--platform`, `--architecture`, `--revision`, `--project-root`, `--symbol name=value`, `--allow-weak`, and `--purl` / `--detected-version` for the next tool. | +| `policy-catalog resolve [options] ...` | `resolveSandboxPolicy`, or `resolveSandboxPolicyWithDiagnostics` with `--diagnostics`. Options: `--platform`, `--architecture`, `--revision`, `--project-root`, `--symbol name=value`, `--allow-weak`, and `--purl` / `--detected-version` for the next tool. | | `policy-catalog inspect` | `getCatalogInfo()` and `listCatalogEntries()`: metadata only, never a policy body. | | `policy-catalog validate [--base-ref REF]` | Integrity, the catalog contract (including dependency cycles), and entry-revision history. With `--base-ref`, also checks that every revision published at `REF` is unchanged. | @@ -94,9 +94,9 @@ npx policy-catalog validate --catalog ./catalog --base-ref origin/main audit, and sandbox creation ([design §2](docs/design.md#2-ownership-boundary), [design §5.3](docs/design.md#53-consumer-obligations)). -- Absence is not an empty policy. `getSandboxConfig` returns `undefined` for +- Absence is not an empty policy. `resolveSandboxPolicy` returns `undefined` for an empty input, when no input matched, or when a selected entry has an - unresolved required symbol. `getSandboxConfigWithDiagnostics` returns + unresolved required symbol. `resolveSandboxPolicyWithDiagnostics` returns `policy: undefined` with the diagnostics. - Library failures throw `PolicyCatalogError` with an MXC error `code` (`policy_validation`, `malformed_request`, `unsupported_containment`, diff --git a/policy-catalog/conformance/fixtures/bundled-catalog.json b/policy-catalog/conformance/fixtures/bundled-catalog.json index f7980a701..43b50f9c5 100644 --- a/policy-catalog/conformance/fixtures/bundled-catalog.json +++ b/policy-catalog/conformance/fixtures/bundled-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from getSandboxConfigWithDiagnostics(tools, context), and getSandboxConfig must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` is the failure's MXC error `code` and stable `details.reason`. `tools` is a single input or an array.", + "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from resolveSandboxPolicyWithDiagnostics(tools, context), and resolveSandboxPolicy must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` is the failure's MXC error `code` and stable `details.reason`. `tools` is a single input or an array.", "catalog": "bundled", "cases": [ { diff --git a/policy-catalog/docs/architecture.md b/policy-catalog/docs/architecture.md index 9f9b1b47d..125b7625e 100644 --- a/policy-catalog/docs/architecture.md +++ b/policy-catalog/docs/architecture.md @@ -35,7 +35,7 @@ layers and ceilings, approval, audit, and the final sandbox creation. | `src/catalog.ts` | Contract and revision validation, variant selection, dependency closure, composition limits. | | `src/store.ts` | Manifest validation; lazy, digest-checked, deep-frozen revision loading. | | `src/host.ts` | Host platform, native system architecture, and approved host symbols. | -| `src/resolver.ts` | `getSandboxConfig`, `getSandboxConfigWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries`. | +| `src/resolver.ts` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries`. | | `src/history.ts` | `entryRevision` monotonicity and published-revision immutability checks. | | `src/validate.ts` | Catalog-directory validation and the `--base-ref` immutability check, shared by `policy-catalog validate` and `scripts/validate-catalog.mjs`. Tooling only; the runtime lookup path never runs git. | | `src/cli.ts` | `policy-catalog resolve \| inspect \| validate` command-line harness over the public API (functional tests, CI, contributors). | @@ -167,8 +167,8 @@ The CLI prints a failure as `{"error": {"code": "...", "message": "...", "details": {"reason": "..."}}}` and exits 1. -Absence is not a failure. `getSandboxConfig` returns `undefined`, and -`getSandboxConfigWithDiagnostics` returns `policy: undefined` with its +Absence is not a failure. `resolveSandboxPolicy` returns `undefined`, and +`resolveSandboxPolicyWithDiagnostics` returns `policy: undefined` with its diagnostics. ## Language bindings @@ -178,9 +178,9 @@ behavior ([design §6.2](design.md#62-cross-language-consistency-and-support)): | Language | Location | Package | Entry points | |---|---|---|---| -| TypeScript | `src/` | npm `@mxc-prototype/policy-catalog` | `getSandboxConfig`, `getSandboxConfigWithDiagnostics` | -| Rust | `rust/` (own `[workspace]`) | crate `mxc-policy-catalog` | `get_sandbox_config`, `get_sandbox_config_with_diagnostics` | -| C# | `dotnet/` (net8.0) | NuGet `Microsoft.Mxc.PolicyCatalog` | `GetSandboxConfig`, `GetSandboxConfigWithDiagnostics` | +| TypeScript | `src/` | npm `@mxc-prototype/policy-catalog` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics` | +| Rust | `rust/` (own `[workspace]`) | crate `mxc-policy-catalog` | `resolve_sandbox_policy`, `resolve_sandbox_policy_with_diagnostics` | +| C# | `dotnet/` (net8.0) | NuGet `Microsoft.Mxc.PolicyCatalog` | `ResolveSandboxPolicy`, `ResolveSandboxPolicyWithDiagnostics` | The Rust and .NET libraries are not wrappers. Each one embeds the bundled catalog and reimplements canonical JSON, digest checks, path normalization diff --git a/policy-catalog/docs/design.md b/policy-catalog/docs/design.md index 7a7a10634..e33a9e4f0 100644 --- a/policy-catalog/docs/design.md +++ b/policy-catalog/docs/design.md @@ -106,8 +106,8 @@ Defaults and omission behavior are: resolvable. - Omitted `packageUrl` or `detectedVersion` supplies no matching evidence. The resolver does not fabricate either value. -- If no policy can be resolved, `getSandboxConfig` returns `undefined`. - `getSandboxConfigWithDiagnostics` instead returns a result whose `policy` is +- If no policy can be resolved, `resolveSandboxPolicy` returns `undefined`. + `resolveSandboxPolicyWithDiagnostics` instead returns a result whose `policy` is `undefined`, preserving the diagnostics. The consumer's restrictive baseline remains unchanged. @@ -134,7 +134,7 @@ Everything else is a consumer decision: sandbox creation. A catalog lookup can only ever narrow what a consumer still has to decide for -itself. `getSandboxConfig` returns a candidate composed requirement or +itself. `resolveSandboxPolicy` returns a candidate composed requirement or `undefined`; its diagnostics counterpart also reports how that result was obtained. The consumer decides whether and how to act on it. This mirrors #779's floor/policy distinction, discussed further in @@ -150,7 +150,7 @@ required to grant. | Strongest satisfied identity predicate describes a match | All eligible matching entries contribute; identity evidence is retained without stronger matches suppressing weaker ones ([§4.3](#43-identity)) | | One `sandboxPolicy` per entry; `when.platform` only conditions dependencies | One complete `SandboxPolicy` per platform variant; a variant cannot name a containment backend ([§4.4](#44-platform-variants)) | | `requires` composition unspecified beyond "union" | Composition limited to a small, explicit, field-by-field set for the first contract version; everything else is rejected until a rule exists ([§4.5](#45-dependencies-and-composition)) | -| `getSandboxConfigForTool(tools: string[])` returns one composed policy | `getSandboxConfig` accepts one tool or an array and returns one policy; `getSandboxConfigWithDiagnostics` adds attribution, with catalog inspection kept separate ([§5](#5-api-surface)) | +| `getSandboxConfigForTool(tools: string[])` returns one composed policy | `resolveSandboxPolicy` accepts one tool or an array and returns one policy; `resolveSandboxPolicyWithDiagnostics` adds attribution, with catalog inspection kept separate ([§5](#5-api-surface)) | | No revision/publication model | Immutable published catalog revisions; corrections publish a new revision ([§10](#10-immutable-revisions)) | The data model, the floor/policy direction argument, multi-tool composition, @@ -427,22 +427,22 @@ interface SandboxConfigResolution { }; } -declare function getSandboxConfig( +declare function resolveSandboxPolicy( tool: ToolInput, ctx?: ResolveContext ): SandboxPolicy | undefined; -declare function getSandboxConfig( +declare function resolveSandboxPolicy( tools: readonly ToolInput[], ctx?: ResolveContext ): SandboxPolicy | undefined; -declare function getSandboxConfigWithDiagnostics( +declare function resolveSandboxPolicyWithDiagnostics( tool: ToolInput, ctx?: ResolveContext ): SandboxConfigResolution; -declare function getSandboxConfigWithDiagnostics( +declare function resolveSandboxPolicyWithDiagnostics( tools: readonly ToolInput[], ctx?: ResolveContext ): SandboxConfigResolution; @@ -455,11 +455,11 @@ rule is: ```ts const ctx = { allowWeakIdentityFallback: true }; -const policy = getSandboxConfig("npm", ctx); -const combinedPolicy = getSandboxConfig(["git", "npm"], ctx); -const result = getSandboxConfigWithDiagnostics("npm", ctx); +const policy = resolveSandboxPolicy("npm", ctx); +const combinedPolicy = resolveSandboxPolicy(["git", "npm"], ctx); +const result = resolveSandboxPolicyWithDiagnostics("npm", ctx); const combinedResult = - getSandboxConfigWithDiagnostics(["git", "npm"], ctx); + resolveSandboxPolicyWithDiagnostics(["git", "npm"], ctx); ``` Single-tool lookup is equivalent to a one-element array; its diagnostic @@ -468,8 +468,8 @@ single-tool calls. A caller wanting one sandbox for several tools passes an array. Both forms compose every eligible matching entry, not just the strongest match, and the selected dependencies. -`getSandboxConfig` returns the composed `SandboxPolicy` directly, not a wrapper -or a `ContainerConfig`. `getSandboxConfigWithDiagnostics` returns that same +`resolveSandboxPolicy` returns the composed `SandboxPolicy` directly, not a wrapper +or a `ContainerConfig`. `resolveSandboxPolicyWithDiagnostics` returns that same policy with attribution and warnings from the same resolution pass. Callers choose one operation; retrieving diagnostics does not require a second lookup or process-global "last result" state. @@ -478,7 +478,7 @@ Following #779, an unmatched input contributes no requirements while matched inputs still contribute. Each input has a diagnostic record; an unmatched input has an empty `matches` list and a warning. An empty input array or an all-unmatched lookup produces no policy, not an empty policy: -`getSandboxConfig` returns `undefined`, while the diagnostics operation returns +`resolveSandboxPolicy` returns `undefined`, while the diagnostics operation returns a `SandboxConfigResolution` with `policy: undefined`. An empty array has no per-input records. Unresolved required symbols in selected entries prevent a policy from being returned and produce diagnostics; they are not grounds for @@ -496,7 +496,7 @@ effective native system architecture and stating that the tool's architecture was not verified. Architecture-neutral fallback is also identified. These diagnostics describe selection; they do not attest to the installed tool's architecture. The policy-only operation does not expose warnings or -attribution; consumers needing them use `getSandboxConfigWithDiagnostics`. +attribution; consumers needing them use `resolveSandboxPolicyWithDiagnostics`. ### 5.2 Setup and inspection @@ -596,8 +596,8 @@ A consumer: 1. Installs and pins the standalone library package for its language. Each package includes a reviewed default catalog revision for local use. 2. Calls `getCatalogInfo()` or `listCatalogEntries()` for inspection. - `getSandboxConfig()` returns a policy for one tool or an array; - `getSandboxConfigWithDiagnostics()` adds match attribution and warnings. + `resolveSandboxPolicy()` returns a policy for one tool or an array; + `resolveSandboxPolicyWithDiagnostics()` adds match attribution and warnings. 3. Handles policy absence without widening its restrictive baseline. It reviews the composed policy and uses the diagnostics operation when it needs contributing identities, revisions, and warnings, applying the diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs index 605cb1653..c1e66d08c 100644 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs @@ -238,12 +238,12 @@ private static int Resolve(List args, string? catalogDir) var catalog = new PolicyCatalog(StoreFrom(catalogDir)); if (diagnostics) { - Print(PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(tools, context), 2)); + Print(PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(tools, context), 2)); } else { // `null` is JSON's rendering of "no policy"; it is never an empty policy. - Print(PolicyCatalogJson.Serialize(catalog.GetSandboxConfig(tools, context), 2)); + Print(PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicy(tools, context), 2)); } return 0; diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs index d057b8009..2a269cfbd 100644 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs @@ -72,9 +72,9 @@ public void Case(string file, int index, string name) { var code = expectError.GetProperty("code").GetString(); var reason = expectError.GetProperty("reason").GetString(); - var withDiagnostics = TestData.Failure(() => catalog.GetSandboxConfigWithDiagnostics(tools, context)); + var withDiagnostics = TestData.Failure(() => catalog.ResolveSandboxPolicyWithDiagnostics(tools, context)); Assert.Equal((code, reason), (withDiagnostics.Code, withDiagnostics.Reason)); - var policyOnly = TestData.Failure(() => catalog.GetSandboxConfig(tools, context)); + var policyOnly = TestData.Failure(() => catalog.ResolveSandboxPolicy(tools, context)); Assert.Equal((code, reason), (policyOnly.Code, policyOnly.Reason)); return; } @@ -88,19 +88,19 @@ public void Case(string file, int index, string name) } var expected = TestData.Canonical(expect.ToJsonString()); - var result = catalog.GetSandboxConfigWithDiagnostics(tools, context); + var result = catalog.ResolveSandboxPolicyWithDiagnostics(tools, context); Assert.Equal(expected, TestData.Canonical(PolicyCatalogJson.Serialize(result))); - var policy = catalog.GetSandboxConfig(tools, context); + var policy = catalog.ResolveSandboxPolicy(tools, context); Assert.Equal(TestData.Canonical(expectedPolicy?.ToJsonString() ?? "null"), TestData.Canonical(PolicyCatalogJson.Serialize(policy))); Assert.Equal(result.Policy is null, policy is null); if (TestData.IsSingle(toolsElement)) { - var single = catalog.GetSandboxConfigWithDiagnostics(tools[0], context); + var single = catalog.ResolveSandboxPolicyWithDiagnostics(tools[0], context); Assert.Equal(expected, TestData.Canonical(PolicyCatalogJson.Serialize(single))); Assert.Equal( - TestData.Canonical(PolicyCatalogJson.Serialize(catalog.GetSandboxConfig(tools[0], context))), + TestData.Canonical(PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicy(tools[0], context))), TestData.Canonical(PolicyCatalogJson.Serialize(policy))); } } diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs index ff54c179a..3d5fd75ba 100644 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs @@ -26,8 +26,8 @@ private static PolicyCatalog ArchCatalog(IHostEnvironment? host = null) => TestD public void OmittedContextUsesHostDefaultsAndNoWeakFallback() { var catalog = ArchCatalog(); - Assert.Null(catalog.GetSandboxConfig("a")); - var result = catalog.GetSandboxConfigWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }); + Assert.Null(catalog.ResolveSandboxPolicy("a")); + var result = catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }); Assert.Equal(2.0, result.Policy?.TimeoutMs); Assert.Equal("2000-01-01.1", result.Diagnostics.CatalogRevision); Assert.Contains("native system architecture 'arm64'; the tool's architecture was not verified", Warnings(result), StringComparison.Ordinal); @@ -36,7 +36,7 @@ public void OmittedContextUsesHostDefaultsAndNoWeakFallback() [Fact] public void ExplicitArchitectureWinsAndSuppressesTheWarning() { - var result = ArchCatalog().GetSandboxConfigWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }, new ResolveContext { Architecture = "x64" }); + var result = ArchCatalog().ResolveSandboxPolicyWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }, new ResolveContext { Architecture = "x64" }); Assert.Equal(1.0, result.Policy?.TimeoutMs); Assert.Empty(result.Diagnostics.Warnings); } @@ -48,7 +48,7 @@ public void AnotherArchitectureIsNeverAFallback() { TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), })); - var result = catalog.GetSandboxConfigWithDiagnostics("a", Weak with { Architecture = "x64" }); + var result = catalog.ResolveSandboxPolicyWithDiagnostics("a", Weak with { Architecture = "x64" }); Assert.Null(result.Policy); Assert.Equal("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/x64", result.Diagnostics.Warnings[0]); } @@ -58,17 +58,17 @@ public void ArchitectureDetectionFailureIsALibraryErrorAndIsLazy() { var host = new FixedHost("linux", () => throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, "unknown machine")); var catalog = new PolicyCatalog(TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:t") }) }), host); - var error = TestData.Failure(() => catalog.GetSandboxConfig("t", Weak with { ProjectRoot = "/p" })); + var error = TestData.Failure(() => catalog.ResolveSandboxPolicy("t", Weak with { ProjectRoot = "/p" })); Assert.Equal(("unsupported_containment", "unsupported_host"), (error.Code, error.Reason)); - Assert.Null(catalog.GetSandboxConfig("nothing", Weak)); - var explicitArch = catalog.GetSandboxConfig("t", Weak with { Architecture = "x64", ProjectRoot = "/p" }); + Assert.Null(catalog.ResolveSandboxPolicy("nothing", Weak)); + var explicitArch = catalog.ResolveSandboxPolicy("t", Weak with { Architecture = "x64", ProjectRoot = "/p" }); Assert.Equal(new[] { "/p" }, explicitArch?.Filesystem?.ReadwritePaths); } [Fact] public void NeverFabricatesSymbols() { - var result = TestData.Bundled().GetSandboxConfigWithDiagnostics("git", Weak with { Architecture = "x64" }); + var result = TestData.Bundled().ResolveSandboxPolicyWithDiagnostics("git", Weak with { Architecture = "x64" }); Assert.Null(result.Policy); Assert.Matches("(?s)required symbol 'git_prefix'.*required symbol 'project_root'", Warnings(result)); } @@ -85,9 +85,9 @@ public void HostSymbolsOnlyForTheCurrentHostPlatform() """), }); var catalog = TestData.CatalogFor(revision, new FixedHost("linux", "x64", new Dictionary { ["user_home"] = "/home/me" })); - Assert.Equal(new[] { "/home/me/.cfg" }, catalog.GetSandboxConfig("t", Weak)?.Filesystem?.ReadonlyPaths); - Assert.Null(catalog.GetSandboxConfig("t", Weak with { Platform = "macos", Architecture = "arm64" })); - Assert.Equal(new[] { "/srv/u/.cfg" }, catalog.GetSandboxConfig("t", Weak with { Symbols = new Dictionary { ["user_home"] = "/srv/u" } })?.Filesystem?.ReadonlyPaths); + Assert.Equal(new[] { "/home/me/.cfg" }, catalog.ResolveSandboxPolicy("t", Weak)?.Filesystem?.ReadonlyPaths); + Assert.Null(catalog.ResolveSandboxPolicy("t", Weak with { Platform = "macos", Architecture = "arm64" })); + Assert.Equal(new[] { "/srv/u/.cfg" }, catalog.ResolveSandboxPolicy("t", Weak with { Symbols = new Dictionary { ["user_home"] = "/srv/u" } })?.Filesystem?.ReadonlyPaths); } [Fact] @@ -95,10 +95,10 @@ public void StringObjectAndOneElementListAreEquivalent() { var catalog = TestData.Bundled(); var ctx = Weak with { Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["git_prefix"] = "/g" } }; - var a = PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics("git", ctx)); - Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(new ToolInput("git"), ctx))); - Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(new ToolInput[] { "git" }, ctx))); - Assert.Null(catalog.GetSandboxConfig("git", ctx with { AllowWeakIdentityFallback = false })); + var a = PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics("git", ctx)); + Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("git"), ctx))); + Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput[] { "git" }, ctx))); + Assert.Null(catalog.ResolveSandboxPolicy("git", ctx with { AllowWeakIdentityFallback = false })); } public static TheoryData InvalidInputs() => new() @@ -122,7 +122,7 @@ public void StringObjectAndOneElementListAreEquivalent() public void InvalidInputsAreLibraryFailures(string label, ToolInput? tool, ResolveContext ctx) { Assert.NotEmpty(label); - Assert.Equal("invalid_context", TestData.ErrorReason(() => TestData.Bundled().GetSandboxConfig(new[] { tool! }, ctx))); + Assert.Equal("invalid_context", TestData.ErrorReason(() => TestData.Bundled().ResolveSandboxPolicy(new[] { tool! }, ctx))); } [Fact] @@ -130,9 +130,9 @@ public void RelativeOrTemplatedSymbolValuesAreRejected() { var catalog = TestData.Bundled(); var ctx = Weak with { Platform = "linux", Architecture = "x64" }; - var error = TestData.Failure(() => catalog.GetSandboxConfig("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "bin" } })); + var error = TestData.Failure(() => catalog.ResolveSandboxPolicy("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "bin" } })); Assert.Equal("[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path", error.Message); - Assert.Equal("invalid_context", TestData.ErrorReason(() => catalog.GetSandboxConfig("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "/x/${git_prefix}" } }))); + Assert.Equal("invalid_context", TestData.ErrorReason(() => catalog.ResolveSandboxPolicy("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "/x/${git_prefix}" } }))); } [Fact] @@ -141,9 +141,9 @@ public void ResultsAreDeterministicCopies() var catalog = TestData.Bundled(); var ctx = new ResolveContext { Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["npm_prefix"] = "/n", ["npm_cache"] = "/c", ["node_prefix"] = "/n" } }; var tool = new ToolInput("npm") { PackageUrl = "pkg:npm/npm" }; - var first = PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(tool, ctx)); - Assert.Equal(first, PolicyCatalogJson.Serialize(catalog.GetSandboxConfigWithDiagnostics(tool, ctx))); - var policy = catalog.GetSandboxConfig(tool, ctx)!; + var first = PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(tool, ctx)); + Assert.Equal(first, PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(tool, ctx))); + var policy = catalog.ResolveSandboxPolicy(tool, ctx)!; Assert.Equal(new[] { "/n" }, policy.Filesystem!.ReadonlyPaths); Assert.Equal(new[] { "/p", "/c" }, policy.Filesystem!.ReadwritePaths); } @@ -155,13 +155,13 @@ public void DeduplicatesWithPlatformPathRules() { TestData.Entry("tool:w", """{ "platformVariants": [{ "when": { "platform": "windows" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}", "${node_prefix}\\"] } } }] }"""), })); - var policy = windows.GetSandboxConfig("w", Weak with { Platform = "windows", Architecture = "x64", Symbols = new Dictionary { ["git_prefix"] = "C:\\Tools", ["node_prefix"] = "c:\\tools" } }); + var policy = windows.ResolveSandboxPolicy("w", Weak with { Platform = "windows", Architecture = "x64", Symbols = new Dictionary { ["git_prefix"] = "C:\\Tools", ["node_prefix"] = "c:\\tools" } }); Assert.Equal(new[] { "C:\\Tools" }, policy?.Filesystem?.ReadonlyPaths); var linux = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:l", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}", "${node_prefix}/"] } } }] }"""), })); - Assert.Equal(new[] { "/Tools", "/tools" }, linux.GetSandboxConfig("l", Weak with { Symbols = new Dictionary { ["git_prefix"] = "/Tools", ["node_prefix"] = "/tools" } })?.Filesystem?.ReadonlyPaths); + Assert.Equal(new[] { "/Tools", "/tools" }, linux.ResolveSandboxPolicy("l", Weak with { Symbols = new Dictionary { ["git_prefix"] = "/Tools", ["node_prefix"] = "/tools" } })?.Filesystem?.ReadonlyPaths); } [Fact] @@ -170,10 +170,10 @@ public void MacOSFoldsPathCaseForOverlap() string Entry(string platform) => $$"""{ "platformVariants": [{ "when": { "platform": "{{platform}}" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}"], "readwritePaths": ["${project_root}"] } } }] }"""; var mac = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:m", Entry("macos")) })); var ctx = Weak with { Platform = "macos", Architecture = "arm64", ProjectRoot = "/tools/work", Symbols = new Dictionary { ["git_prefix"] = "/Tools" } }; - var error = TestData.Failure(() => mac.GetSandboxConfig("m", ctx)); + var error = TestData.Failure(() => mac.ResolveSandboxPolicy("m", ctx)); Assert.Equal("[policy_validation] resolved paths overlap across access classes: '/Tools' (readonlyPaths) overlaps '/tools/work' (readwritePaths)", error.Message); var linux = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:m", Entry("linux")) })); - Assert.NotNull(linux.GetSandboxConfig("m", ctx with { Platform = "linux", Architecture = "x64" })); + Assert.NotNull(linux.ResolveSandboxPolicy("m", ctx with { Platform = "linux", Architecture = "x64" })); } [Fact] @@ -189,7 +189,7 @@ public void InvocationNameCasingFollowsThePlatform() """), })); IEnumerable On(string platform, string name) => - catalog.GetSandboxConfigWithDiagnostics(name, Weak with { Platform = platform, Architecture = "x64" }).Diagnostics.Tools[0].Matches.Select(m => m.EntryId); + catalog.ResolveSandboxPolicyWithDiagnostics(name, Weak with { Platform = platform, Architecture = "x64" }).Diagnostics.Tools[0].Matches.Select(m => m.EntryId); Assert.Equal(new[] { "tool:gh" }, On("linux", "gh")); Assert.Empty(On("linux", "GH")); Assert.Equal(new[] { "tool:gh" }, On("macos", "GH")); @@ -211,7 +211,7 @@ static string Variant(string[] deps, string path) => $$""" TestData.Entry("tool:right", Variant(new[] { "tool:leaf" }, "right")), TestData.Entry("tool:leaf", Variant(Array.Empty(), "leaf")), })); - var result = catalog.GetSandboxConfigWithDiagnostics("top", Weak with { ProjectRoot = "/r" }); + var result = catalog.ResolveSandboxPolicyWithDiagnostics("top", Weak with { ProjectRoot = "/r" }); Assert.Equal(new[] { "tool:leaf", "tool:left", "tool:right" }, result.Diagnostics.ResolvedDependencies.Select(d => d.EntryId)); Assert.Equal(new[] { "/r/top", "/r/left", "/r/leaf", "/r/right" }, result.Policy?.Filesystem?.ReadonlyPaths); } @@ -225,7 +225,7 @@ public void DependencyDiagnosticsKeepDistinctRangesSorted() TestData.Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:c" }], "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), TestData.Entry("tool:c", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), })); - var result = catalog.GetSandboxConfigWithDiagnostics(new ToolInput[] { "a", "b", "a" }, Weak); + var result = catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput[] { "a", "b", "a" }, Weak); Assert.Equal( new[] { new ResolvedDependency("tool:c", 1, null), new ResolvedDependency("tool:c", 1, ">=2") }, result.Diagnostics.ResolvedDependencies); @@ -241,8 +241,8 @@ public void CatalogFileOrderDoesNotMatter() TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["x"] }], "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/a"] } } }] }"""), }; var ctx = Weak with { ProjectRoot = "/r" }; - var forward = TestData.CatalogFor(TestData.Revision(entries.Select(e => e.DeepClone()))).GetSandboxConfigWithDiagnostics("x", ctx); - var backward = TestData.CatalogFor(TestData.Revision(Enumerable.Reverse(entries).Select(e => e.DeepClone()))).GetSandboxConfigWithDiagnostics("x", ctx); + var forward = TestData.CatalogFor(TestData.Revision(entries.Select(e => e.DeepClone()))).ResolveSandboxPolicyWithDiagnostics("x", ctx); + var backward = TestData.CatalogFor(TestData.Revision(Enumerable.Reverse(entries).Select(e => e.DeepClone()))).ResolveSandboxPolicyWithDiagnostics("x", ctx); Assert.Equal(PolicyCatalogJson.Serialize(forward), PolicyCatalogJson.Serialize(backward)); Assert.Equal(new[] { "/r/a", "/r/b" }, forward.Policy?.Filesystem?.ReadonlyPaths); Assert.Contains("input 0 ('x') matched 2 entries (tool:a, tool:b); all contribute", forward.Diagnostics.Warnings); @@ -256,9 +256,9 @@ public void MixedVersionsAcrossInputsAreACompositionConflict() TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.8.0-alpha" } }] }"""), TestData.Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), })); - var error = TestData.Failure(() => catalog.GetSandboxConfig(new ToolInput[] { "a", "b" }, Weak)); + var error = TestData.Failure(() => catalog.ResolveSandboxPolicy(new ToolInput[] { "a", "b" }, Weak)); Assert.Equal("[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)", error.Message); - Assert.Equal("""{"version":"0.8.0-alpha"}""", PolicyCatalogJson.Serialize(catalog.GetSandboxConfig("a", Weak))); + Assert.Equal("""{"version":"0.8.0-alpha"}""", PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicy("a", Weak))); } [Fact] @@ -269,10 +269,10 @@ public void VersionRangeWarnings() TestData.Entry("tool:app", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/app", "versionRange": ">=2 <3" }] }"""), })); var ctx = new ResolveContext { ProjectRoot = "/p" }; - Assert.DoesNotContain(catalog.GetSandboxConfigWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@2.1.0" }, ctx).Diagnostics.Warnings, w => w.Contains("range", StringComparison.Ordinal)); + Assert.DoesNotContain(catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@2.1.0" }, ctx).Diagnostics.Warnings, w => w.Contains("range", StringComparison.Ordinal)); Assert.Contains( "input 0 ('app'): detected version 'nightly' could not be compared with the reviewed range '>=2 <3' for tool:app", - catalog.GetSandboxConfigWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@3.0.0", DetectedVersion = "nightly" }, ctx).Diagnostics.Warnings); + catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@3.0.0", DetectedVersion = "nightly" }, ctx).Diagnostics.Warnings); } [Fact] @@ -294,10 +294,10 @@ public void InspectionListsMetadataOnly() public void BundledConvenienceFunctions() { var ctx = Weak with { Platform = "linux", Architecture = "x64" }; - Assert.Null(BundledPolicyCatalog.GetSandboxConfig("definitely-unknown-tool", ctx)); - var result = BundledPolicyCatalog.GetSandboxConfigWithDiagnostics(new ToolInput[] { "definitely-unknown-tool" }, ctx); + Assert.Null(BundledPolicyCatalog.ResolveSandboxPolicy("definitely-unknown-tool", ctx)); + var result = BundledPolicyCatalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput[] { "definitely-unknown-tool" }, ctx); Assert.Null(result.Policy); Assert.Equal("""{"diagnostics":{"catalogRevision":"2026-09-29.1","tools":[{"inputIndex":0,"matches":[]}],"resolvedDependencies":[],"warnings":["input 0 ('definitely-unknown-tool') matched no eligible catalog entry"]}}""", PolicyCatalogJson.Serialize(result)); - Assert.Equal("revision_unavailable", TestData.ErrorReason(() => BundledPolicyCatalog.GetSandboxConfig("git", ctx with { CatalogRevision = "1999-01-01.1" }))); + Assert.Equal("revision_unavailable", TestData.ErrorReason(() => BundledPolicyCatalog.ResolveSandboxPolicy("git", ctx with { CatalogRevision = "1999-01-01.1" }))); } } diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs index 41c3840c8..ff7ccdc55 100644 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs @@ -40,8 +40,8 @@ public void TamperedRevisionIsAnIntegrityError() Assert.Matches("^\\[backend_error\\] catalog revision '2000-01-01.1' digest [0-9a-f]{64} does not match the published digest 0{64}$", error.Message); var catalog = new PolicyCatalog(store, new FixedHost()); var weak = new ResolveContext { AllowWeakIdentityFallback = true }; - Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetSandboxConfig("a", weak))); - Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetSandboxConfigWithDiagnostics("a", weak))); + Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ResolveSandboxPolicy("a", weak))); + Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ResolveSandboxPolicyWithDiagnostics("a", weak))); Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ListCatalogEntries())); Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetCatalogInfo())); } @@ -80,11 +80,11 @@ public void ExplicitRevisionIsNeverSubstituted() var r2 = TestData.Revision(new[] { TestData.Entry("tool:a", """{ "entryRevision": 2, "displayName": "renamed" }"""), TestData.Entry("tool:b") }, "2000-01-02.1"); var catalog = new PolicyCatalog(TestData.StoreFor(new[] { r1, r2 }), new FixedHost()); var ctx = new ResolveContext { AllowWeakIdentityFallback = true, ProjectRoot = "/p" }; - var latest = catalog.GetSandboxConfigWithDiagnostics("a", ctx); + var latest = catalog.ResolveSandboxPolicyWithDiagnostics("a", ctx); Assert.Equal(("2000-01-02.1", 2.0), (latest.Diagnostics.CatalogRevision, latest.Diagnostics.Tools[0].Matches[0].EntryRevision)); - var older = catalog.GetSandboxConfigWithDiagnostics("a", ctx with { CatalogRevision = "2000-01-01.1" }); + var older = catalog.ResolveSandboxPolicyWithDiagnostics("a", ctx with { CatalogRevision = "2000-01-01.1" }); Assert.Equal(("2000-01-01.1", 1.0), (older.Diagnostics.CatalogRevision, older.Diagnostics.Tools[0].Matches[0].EntryRevision)); - var error = TestData.Failure(() => catalog.GetSandboxConfig("a", ctx with { CatalogRevision = "2000-01-03.1" })); + var error = TestData.Failure(() => catalog.ResolveSandboxPolicy("a", ctx with { CatalogRevision = "2000-01-03.1" })); Assert.Equal(("backend_error", "revision_unavailable", "[backend_error] catalog revision '2000-01-03.1' is not installed"), (error.Code, error.Reason, error.Message)); } diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs index 03d028f36..1f043d834 100644 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs @@ -69,7 +69,7 @@ public IReadOnlyList ListCatalogEntries() /// Lookup context; null means all defaults. /// The policy or null. /// A library failure; never reported as absence. - public CatalogSandboxPolicy? GetSandboxConfig(IReadOnlyList tools, ResolveContext? context = null) => + public CatalogSandboxPolicy? ResolveSandboxPolicy(IReadOnlyList tools, ResolveContext? context = null) => Resolve(tools, context).Policy; /// The composed candidate policy for one tool; exactly a one-element list. @@ -77,7 +77,7 @@ public IReadOnlyList ListCatalogEntries() /// Lookup context. /// The policy or null. /// A library failure. - public CatalogSandboxPolicy? GetSandboxConfig(ToolInput tool, ResolveContext? context = null) => + public CatalogSandboxPolicy? ResolveSandboxPolicy(ToolInput tool, ResolveContext? context = null) => Resolve(new[] { tool }, context).Policy; /// @@ -89,7 +89,7 @@ public IReadOnlyList ListCatalogEntries() /// Lookup context. /// The resolution. /// A library failure. - public SandboxConfigResolution GetSandboxConfigWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => + public SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => Resolve(tools, context); /// Resolves one tool; exactly a one-element list. @@ -97,7 +97,7 @@ public SandboxConfigResolution GetSandboxConfigWithDiagnostics(IReadOnlyListLookup context. /// The resolution. /// A library failure. - public SandboxConfigResolution GetSandboxConfigWithDiagnostics(ToolInput tool, ResolveContext? context = null) => + public SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(ToolInput tool, ResolveContext? context = null) => Resolve(new[] { tool }, context); private sealed record EntryMatch(Entry Entry, VariantSelection Selection, List Satisfied); @@ -549,25 +549,25 @@ public static class BundledPolicyCatalog /// Tool inputs. /// Lookup context. /// The policy or null. - public static CatalogSandboxPolicy? GetSandboxConfig(IReadOnlyList tools, ResolveContext? context = null) => Catalog.GetSandboxConfig(tools, context); + public static CatalogSandboxPolicy? ResolveSandboxPolicy(IReadOnlyList tools, ResolveContext? context = null) => Catalog.ResolveSandboxPolicy(tools, context); /// Composed candidate policy for one tool, from the bundled catalog. /// The tool input. /// Lookup context. /// The policy or null. - public static CatalogSandboxPolicy? GetSandboxConfig(ToolInput tool, ResolveContext? context = null) => Catalog.GetSandboxConfig(tool, context); + public static CatalogSandboxPolicy? ResolveSandboxPolicy(ToolInput tool, ResolveContext? context = null) => Catalog.ResolveSandboxPolicy(tool, context); /// Composed candidate policy plus attribution for several tools. /// Tool inputs. /// Lookup context. /// The resolution. - public static SandboxConfigResolution GetSandboxConfigWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => Catalog.GetSandboxConfigWithDiagnostics(tools, context); + public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => Catalog.ResolveSandboxPolicyWithDiagnostics(tools, context); /// Composed candidate policy plus attribution for one tool. /// The tool input. /// Lookup context. /// The resolution. - public static SandboxConfigResolution GetSandboxConfigWithDiagnostics(ToolInput tool, ResolveContext? context = null) => Catalog.GetSandboxConfigWithDiagnostics(tool, context); + public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(ToolInput tool, ResolveContext? context = null) => Catalog.ResolveSandboxPolicyWithDiagnostics(tool, context); /// Bundled catalog entry metadata. /// Entry metadata ordered by entry ID. diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs index 68d9956ee..60ab674fd 100644 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs +++ b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs @@ -150,7 +150,7 @@ public sealed record ResolutionDiagnostics( IReadOnlyList ResolvedDependencies, IReadOnlyList Warnings); -/// Result of . +/// Result of . /// The composed candidate policy, or null when none can be resolved. /// Attribution and warnings. public sealed record SandboxConfigResolution(CatalogSandboxPolicy? Policy, ResolutionDiagnostics Diagnostics); diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs index 27af8af6c..e15bdc906 100644 --- a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs +++ b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs @@ -142,7 +142,7 @@ public void UnknownToolYieldsNoPolicyWithWarning() Assert.Equal("""[{"inputIndex":0,"matches":[]}]""", result.Json["diagnostics"]!["tools"]!.ToJsonString(Relaxed)); Assert.Equal("input 0 ('cargo') matched no eligible catalog entry", result.Warnings); Assert.Equal("null\n", Cli(Args("resolve", FullContext("linux", "x64"), "cargo")).Stdout); - var library = BundledPolicyCatalog.GetSandboxConfigWithDiagnostics("cargo", new ResolveContext { Platform = "linux", Architecture = "x64" }); + var library = BundledPolicyCatalog.ResolveSandboxPolicyWithDiagnostics("cargo", new ResolveContext { Platform = "linux", Architecture = "x64" }); Assert.Null(library.Policy); Assert.Equal(new[] { "input 0 ('cargo') matched no eligible catalog entry" }, library.Diagnostics.Warnings); } @@ -161,8 +161,8 @@ public void WeakIdentityRequiresOptIn() var catalog = new PolicyCatalog(CatalogStore.Bundled()); var ctx = new ResolveContext { Platform = "linux", Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["git_prefix"] = "/usr/bin" } }; - Assert.Null(catalog.GetSandboxConfig("git", ctx)); - Assert.NotNull(catalog.GetSandboxConfig(new ToolInput("git"), ctx with { AllowWeakIdentityFallback = true })); + Assert.Null(catalog.ResolveSandboxPolicy("git", ctx)); + Assert.NotNull(catalog.ResolveSandboxPolicy(new ToolInput("git"), ctx with { AllowWeakIdentityFallback = true })); var strong = Cli(Args("resolve", common, "--purl", "pkg:npm/npm@11.0.0", "npm")); Assert.NotNull(strong.Json!["policy"]); @@ -211,7 +211,7 @@ public void UnavailableRevisionIsNeverSubstituted() var result = Cli(Args("resolve", "--revision", "2099-01-01.1", FullContext("linux", "x64"), "git")); Assert.Equal(1, result.Status); Assert.Equal("""{"code":"backend_error","message":"[backend_error] catalog revision '2099-01-01.1' is not installed","details":{"reason":"revision_unavailable"}}""", result.Json!["error"]!.ToJsonString(Relaxed)); - var error = Assert.Throws(() => BundledPolicyCatalog.GetSandboxConfig("git", new ResolveContext { CatalogRevision = "2099-01-01.1", Platform = "linux", Architecture = "x64" })); + var error = Assert.Throws(() => BundledPolicyCatalog.ResolveSandboxPolicy("git", new ResolveContext { CatalogRevision = "2099-01-01.1", Platform = "linux", Architecture = "x64" })); Assert.Equal("revision_unavailable", error.Reason); var r1 = Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1"); @@ -230,7 +230,7 @@ public void HostDefaultArchitectureAndNeutralFallbackWarnings(string platform, s var catalog = new PolicyCatalog(CatalogStore.Bundled(), new FixedHost(platform, architecture)); var c = PlatformContext[platform]; var ctx = new ResolveContext { AllowWeakIdentityFallback = true, ProjectRoot = c.Root, Symbols = new Dictionary { ["git_prefix"] = c.Prefix } }; - var omitted = catalog.GetSandboxConfigWithDiagnostics("git", ctx); + var omitted = catalog.ResolveSandboxPolicyWithDiagnostics("git", ctx); Assert.Equal( new[] { @@ -239,7 +239,7 @@ public void HostDefaultArchitectureAndNeutralFallbackWarnings(string platform, s $"tool:git uses its architecture-neutral {platform} variant; no {architecture}-specific variant exists", }, omitted.Diagnostics.Warnings); - var explicitArch = catalog.GetSandboxConfigWithDiagnostics("git", ctx with { Platform = platform, Architecture = architecture }); + var explicitArch = catalog.ResolveSandboxPolicyWithDiagnostics("git", ctx with { Platform = platform, Architecture = architecture }); Assert.DoesNotContain(explicitArch.Diagnostics.Warnings, w => w.StartsWith("architecture was not specified", StringComparison.Ordinal)); Assert.Equal(PolicyCatalogJson.Serialize(omitted.Policy), PolicyCatalogJson.Serialize(explicitArch.Policy)); @@ -366,7 +366,7 @@ public void ResolveNeverReturnsAPolicyFromACyclicCatalog() Assert.Equal("policy_validation", result.Json?["error"]?["code"]?.GetValue()); Assert.Equal("invalid_catalog", result.ErrorReason); Assert.Contains("cycle (tool:a -> tool:b -> tool:a)", result.ErrorMessage, StringComparison.Ordinal); - var error = Assert.Throws(() => new PolicyCatalog(CatalogStore.FromDirectory(dir)).GetSandboxConfig("a", new ResolveContext { AllowWeakIdentityFallback = true, Platform = "linux", Architecture = "x64" })); + var error = Assert.Throws(() => new PolicyCatalog(CatalogStore.FromDirectory(dir)).ResolveSandboxPolicy("a", new ResolveContext { AllowWeakIdentityFallback = true, Platform = "linux", Architecture = "x64" })); Assert.Equal("invalid_catalog", error.Reason); } diff --git a/policy-catalog/rust/README.md b/policy-catalog/rust/README.md index e88d5e629..143cf6a6d 100644 --- a/policy-catalog/rust/README.md +++ b/policy-catalog/rust/README.md @@ -4,7 +4,7 @@ A native Rust implementation of the policy catalog library. It behaves the same as the TypeScript library in `../src`. It is not a wrapper, and it needs no Node at runtime. The catalog is compiled into the crate. -- Library: `mxc_policy_catalog` provides `get_sandbox_config`, `get_sandbox_config_with_diagnostics`, +- Library: `mxc_policy_catalog` provides `resolve_sandbox_policy`, `resolve_sandbox_policy_with_diagnostics`, `get_catalog_info`, `list_catalog_entries`, and `PolicyCatalog` (a store plus an injectable `HostEnvironment`). Contribution and CI rules are in `mxc_policy_catalog::tooling`. - Binary: `policy-catalog resolve | inspect | validate`. Its output matches `node dist/cli.js`. diff --git a/policy-catalog/rust/functional/tests/functional.rs b/policy-catalog/rust/functional/tests/functional.rs index 035c0bd65..4e8b12dac 100644 --- a/policy-catalog/rust/functional/tests/functional.rs +++ b/policy-catalog/rust/functional/tests/functional.rs @@ -43,7 +43,7 @@ fn unknown_tool_is_no_policy_with_warning() { let plain = cli(&args(&["resolve"], &ctx).into_iter().chain(["cargo"]).collect::>()); assert_eq!(plain.stdout, "null\n"); // Library API over the bundled catalog. - let r = mxc_policy_catalog::get_sandbox_config_with_diagnostics( + let r = mxc_policy_catalog::resolve_sandbox_policy_with_diagnostics( "cargo", &ResolveContext::new().platform(Platform::Linux).architecture(Architecture::X64).allow_weak(true), ) @@ -89,7 +89,7 @@ fn dependency_cycle_fails_validate_and_resolve() { // Library API. let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); let err = catalog - .get_sandbox_config("a", &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) + .resolve_sandbox_policy("a", &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) .unwrap_err(); assert_eq!(err.reason(), ErrorReason::InvalidCatalog); assert!(err.message().contains("cycle ("), "{}", err.message()); @@ -188,7 +188,7 @@ fn unavailable_revision_is_an_error_never_a_substitution() { assert_eq!(r.error_reason().as_deref(), Some("revision_unavailable")); let message = r.json.as_ref().unwrap().get("error").unwrap().get("message").and_then(Json::as_str).unwrap(); assert_eq!(message, "[backend_error] catalog revision '2099-01-01.1' is not installed"); - let err = mxc_policy_catalog::get_sandbox_config("git", &ResolveContext::new().catalog_revision("1999-01-01.1")).unwrap_err(); + let err = mxc_policy_catalog::resolve_sandbox_policy("git", &ResolveContext::new().catalog_revision("1999-01-01.1")).unwrap_err(); assert_eq!(err.reason(), ErrorReason::RevisionUnavailable); // An older installed revision stays addressable. @@ -246,10 +246,10 @@ fn weak_identity_requires_opt_in() { .architecture(Architecture::X64) .project_root("/p") .symbol("git_prefix", "/usr/bin"); - assert_eq!(catalog.get_sandbox_config("git", &ctx).unwrap(), None); - assert_eq!(catalog.get_sandbox_config(ToolCandidate::new("git"), &ctx).unwrap(), None); + assert_eq!(catalog.resolve_sandbox_policy("git", &ctx).unwrap(), None); + assert_eq!(catalog.resolve_sandbox_policy(ToolCandidate::new("git"), &ctx).unwrap(), None); let on = ctx.allow_weak(true); - assert!(catalog.get_sandbox_config("git", &on).unwrap().is_some()); + assert!(catalog.resolve_sandbox_policy("git", &on).unwrap().is_some()); } // --------------------------------------------------------------------------- @@ -302,14 +302,14 @@ fn host_default_architecture_and_neutral_fallback_warnings() { let root = if platform == Platform::Windows { "C:\\w" } else { "/w" }; let prefix = if platform == Platform::Windows { "C:\\g" } else { "/g" }; let ctx = ResolveContext::new().allow_weak(true).project_root(root).symbol("git_prefix", prefix); - let omitted = catalog.get_sandbox_config_with_diagnostics("git", &ctx).unwrap(); + let omitted = catalog.resolve_sandbox_policy_with_diagnostics("git", &ctx).unwrap(); let w = omitted.diagnostics.warnings.join("\n"); assert!(w.contains(&format!( "architecture was not specified; variants were selected for the native system architecture '{arch}'; the tool's architecture was not verified" )), "{w}"); assert!(w.contains(&format!("tool:git uses its architecture-neutral {platform} variant; no {arch}-specific variant exists"))); let explicit = catalog - .get_sandbox_config_with_diagnostics("git", &ctx.clone().platform(platform).architecture(arch)) + .resolve_sandbox_policy_with_diagnostics("git", &ctx.clone().platform(platform).architecture(arch)) .unwrap(); assert!(!explicit.diagnostics.warnings.join("\n").contains("was not verified")); assert_eq!(explicit.policy, omitted.policy); @@ -360,7 +360,7 @@ fn one_input_matching_several_entries_warns_and_composes_all() { // Library API. let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); let res = catalog - .get_sandbox_config_with_diagnostics("app", &ResolveContext::new().allow_weak(true).architecture(Architecture::X64).symbol("git_prefix", "/opt")) + .resolve_sandbox_policy_with_diagnostics("app", &ResolveContext::new().allow_weak(true).architecture(Architecture::X64).symbol("git_prefix", "/opt")) .unwrap(); assert_eq!(res.diagnostics.tools[0].matches.len(), 2); } @@ -403,7 +403,7 @@ fn composition_conflicts_are_errors_not_choices() { // Library API. let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); let err = catalog - .get_sandbox_config(vec!["a", "b"], &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) + .resolve_sandbox_policy(vec!["a", "b"], &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) .unwrap_err(); assert_eq!(err.reason(), ErrorReason::CompositionConflict); } diff --git a/policy-catalog/rust/src/cli.rs b/policy-catalog/rust/src/cli.rs index 248129c3a..fcc7d4d72 100644 --- a/policy-catalog/rust/src/cli.rs +++ b/policy-catalog/rust/src/cli.rs @@ -219,11 +219,11 @@ fn resolve_command(args: &[String], catalog_dir: &Option, out: &mut Stri let tools = ToolInputs(tools); if diagnostics { print( - &catalog.get_sandbox_config_with_diagnostics(tools, &ctx)?.to_json(), + &catalog.resolve_sandbox_policy_with_diagnostics(tools, &ctx)?.to_json(), out, ); } else { - let policy = catalog.get_sandbox_config(tools, &ctx)?; + let policy = catalog.resolve_sandbox_policy(tools, &ctx)?; print(&policy.map_or(Json::Null, |p| p.to_json()), out); } Ok(0) diff --git a/policy-catalog/rust/src/lib.rs b/policy-catalog/rust/src/lib.rs index 2a760e21c..3325ab527 100644 --- a/policy-catalog/rust/src/lib.rs +++ b/policy-catalog/rust/src/lib.rs @@ -5,8 +5,8 @@ //! catalog library, with behavioral parity to the TypeScript implementation //! in `policy-catalog/src`. //! -//! The runtime lookup API ([`get_sandbox_config`], -//! [`get_sandbox_config_with_diagnostics`], [`get_catalog_info`], +//! The runtime lookup API ([`resolve_sandbox_policy`], +//! [`resolve_sandbox_policy_with_diagnostics`], [`get_catalog_info`], //! [`list_catalog_entries`], and [`PolicyCatalog`]) returns a **candidate //! lower-bound** policy. It never grants access, launches a sandbox, contacts //! a network service, or writes consumer state. The catalog bundled with this @@ -38,7 +38,8 @@ pub use model::{ ToolInputs, ToolRecord, }; pub use resolver::{ - get_catalog_info, get_sandbox_config, get_sandbox_config_with_diagnostics, list_catalog_entries, PolicyCatalog, + get_catalog_info, list_catalog_entries, resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, + PolicyCatalog, }; pub use store::{ bundled_catalog_store, load_catalog_directory, CatalogManifest, CatalogSource, CatalogStore, DirectorySource, diff --git a/policy-catalog/rust/src/model.rs b/policy-catalog/rust/src/model.rs index c0543b30f..72bdcc505 100644 --- a/policy-catalog/rust/src/model.rs +++ b/policy-catalog/rust/src/model.rs @@ -439,7 +439,7 @@ impl Diagnostics { } } -/// Result of `get_sandbox_config_with_diagnostics` (design §5.1). +/// Result of `resolve_sandbox_policy_with_diagnostics` (design §5.1). #[derive(Clone, Debug, PartialEq)] pub struct SandboxConfigResolution { pub policy: Option, diff --git a/policy-catalog/rust/src/resolver.rs b/policy-catalog/rust/src/resolver.rs index c6eb51a51..4e9cc72e1 100644 --- a/policy-catalog/rust/src/resolver.rs +++ b/policy-catalog/rust/src/resolver.rs @@ -168,7 +168,7 @@ impl PolicyCatalog { // ----------------------------------------------------------------------- /// The composed candidate policy, or `None` when no policy can be resolved. - pub fn get_sandbox_config( + pub fn resolve_sandbox_policy( &self, tools: impl Into, ctx: &ResolveContext, @@ -180,7 +180,7 @@ impl PolicyCatalog { /// candidate policy with attribution and warnings. Library failures are /// errors, never absence. The result is a candidate lower bound, not /// authorization. - pub fn get_sandbox_config_with_diagnostics( + pub fn resolve_sandbox_policy_with_diagnostics( &self, tools: impl Into, ctx: &ResolveContext, @@ -621,16 +621,16 @@ fn bundled() -> Result<&'static PolicyCatalog> { } /// Composed candidate policy for one or several tools, from the bundled catalog. -pub fn get_sandbox_config(tools: impl Into, ctx: &ResolveContext) -> Result> { - bundled()?.get_sandbox_config(tools, ctx) +pub fn resolve_sandbox_policy(tools: impl Into, ctx: &ResolveContext) -> Result> { + bundled()?.resolve_sandbox_policy(tools, ctx) } /// Composed candidate policy plus attribution, from the bundled catalog. -pub fn get_sandbox_config_with_diagnostics( +pub fn resolve_sandbox_policy_with_diagnostics( tools: impl Into, ctx: &ResolveContext, ) -> Result { - bundled()?.get_sandbox_config_with_diagnostics(tools, ctx) + bundled()?.resolve_sandbox_policy_with_diagnostics(tools, ctx) } /// Bundled catalog entry metadata. diff --git a/policy-catalog/rust/tests/conformance.rs b/policy-catalog/rust/tests/conformance.rs index ca0af22df..00c50bb40 100644 --- a/policy-catalog/rust/tests/conformance.rs +++ b/policy-catalog/rust/tests/conformance.rs @@ -57,12 +57,12 @@ fn all_conformance_fixtures() { expect_error.get("reason").and_then(Json::as_str).unwrap().to_string(), )); assert_eq!( - failure(catalog.get_sandbox_config_with_diagnostics(tools.clone(), &ctx)), + failure(catalog.resolve_sandbox_policy_with_diagnostics(tools.clone(), &ctx)), expected, "{case_name}" ); assert_eq!( - failure(catalog.get_sandbox_config(tools, &ctx)), + failure(catalog.resolve_sandbox_policy(tools, &ctx)), expected, "{case_name}" ); @@ -79,14 +79,14 @@ fn all_conformance_fixtures() { let expected = Json::Object(expected); let actual = catalog - .get_sandbox_config_with_diagnostics(tools.clone(), &ctx) + .resolve_sandbox_policy_with_diagnostics(tools.clone(), &ctx) .unwrap(); assert_eq!(actual.to_json(), expected, "{case_name}\nactual: {}", actual.to_json()); - let policy = catalog.get_sandbox_config(tools.clone(), &ctx).unwrap(); + let policy = catalog.resolve_sandbox_policy(tools.clone(), &ctx).unwrap(); assert_eq!(policy.map(|p| p.to_json()), expected_policy, "{case_name}"); if !matches!(raw_tools, Json::Array(_)) { let as_array = tools_from(&Json::Array(vec![raw_tools.clone()])); - let again = catalog.get_sandbox_config_with_diagnostics(as_array, &ctx).unwrap(); + let again = catalog.resolve_sandbox_policy_with_diagnostics(as_array, &ctx).unwrap(); assert_eq!(again, actual, "{case_name}: single input == one-element array"); } cases += 1; diff --git a/policy-catalog/rust/tests/library.rs b/policy-catalog/rust/tests/library.rs index 08e661445..91808ef11 100644 --- a/policy-catalog/rust/tests/library.rs +++ b/policy-catalog/rust/tests/library.rs @@ -12,8 +12,8 @@ use mxc_policy_catalog::tooling::{ validate_catalog_revision, validate_contract, Json, PublishedRevision, PublishedState, }; use mxc_policy_catalog::{ - get_catalog_info, get_sandbox_config, get_sandbox_config_with_diagnostics, list_catalog_entries, Architecture, - ErrorReason, FixedHost, HostEnvironment, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, + get_catalog_info, list_catalog_entries, resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, + Architecture, ErrorReason, FixedHost, HostEnvironment, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, ToolCandidate, ToolInput, ToolInputs, }; use std::collections::HashMap; @@ -79,9 +79,12 @@ fn arch_catalog(host: Arc) -> PolicyCatalog { #[test] fn omitted_context_uses_host_platform_native_arch_default_revision_no_weak() { let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); - assert_eq!(catalog.get_sandbox_config("a", &ResolveContext::new()).unwrap(), None); + assert_eq!( + catalog.resolve_sandbox_policy("a", &ResolveContext::new()).unwrap(), + None + ); let result = catalog - .get_sandbox_config_with_diagnostics( + .resolve_sandbox_policy_with_diagnostics( ToolCandidate::new("a").with_package_url("pkg:npm/a"), &ResolveContext::new(), ) @@ -99,7 +102,7 @@ fn omitted_context_uses_host_platform_native_arch_default_revision_no_weak() { fn explicit_architecture_wins_and_suppresses_host_default_warning() { let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); let result = catalog - .get_sandbox_config_with_diagnostics( + .resolve_sandbox_policy_with_diagnostics( ToolCandidate::new("a").with_package_url("pkg:npm/a"), &ResolveContext::new().architecture(Architecture::X64), ) @@ -120,7 +123,7 @@ fn another_architecture_is_never_a_fallback() { linux_x64(), ); let result = catalog - .get_sandbox_config_with_diagnostics("a", &weak().architecture(Architecture::X64)) + .resolve_sandbox_policy_with_diagnostics("a", &weak().architecture(Architecture::X64)) .unwrap(); assert_eq!(result.policy, None); assert!(result.diagnostics.warnings[0].contains("tool:a has no variant for linux/x64")); @@ -143,12 +146,14 @@ impl HostEnvironment for FailingHost { fn host_detection_failure_is_an_error_and_only_attempted_when_needed() { let store = Arc::new(store_for(&[revision(vec![entry("tool:t", "")])]).unwrap()); let catalog = PolicyCatalog::with_host(store, Arc::new(FailingHost)); - let error = catalog.get_sandbox_config("t", &weak().project_root("/p")).unwrap_err(); + let error = catalog + .resolve_sandbox_policy("t", &weak().project_root("/p")) + .unwrap_err(); assert!(error.message().contains("unknown machine")); assert_eq!(error.reason(), ErrorReason::UnsupportedHost); - assert_eq!(catalog.get_sandbox_config("nothing", &weak()).unwrap(), None); + assert_eq!(catalog.resolve_sandbox_policy("nothing", &weak()).unwrap(), None); let policy = catalog - .get_sandbox_config("t", &weak().architecture(Architecture::X64).project_root("/p")) + .resolve_sandbox_policy("t", &weak().architecture(Architecture::X64).project_root("/p")) .unwrap(); assert_eq!(fs_paths(&policy), j(r#"{"readwritePaths":["/p"]}"#)); } @@ -156,7 +161,7 @@ fn host_detection_failure_is_an_error_and_only_attempted_when_needed() { #[test] fn never_fabricates_project_root_or_caller_symbols() { let result = bundled_catalog(linux_x64()) - .get_sandbox_config_with_diagnostics("git", &weak().architecture(Architecture::X64)) + .resolve_sandbox_policy_with_diagnostics("git", &weak().architecture(Architecture::X64)) .unwrap(); assert_eq!(result.policy, None); let warnings = result.diagnostics.warnings.join("\n"); @@ -178,19 +183,19 @@ fn host_symbols_only_for_current_host_platform_and_caller_overrides() { let host = Arc::new(FixedHost::new(Platform::Linux, Architecture::X64).with_symbol("user_home", "/home/me")); let catalog = catalog_for(rev, host); assert_eq!( - fs_paths(&catalog.get_sandbox_config("t", &weak()).unwrap()), + fs_paths(&catalog.resolve_sandbox_policy("t", &weak()).unwrap()), j(r#"{"readonlyPaths":["/home/me/.cfg"]}"#) ); assert_eq!( catalog - .get_sandbox_config("t", &weak().platform(Platform::Macos).architecture(Architecture::Arm64)) + .resolve_sandbox_policy("t", &weak().platform(Platform::Macos).architecture(Architecture::Arm64)) .unwrap(), None ); assert_eq!( fs_paths( &catalog - .get_sandbox_config("t", &weak().symbol("user_home", "/srv/u")) + .resolve_sandbox_policy("t", &weak().symbol("user_home", "/srv/u")) .unwrap() ), j(r#"{"readonlyPaths":["/srv/u/.cfg"]}"#) @@ -208,15 +213,17 @@ fn string_object_and_one_element_array_are_equivalent() { .architecture(Architecture::X64) .project_root("/p") .symbol("git_prefix", "/g"); - let a = catalog.get_sandbox_config_with_diagnostics("git", &ctx).unwrap(); + let a = catalog.resolve_sandbox_policy_with_diagnostics("git", &ctx).unwrap(); assert_eq!( catalog - .get_sandbox_config_with_diagnostics(ToolCandidate::new("git"), &ctx) + .resolve_sandbox_policy_with_diagnostics(ToolCandidate::new("git"), &ctx) .unwrap(), a ); assert_eq!( - catalog.get_sandbox_config_with_diagnostics(vec!["git"], &ctx).unwrap(), + catalog + .resolve_sandbox_policy_with_diagnostics(vec!["git"], &ctx) + .unwrap(), a ); assert_eq!(a.diagnostics.tools[0].input_index, 0); @@ -224,9 +231,11 @@ fn string_object_and_one_element_array_are_equivalent() { .architecture(Architecture::X64) .project_root("/p") .symbol("git_prefix", "/g"); - assert_eq!(catalog.get_sandbox_config("git", &strict).unwrap(), None); + assert_eq!(catalog.resolve_sandbox_policy("git", &strict).unwrap(), None); assert_eq!( - catalog.get_sandbox_config(ToolCandidate::new("git"), &strict).unwrap(), + catalog + .resolve_sandbox_policy(ToolCandidate::new("git"), &strict) + .unwrap(), None ); } @@ -270,7 +279,7 @@ fn invalid_context_and_inputs_are_failures_not_absence() { ]; for (tools, ctx) in cases { assert_eq!( - reason_of(catalog.get_sandbox_config(tools.clone(), &ctx)), + reason_of(catalog.resolve_sandbox_policy(tools.clone(), &ctx)), ErrorReason::InvalidContext, "{tools:?} {ctx:?}" ); @@ -278,7 +287,7 @@ fn invalid_context_and_inputs_are_failures_not_absence() { // A relative or `${`-containing symbol value is rejected once it is needed. for value in ["bin", "/x/${node_prefix}"] { let ctx = weak().architecture(Architecture::X64).symbol("node_prefix", value); - let error = catalog.get_sandbox_config("node", &ctx).unwrap_err(); + let error = catalog.resolve_sandbox_policy("node", &ctx).unwrap_err(); assert_eq!( error.message(), "[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path" @@ -296,7 +305,9 @@ fn results_are_caller_owned_and_deterministic() { .symbol("npm_cache", "/c") .symbol("node_prefix", "/n"); let tool = ToolCandidate::new("npm").with_package_url("pkg:npm/npm"); - let mut first = catalog.get_sandbox_config_with_diagnostics(tool.clone(), &ctx).unwrap(); + let mut first = catalog + .resolve_sandbox_policy_with_diagnostics(tool.clone(), &ctx) + .unwrap(); first .policy .as_mut() @@ -309,7 +320,7 @@ fn results_are_caller_owned_and_deterministic() { .unwrap() .push("/mutated".into()); first.diagnostics.warnings.push("mutated".into()); - let second = catalog.get_sandbox_config_with_diagnostics(tool, &ctx).unwrap(); + let second = catalog.resolve_sandbox_policy_with_diagnostics(tool, &ctx).unwrap(); assert_eq!( fs_paths(&second.policy), j(r#"{"readonlyPaths":["/n"],"readwritePaths":["/p","/c"]}"#) @@ -334,7 +345,7 @@ fn dedupes_normalized_paths_with_platform_casing() { .symbol("git_prefix", "C:\\Tools") .symbol("node_prefix", "c:\\tools"); assert_eq!( - fs_paths(&windows.get_sandbox_config("w", &ctx).unwrap()), + fs_paths(&windows.resolve_sandbox_policy("w", &ctx).unwrap()), j(r#"{"readonlyPaths":["C:\\Tools"]}"#) ); let tmpl = |platform: &str, id: &str| { @@ -348,13 +359,13 @@ fn dedupes_normalized_paths_with_platform_casing() { let linux = catalog_for(revision(vec![tmpl("linux", "tool:l")]), linux_x64()); let ctx = weak().symbol("git_prefix", "/Tools").symbol("node_prefix", "/tools"); assert_eq!( - fs_paths(&linux.get_sandbox_config("l", &ctx).unwrap()), + fs_paths(&linux.resolve_sandbox_policy("l", &ctx).unwrap()), j(r#"{"readonlyPaths":["/Tools","/tools"]}"#) ); let mac = catalog_for(revision(vec![tmpl("macos", "tool:m")]), linux_x64()); let ctx = ctx.platform(Platform::Macos).architecture(Architecture::Arm64); assert_eq!( - fs_paths(&mac.get_sandbox_config("m", &ctx).unwrap()), + fs_paths(&mac.resolve_sandbox_policy("m", &ctx).unwrap()), j(r#"{"readonlyPaths":["/Tools"]}"#) ); } @@ -377,13 +388,13 @@ fn overlap_detection_follows_platform_casing() { .project_root("/tools/work") .symbol("git_prefix", "/Tools"); assert_eq!( - reason_of(make("macos").get_sandbox_config("m", &ctx.clone().platform(Platform::Macos))), + reason_of(make("macos").resolve_sandbox_policy("m", &ctx.clone().platform(Platform::Macos))), ErrorReason::CompositionConflict ); assert_eq!( fs_paths( &make("linux") - .get_sandbox_config("m", &ctx.platform(Platform::Linux)) + .resolve_sandbox_policy("m", &ctx.platform(Platform::Linux)) .unwrap() ), j(r#"{"readonlyPaths":["/Tools"],"readwritePaths":["/tools/work"]}"#) @@ -406,7 +417,7 @@ fn invocation_name_casing_per_platform() { ); let on = |platform: Platform, name: &str| -> Vec { catalog - .get_sandbox_config_with_diagnostics(name, &weak().platform(platform).architecture(Architecture::X64)) + .resolve_sandbox_policy_with_diagnostics(name, &weak().platform(platform).architecture(Architecture::X64)) .unwrap() .diagnostics .tools[0] @@ -449,7 +460,7 @@ fn dependency_chain_composes_each_entry_once_in_order() { linux_x64(), ); let result = catalog - .get_sandbox_config_with_diagnostics("top", &weak().project_root("/r")) + .resolve_sandbox_policy_with_diagnostics("top", &weak().project_root("/r")) .unwrap(); let deps: Vec<&str> = result .diagnostics @@ -487,7 +498,7 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { linux_x64(), ); let result = catalog - .get_sandbox_config_with_diagnostics(vec!["a", "b", "a"], &weak()) + .resolve_sandbox_policy_with_diagnostics(vec!["a", "b", "a"], &weak()) .unwrap(); assert_eq!( result @@ -515,10 +526,10 @@ fn catalog_file_order_does_not_matter() { }; let ctx = weak().project_root("/r"); let forward = catalog_for(revision(vec![e("tool:b", "b"), e("tool:a", "a")]), linux_x64()) - .get_sandbox_config_with_diagnostics("x", &ctx) + .resolve_sandbox_policy_with_diagnostics("x", &ctx) .unwrap(); let backward = catalog_for(revision(vec![e("tool:a", "a"), e("tool:b", "b")]), linux_x64()) - .get_sandbox_config_with_diagnostics("x", &ctx) + .resolve_sandbox_policy_with_diagnostics("x", &ctx) .unwrap(); assert_eq!(forward, backward); assert_eq!(fs_paths(&forward.policy), j(r#"{"readonlyPaths":["/r/a","/r/b"]}"#)); @@ -533,13 +544,13 @@ fn mixed_versions_across_inputs_conflict() { ]), linux_x64(), ); - let error = catalog.get_sandbox_config(vec!["a", "b"], &weak()).unwrap_err(); + let error = catalog.resolve_sandbox_policy(vec!["a", "b"], &weak()).unwrap_err(); assert_eq!( error.message(), "[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)" ); assert_eq!( - catalog.get_sandbox_config("a", &weak()).unwrap().unwrap().to_json(), + catalog.resolve_sandbox_policy("a", &weak()).unwrap().unwrap().to_json(), j(r#"{"version":"0.8.0-alpha"}"#) ); } @@ -572,15 +583,16 @@ fn inspection_over_the_bundled_catalog() { #[test] fn module_level_functions_use_the_bundled_catalog() { let ctx = weak().platform(Platform::Linux).architecture(Architecture::X64); - assert_eq!(get_sandbox_config("definitely-unknown-tool", &ctx).unwrap(), None); - let result = get_sandbox_config_with_diagnostics(vec![ToolInput::from("definitely-unknown-tool")], &ctx).unwrap(); + assert_eq!(resolve_sandbox_policy("definitely-unknown-tool", &ctx).unwrap(), None); + let result = + resolve_sandbox_policy_with_diagnostics(vec![ToolInput::from("definitely-unknown-tool")], &ctx).unwrap(); assert_eq!(result.policy, None); assert_eq!( result.diagnostics.to_json().get("tools").unwrap(), &j(r#"[{"inputIndex":0,"matches":[]}]"#) ); assert_eq!( - reason_of(get_sandbox_config("git", &ctx.catalog_revision("1999-01-01.1"))), + reason_of(resolve_sandbox_policy("git", &ctx.catalog_revision("1999-01-01.1"))), ErrorReason::RevisionUnavailable ); } @@ -873,11 +885,11 @@ fn tampered_revision_is_an_integrity_error_everywhere() { assert_eq!(reason_of(store.revision(None)), ErrorReason::Integrity); let catalog = PolicyCatalog::with_host(store, linux_x64()); assert_eq!( - reason_of(catalog.get_sandbox_config("a", &weak())), + reason_of(catalog.resolve_sandbox_policy("a", &weak())), ErrorReason::Integrity ); assert_eq!( - reason_of(catalog.get_sandbox_config_with_diagnostics("a", &weak())), + reason_of(catalog.resolve_sandbox_policy_with_diagnostics("a", &weak())), ErrorReason::Integrity ); assert_eq!(reason_of(catalog.list_catalog_entries()), ErrorReason::Integrity); @@ -941,7 +953,7 @@ fn explicit_revisions_are_never_substituted() { 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.get_sandbox_config_with_diagnostics("a", ctx).unwrap(); + let r = catalog.resolve_sandbox_policy_with_diagnostics("a", ctx).unwrap(); ( r.diagnostics.catalog_revision.clone(), r.diagnostics.tools[0].matches[0].entry_revision, @@ -953,7 +965,7 @@ fn explicit_revisions_are_never_substituted() { ("2000-01-01.1".into(), 1.0) ); let error = catalog - .get_sandbox_config("a", &ctx.catalog_revision("2000-01-03.1")) + .resolve_sandbox_policy("a", &ctx.catalog_revision("2000-01-03.1")) .unwrap_err(); assert_eq!( error.message(), diff --git a/policy-catalog/scripts/package-smoke.mjs b/policy-catalog/scripts/package-smoke.mjs index 3f201a60a..513e89cd5 100644 --- a/policy-catalog/scripts/package-smoke.mjs +++ b/policy-catalog/scripts/package-smoke.mjs @@ -30,19 +30,19 @@ try { run(['install', '--offline', '--no-audit', '--no-fund', tarball], consumer); const manifest = JSON.parse(readFileSync(join(packageDir, 'catalog', 'manifest.json'), 'utf8')); writeFileSync(join(consumer, 'smoke.mjs'), ` - import { getCatalogInfo, listCatalogEntries, getSandboxConfig, getSandboxConfigWithDiagnostics, PolicyCatalogError } from '@mxc-prototype/policy-catalog'; + import { getCatalogInfo, listCatalogEntries, resolveSandboxPolicy, resolveSandboxPolicyWithDiagnostics, PolicyCatalogError } from '@mxc-prototype/policy-catalog'; import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; assert.equal(getCatalogInfo().catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); assert.ok(listCatalogEntries().length > 0); // Weak identity requires opt-in, so a bare name yields no policy. - assert.equal(getSandboxConfig('git', { platform: 'linux', architecture: 'x64' }), undefined); + assert.equal(resolveSandboxPolicy('git', { platform: 'linux', architecture: 'x64' }), undefined); const ctx = { platform: 'linux', architecture: 'x64', allowWeakIdentityFallback: true, projectRoot: '/p', symbols: { git_prefix: '/usr/bin', node_prefix: '/n', npm_prefix: '/n', npm_cache: '/c' } }; - assert.deepEqual(getSandboxConfig(['git', 'npm'], ctx).filesystem.readwritePaths, ['/p', '/c']); - const r = getSandboxConfigWithDiagnostics('git', ctx); + assert.deepEqual(resolveSandboxPolicy(['git', 'npm'], ctx).filesystem.readwritePaths, ['/p', '/c']); + const r = resolveSandboxPolicyWithDiagnostics('git', ctx); assert.equal(r.diagnostics.tools[0].matches[0].entryId, 'tool:git'); - assert.throws(() => getSandboxConfig('git', { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); + assert.throws(() => resolveSandboxPolicy('git', { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); // The packaged CLI runs from the installed tree. const inspected = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'inspect'], { encoding: 'utf8' })); assert.equal(inspected.info.catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); diff --git a/policy-catalog/scripts/validate-catalog.mjs b/policy-catalog/scripts/validate-catalog.mjs index 5ed90e1dc..928138eb9 100644 --- a/policy-catalog/scripts/validate-catalog.mjs +++ b/policy-catalog/scripts/validate-catalog.mjs @@ -121,8 +121,8 @@ step('deterministic resolution', () => { symbols: Object.fromEntries(Object.keys(symbols).map(s => [s, `${base}${sep}${s}`])), }; const tool = { invocationName: name ?? 'unused', ...(purl ? { packageUrl: purl } : {}) }; - const first = catalog.getSandboxConfigWithDiagnostics(tool, ctx); - const second = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + const first = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); + const second = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); if (first.policy === undefined) { found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} produced no policy: ${first.diagnostics.warnings.join('; ')}`); } else if (JSON.stringify(first) !== JSON.stringify(second)) { @@ -172,7 +172,7 @@ step('order independence', () => { }; const run = (catalogStore, tools) => { try { - return JSON.stringify(new PolicyCatalog(catalogStore).getSandboxConfig(tools, ctx)); + return JSON.stringify(new PolicyCatalog(catalogStore).resolveSandboxPolicy(tools, ctx)); } catch (error) { return `error:${error.reason ?? error.message}`; } diff --git a/policy-catalog/src/cli.ts b/policy-catalog/src/cli.ts index b8bed0bba..e65aa05bd 100644 --- a/policy-catalog/src/cli.ts +++ b/policy-catalog/src/cli.ts @@ -13,7 +13,7 @@ // policy-catalog inspect [--catalog DIR] // policy-catalog validate [--catalog DIR] [--base-ref REF] // -// resolve runs getSandboxConfig / getSandboxConfigWithDiagnostics. +// resolve runs resolveSandboxPolicy / resolveSandboxPolicyWithDiagnostics. // inspect prints getCatalogInfo() and listCatalogEntries() (metadata only). // validate checks integrity, the catalog contract (including dependency // cycles), entry-revision history, and, with --base-ref, that every @@ -176,10 +176,10 @@ function resolveCommand(args: string[], catalogDir: string | undefined): number } const catalog = new PolicyCatalog(storeFrom(catalogDir)); if (diagnostics) { - print(catalog.getSandboxConfigWithDiagnostics(tools, ctx)); + print(catalog.resolveSandboxPolicyWithDiagnostics(tools, ctx)); } else { // `null` is JSON's rendering of "no policy"; it is never an empty policy. - print(catalog.getSandboxConfig(tools, ctx) ?? null); + print(catalog.resolveSandboxPolicy(tools, ctx) ?? null); } return 0; } diff --git a/policy-catalog/src/index.ts b/policy-catalog/src/index.ts index 63149e68f..0c29a886e 100644 --- a/policy-catalog/src/index.ts +++ b/policy-catalog/src/index.ts @@ -5,8 +5,8 @@ // separate entry points. None of them launches a sandbox, contacts a network // service, or writes consumer state. export { - getSandboxConfig, - getSandboxConfigWithDiagnostics, + resolveSandboxPolicy, + resolveSandboxPolicyWithDiagnostics, listCatalogEntries, getCatalogInfo, PolicyCatalog, diff --git a/policy-catalog/src/resolver.ts b/policy-catalog/src/resolver.ts index eb227d921..b8934932c 100644 --- a/policy-catalog/src/resolver.ts +++ b/policy-catalog/src/resolver.ts @@ -127,7 +127,7 @@ export class PolicyCatalog { // ------------------------------------------------------------------------- /** Returns the composed candidate policy, or `undefined` when no policy can be resolved. */ - getSandboxConfig(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): CatalogSandboxPolicy | undefined { + resolveSandboxPolicy(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): CatalogSandboxPolicy | undefined { return this.resolve(tools, ctx).policy; } @@ -144,7 +144,7 @@ export class PolicyCatalog { * The result is a candidate lower bound, not authorization. Consumers keep * authorization, ceilings, persistence, composition, approval, and audit. */ - getSandboxConfigWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): SandboxConfigResolution { + resolveSandboxPolicyWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): SandboxConfigResolution { return this.resolve(tools, ctx); } @@ -464,19 +464,19 @@ function bundled(): PolicyCatalog { } /** Composed candidate policy for one tool, from the bundled catalog. */ -export function getSandboxConfig(tool: ToolInput, ctx?: ResolveContext): CatalogSandboxPolicy | undefined; +export function resolveSandboxPolicy(tool: ToolInput, ctx?: ResolveContext): CatalogSandboxPolicy | undefined; /** Composed candidate policy for several tools, from the bundled catalog. */ -export function getSandboxConfig(tools: readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined; -export function getSandboxConfig(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined { - return bundled().getSandboxConfig(tools, ctx); +export function resolveSandboxPolicy(tools: readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined; +export function resolveSandboxPolicy(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined { + return bundled().resolveSandboxPolicy(tools, ctx); } -/** Composed candidate policy plus attribution for one tool. See {@link PolicyCatalog.getSandboxConfigWithDiagnostics}. */ -export function getSandboxConfigWithDiagnostics(tool: ToolInput, ctx?: ResolveContext): SandboxConfigResolution; +/** Composed candidate policy plus attribution for one tool. See {@link PolicyCatalog.resolveSandboxPolicyWithDiagnostics}. */ +export function resolveSandboxPolicyWithDiagnostics(tool: ToolInput, ctx?: ResolveContext): SandboxConfigResolution; /** Composed candidate policy plus attribution for several tools. */ -export function getSandboxConfigWithDiagnostics(tools: readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution; -export function getSandboxConfigWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution { - return bundled().getSandboxConfigWithDiagnostics(tools, ctx); +export function resolveSandboxPolicyWithDiagnostics(tools: readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution; +export function resolveSandboxPolicyWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution { + return bundled().resolveSandboxPolicyWithDiagnostics(tools, ctx); } /** Lists bundled catalog entry metadata. See {@link PolicyCatalog.listCatalogEntries}. */ diff --git a/policy-catalog/src/types.ts b/policy-catalog/src/types.ts index d7fa09049..2852cddde 100644 --- a/policy-catalog/src/types.ts +++ b/policy-catalog/src/types.ts @@ -69,7 +69,7 @@ export interface ResolveContext { /** Strength of a matched identity predicate. */ export type IdentityStrength = 'strong' | 'weak'; -/** Result of `getSandboxConfigWithDiagnostics` (design §5.1). */ +/** Result of `resolveSandboxPolicyWithDiagnostics` (design §5.1). */ export interface SandboxConfigResolution { policy: CatalogSandboxPolicy | undefined; diagnostics: { diff --git a/policy-catalog/tests/functional/resolution.test.ts b/policy-catalog/tests/functional/resolution.test.ts index bc41b151e..26eb0e63a 100644 --- a/policy-catalog/tests/functional/resolution.test.ts +++ b/policy-catalog/tests/functional/resolution.test.ts @@ -117,11 +117,11 @@ describe('weak-identity opt-in', () => { it('string shorthand and object input obey the same option in the installed library', () => { const catalog = new lib.PolicyCatalog(lib.bundledCatalogStore()); const ctx = { platform: 'linux' as const, architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/usr/bin' } }; - assert.equal(catalog.getSandboxConfig('git', ctx), undefined); - assert.equal(catalog.getSandboxConfig({ invocationName: 'git' }, ctx), undefined); + assert.equal(catalog.resolveSandboxPolicy('git', ctx), undefined); + assert.equal(catalog.resolveSandboxPolicy({ invocationName: 'git' }, ctx), undefined); const on = { ...ctx, allowWeakIdentityFallback: true }; - assert.deepEqual(catalog.getSandboxConfig('git', on), catalog.getSandboxConfig({ invocationName: 'git' }, on)); - assert.notEqual(catalog.getSandboxConfig('git', on), undefined); + assert.deepEqual(catalog.resolveSandboxPolicy('git', on), catalog.resolveSandboxPolicy({ invocationName: 'git' }, on)); + assert.notEqual(catalog.resolveSandboxPolicy('git', on), undefined); }); }); @@ -197,7 +197,7 @@ describe('catalog revisions', () => { assert.equal(result.json.error.details.reason, 'revision_unavailable'); assert.match(result.json.error.message, /'1999-01-01\.1' is not installed/); assert.throws( - () => lib.getSandboxConfig('git', { catalogRevision: '1999-01-01.1', platform: 'linux', architecture: 'x64' }), + () => lib.resolveSandboxPolicy('git', { catalogRevision: '1999-01-01.1', platform: 'linux', architecture: 'x64' }), (error: any) => error instanceof lib.PolicyCatalogError && error.reason === 'revision_unavailable', ); }); @@ -239,12 +239,12 @@ describe('platform and architecture selection', () => { const catalog = new lib.PolicyCatalog(lib.bundledCatalogStore(), host); const c = PLATFORM_CONTEXT[platform]; const ctx = { allowWeakIdentityFallback: true, projectRoot: c.root, symbols: { git_prefix: c.prefix } }; - const omitted = catalog.getSandboxConfigWithDiagnostics('git', ctx); + const omitted = catalog.resolveSandboxPolicyWithDiagnostics('git', ctx); const warnings = omitted.diagnostics.warnings.join('\n'); assert.match(warnings, NOT_VERIFIED); assert.match(warnings, new RegExp(`native system architecture '${architecture}'`)); assert.match(warnings, new RegExp(`no ${architecture}-specific variant exists`)); - const explicit = catalog.getSandboxConfigWithDiagnostics('git', { ...ctx, architecture, platform }); + const explicit = catalog.resolveSandboxPolicyWithDiagnostics('git', { ...ctx, architecture, platform }); assert.doesNotMatch(explicit.diagnostics.warnings.join('\n'), NOT_VERIFIED); assert.deepEqual(explicit.policy, omitted.policy); }); diff --git a/policy-catalog/tests/unit/conformance.test.ts b/policy-catalog/tests/unit/conformance.test.ts index 091414e4a..767c3153d 100644 --- a/policy-catalog/tests/unit/conformance.test.ts +++ b/policy-catalog/tests/unit/conformance.test.ts @@ -32,17 +32,17 @@ for (const name of fixtureFiles()) { const host = fixedHost(testCase.host?.platform ?? 'linux', testCase.host?.nativeArchitecture ?? 'x64'); const catalog = fixture.catalog === 'bundled' ? bundledCatalog(host) : catalogFor(fixture.catalog, host); if (testCase.expectError !== undefined) { - assert.deepEqual(failure(() => catalog.getSandboxConfigWithDiagnostics(testCase.tools, testCase.context)), testCase.expectError); - assert.deepEqual(failure(() => catalog.getSandboxConfig(testCase.tools, testCase.context)), testCase.expectError); + assert.deepEqual(failure(() => catalog.resolveSandboxPolicyWithDiagnostics(testCase.tools, testCase.context)), testCase.expectError); + assert.deepEqual(failure(() => catalog.resolveSandboxPolicy(testCase.tools, testCase.context)), testCase.expectError); return; } const expected = toExpected(testCase.expect); - assert.deepEqual(catalog.getSandboxConfigWithDiagnostics(testCase.tools, testCase.context), expected); + assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics(testCase.tools, testCase.context), expected); // The policy-only operation returns the same policy from the same logic. - assert.deepEqual(catalog.getSandboxConfig(testCase.tools, testCase.context), expected.policy); + assert.deepEqual(catalog.resolveSandboxPolicy(testCase.tools, testCase.context), expected.policy); // A single input is exactly a one-element array. if (!Array.isArray(testCase.tools)) { - assert.deepEqual(catalog.getSandboxConfigWithDiagnostics([testCase.tools], testCase.context), expected); + assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics([testCase.tools], testCase.context), expected); } }); } diff --git a/policy-catalog/tests/unit/resolver.test.ts b/policy-catalog/tests/unit/resolver.test.ts index 4dc9e342b..b6fd79d85 100644 --- a/policy-catalog/tests/unit/resolver.test.ts +++ b/policy-catalog/tests/unit/resolver.test.ts @@ -6,8 +6,8 @@ import assert from 'node:assert/strict'; import { PolicyCatalog, getCatalogInfo, - getSandboxConfig, - getSandboxConfigWithDiagnostics, + resolveSandboxPolicy, + resolveSandboxPolicyWithDiagnostics, listCatalogEntries, } from '@mxc-prototype/policy-catalog'; import { architectureFromMachine } from '@mxc-prototype/policy-catalog/tooling'; @@ -29,15 +29,15 @@ describe('runtime lookup: defaults (design §1)', () => { it('omitted context: host platform, native architecture, installed revision, no weak fallback', () => { const catalog = archCatalog(); - assert.equal(catalog.getSandboxConfig('a'), undefined, 'weak identity requires opt-in'); - const result = catalog.getSandboxConfigWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }); + assert.equal(catalog.resolveSandboxPolicy('a'), undefined, 'weak identity requires opt-in'); + const result = catalog.resolveSandboxPolicyWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }); assert.equal(result.policy?.timeoutMs, 2, 'native arm64 selected'); assert.equal(result.diagnostics.catalogRevision, '2000-01-01.1'); assert.match(result.diagnostics.warnings.join('\n'), /native system architecture 'arm64'; the tool's architecture was not verified/); }); it('explicit architecture takes precedence and suppresses the host-default warning', () => { - const result = archCatalog().getSandboxConfigWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }, { architecture: 'x64' }); + const result = archCatalog().resolveSandboxPolicyWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }, { architecture: 'x64' }); assert.equal(result.policy?.timeoutMs, 1); assert.deepEqual(result.diagnostics.warnings, []); }); @@ -46,7 +46,7 @@ describe('runtime lookup: defaults (design §1)', () => { const catalog = catalogFor(revisionWith([ entry('tool:a', { platformVariants: [{ when: { platform: 'linux', architecture: 'arm64' }, sandboxPolicy: { version: v } }] }), ])); - const result = catalog.getSandboxConfigWithDiagnostics('a', { ...weak, architecture: 'x64' }); + const result = catalog.resolveSandboxPolicyWithDiagnostics('a', { ...weak, architecture: 'x64' }); assert.equal(result.policy, undefined); assert.match(result.diagnostics.warnings[0], /tool:a has no variant for linux\/x64/); }); @@ -54,11 +54,11 @@ describe('runtime lookup: defaults (design §1)', () => { it('host architecture detection failure is a library error, not a guessed selection', () => { const host = { ...fixedHost(), nativeArchitecture: (): never => { throw new Error('unknown machine'); } }; const catalog = new PolicyCatalog(storeFor([revisionWith([entry('tool:t')])]), host); - assert.throws(() => catalog.getSandboxConfig('t', { ...weak, projectRoot: '/p' }), /unknown machine/); + assert.throws(() => catalog.resolveSandboxPolicy('t', { ...weak, projectRoot: '/p' }), /unknown machine/); // Detection is not needed when nothing matches, so it is not attempted. - assert.equal(catalog.getSandboxConfig('nothing', weak), undefined); + assert.equal(catalog.resolveSandboxPolicy('nothing', weak), undefined); // An explicit architecture never consults the host. - assert.deepEqual(catalog.getSandboxConfig('t', { ...weak, architecture: 'x64', projectRoot: '/p' })?.filesystem, { readwritePaths: ['/p'] }); + assert.deepEqual(catalog.resolveSandboxPolicy('t', { ...weak, architecture: 'x64', projectRoot: '/p' })?.filesystem, { readwritePaths: ['/p'] }); }); it('maps OS machine names to selectors and rejects unknown ones', () => { @@ -71,7 +71,7 @@ describe('runtime lookup: defaults (design §1)', () => { }); it('never fabricates projectRoot or caller symbols', () => { - const result = bundledCatalog().getSandboxConfigWithDiagnostics('git', { ...weak, architecture: 'x64' }); + const result = bundledCatalog().resolveSandboxPolicyWithDiagnostics('git', { ...weak, architecture: 'x64' }); assert.equal(result.policy, undefined); assert.match(result.diagnostics.warnings.join('\n'), /required symbol 'git_prefix'.*required symbol 'project_root'/s); }); @@ -86,9 +86,9 @@ describe('runtime lookup: defaults (design §1)', () => { }), ]); const catalog = catalogFor(revision, fixedHost('linux', 'x64', { user_home: '/home/me' })); - assert.deepEqual(catalog.getSandboxConfig('t', weak)?.filesystem, { readonlyPaths: ['/home/me/.cfg'] }); - assert.equal(catalog.getSandboxConfig('t', { ...weak, platform: 'macos', architecture: 'arm64' }), undefined); - assert.deepEqual(catalog.getSandboxConfig('t', { ...weak, symbols: { user_home: '/srv/u' } })?.filesystem, { readonlyPaths: ['/srv/u/.cfg'] }); + assert.deepEqual(catalog.resolveSandboxPolicy('t', weak)?.filesystem, { readonlyPaths: ['/home/me/.cfg'] }); + assert.equal(catalog.resolveSandboxPolicy('t', { ...weak, platform: 'macos', architecture: 'arm64' }), undefined); + assert.deepEqual(catalog.resolveSandboxPolicy('t', { ...weak, symbols: { user_home: '/srv/u' } })?.filesystem, { readonlyPaths: ['/srv/u/.cfg'] }); }); }); @@ -96,17 +96,17 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { it('string shorthand, object input, and a one-element array are equivalent', () => { const catalog = bundledCatalog(); const ctx = { ...weak, architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/g' } }; - const a = catalog.getSandboxConfigWithDiagnostics('git', ctx); - assert.deepEqual(catalog.getSandboxConfigWithDiagnostics({ invocationName: 'git' }, ctx), a); - assert.deepEqual(catalog.getSandboxConfigWithDiagnostics(['git'], ctx), a); + const a = catalog.resolveSandboxPolicyWithDiagnostics('git', ctx); + assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics({ invocationName: 'git' }, ctx), a); + assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics(['git'], ctx), a); assert.equal(a.diagnostics.tools[0].inputIndex, 0); }); it('string shorthand obeys the weak-identity option exactly like an object input', () => { const catalog = bundledCatalog(); const ctx = { architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/g' } }; - assert.equal(catalog.getSandboxConfig('git', ctx), undefined); - assert.equal(catalog.getSandboxConfig({ invocationName: 'git' }, ctx), undefined); + assert.equal(catalog.resolveSandboxPolicy('git', ctx), undefined); + assert.equal(catalog.resolveSandboxPolicy({ invocationName: 'git' }, ctx), undefined); }); it('rejects invalid context and inputs as library failures, not absence', () => { @@ -124,7 +124,7 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { ['git', { projectRoot: '' }], ]; for (const [tools, ctx] of cases) { - assert.equal(errorReason(() => catalog.getSandboxConfig(tools as any, ctx as any)), 'invalid_context', JSON.stringify([tools, ctx])); + assert.equal(errorReason(() => catalog.resolveSandboxPolicy(tools as any, ctx as any)), 'invalid_context', JSON.stringify([tools, ctx])); } }); @@ -132,11 +132,11 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { const catalog = bundledCatalog(); const ctx = { architecture: 'x64' as const, projectRoot: '/p', symbols: { npm_prefix: '/n', npm_cache: '/c', node_prefix: '/n' } }; const tool = { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }; - const first = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + const first = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); first.policy!.filesystem!.readwritePaths!.push('/mutated'); first.diagnostics.warnings.push('mutated'); first.diagnostics.tools[0].matches.length = 0; - const second = catalog.getSandboxConfigWithDiagnostics(tool, ctx); + const second = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); assert.deepEqual(second.policy?.filesystem, { readonlyPaths: ['/n'], readwritePaths: ['/p', '/c'] }); assert.equal(second.diagnostics.tools[0].matches.length, 1); assert.ok(!second.diagnostics.warnings.includes('mutated')); @@ -148,13 +148,13 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { platformVariants: [{ when: { platform: 'windows' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}\\'] } } }], }), ])); - const policy = catalog.getSandboxConfig('w', { platform: 'windows', architecture: 'x64', ...weak, symbols: { git_prefix: 'C:\\Tools', node_prefix: 'c:\\tools' } }); + const policy = catalog.resolveSandboxPolicy('w', { platform: 'windows', architecture: 'x64', ...weak, symbols: { git_prefix: 'C:\\Tools', node_prefix: 'c:\\tools' } }); assert.deepEqual(policy?.filesystem, { readonlyPaths: ['C:\\Tools'] }); // Linux paths are case-sensitive, so the same values stay distinct there. const linux = catalogFor(revisionWith([ entry('tool:l', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}/'] } } }] }), ])); - assert.deepEqual(linux.getSandboxConfig('l', { ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } })?.filesystem, { readonlyPaths: ['/Tools', '/tools'] }); + assert.deepEqual(linux.resolveSandboxPolicy('l', { ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } })?.filesystem, { readonlyPaths: ['/Tools', '/tools'] }); }); it('macOS folds path case like Windows (one casing rule per OS)', () => { @@ -162,17 +162,17 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:m', { platformVariants: [{ when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}/'] } } }] }), ])); const ctx = { platform: 'macos', architecture: 'arm64', ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } } as const; - assert.deepEqual(mac.getSandboxConfig('m', ctx)?.filesystem, { readonlyPaths: ['/Tools'] }); + assert.deepEqual(mac.resolveSandboxPolicy('m', ctx)?.filesystem, { readonlyPaths: ['/Tools'] }); // Overlap across access classes is also detected case-insensitively on macOS. const overlap = catalogFor(revisionWith([ entry('tool:m', { platformVariants: [{ when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}'], readwritePaths: ['${project_root}'] } } }] }), ])); - assert.equal(errorReason(() => overlap.getSandboxConfig('m', { ...ctx, projectRoot: '/tools/work' })), 'composition_conflict'); + assert.equal(errorReason(() => overlap.resolveSandboxPolicy('m', { ...ctx, projectRoot: '/tools/work' })), 'composition_conflict'); // The same values on Linux are distinct paths, so there is no overlap. const linux = catalogFor(revisionWith([ entry('tool:m', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}'], readwritePaths: ['${project_root}'] } } }] }), ])); - assert.deepEqual(linux.getSandboxConfig('m', { ...weak, platform: 'linux', architecture: 'x64', projectRoot: '/tools/work', symbols: { git_prefix: '/Tools' } })?.filesystem, + assert.deepEqual(linux.resolveSandboxPolicy('m', { ...weak, platform: 'linux', architecture: 'x64', projectRoot: '/tools/work', symbols: { git_prefix: '/Tools' } })?.filesystem, { readonlyPaths: ['/Tools'], readwritePaths: ['/tools/work'] }); }); @@ -181,7 +181,7 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:gh', { platformVariants: ['linux', 'macos', 'windows'].map(platform => ({ when: { platform }, sandboxPolicy: { version: v } })) }), ])); const on = (platform: 'linux' | 'macos' | 'windows', name: string) => - catalog.getSandboxConfigWithDiagnostics(name, { ...weak, platform, architecture: 'x64' }).diagnostics.tools[0].matches.map(m => m.entryId); + catalog.resolveSandboxPolicyWithDiagnostics(name, { ...weak, platform, architecture: 'x64' }).diagnostics.tools[0].matches.map(m => m.entryId); assert.deepEqual(on('linux', 'gh'), ['tool:gh']); assert.deepEqual(on('linux', 'GH'), []); assert.deepEqual(on('macos', 'GH'), ['tool:gh']); @@ -200,7 +200,7 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:right', { platformVariants: [variant(['tool:leaf'], 'right')] }), entry('tool:leaf', { platformVariants: [variant([], 'leaf')] }), ])); - const result = catalog.getSandboxConfigWithDiagnostics('top', { ...weak, projectRoot: '/r' }); + const result = catalog.resolveSandboxPolicyWithDiagnostics('top', { ...weak, projectRoot: '/r' }); assert.deepEqual(result.diagnostics.resolvedDependencies.map(d => d.entryId), ['tool:leaf', 'tool:left', 'tool:right']); assert.deepEqual(result.policy?.filesystem?.readonlyPaths, ['/r/top', '/r/left', '/r/leaf', '/r/right']); }); @@ -211,7 +211,7 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:c' }], sandboxPolicy: { version: v } }] }), entry('tool:c', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), ])); - const result = catalog.getSandboxConfigWithDiagnostics(['a', 'b', 'a'], weak); + const result = catalog.resolveSandboxPolicyWithDiagnostics(['a', 'b', 'a'], weak); assert.deepEqual(result.diagnostics.resolvedDependencies, [ { entryId: 'tool:c', entryRevision: 1 }, { entryId: 'tool:c', entryRevision: 1, requiredVersionRange: '>=2' }, @@ -224,8 +224,8 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['x'] }], platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${project_root}/b'] } } }] }), entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['x'] }], platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${project_root}/a'] } } }] }), ]; - const forward = catalogFor(revisionWith(entries)).getSandboxConfigWithDiagnostics('x', { ...weak, projectRoot: '/r' }); - const backward = catalogFor(revisionWith([...entries].reverse())).getSandboxConfigWithDiagnostics('x', { ...weak, projectRoot: '/r' }); + const forward = catalogFor(revisionWith(entries)).resolveSandboxPolicyWithDiagnostics('x', { ...weak, projectRoot: '/r' }); + const backward = catalogFor(revisionWith([...entries].reverse())).resolveSandboxPolicyWithDiagnostics('x', { ...weak, projectRoot: '/r' }); assert.deepEqual(forward, backward); assert.deepEqual(forward.diagnostics.tools[0].matches.map(m => m.entryId), ['tool:a', 'tool:b']); assert.deepEqual(forward.policy?.filesystem?.readonlyPaths, ['/r/a', '/r/b']); @@ -236,9 +236,9 @@ describe('runtime lookup: inputs and failures (design §5.1)', () => { entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.8.0-alpha' } }] }), entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), ])); - assert.equal(errorReason(() => catalog.getSandboxConfig(['a', 'b'], weak)), 'composition_conflict'); + assert.equal(errorReason(() => catalog.resolveSandboxPolicy(['a', 'b'], weak)), 'composition_conflict'); // Each tool on its own is fine. - assert.deepEqual(catalog.getSandboxConfig('a', weak), { version: '0.8.0-alpha' }); + assert.deepEqual(catalog.resolveSandboxPolicy('a', weak), { version: '0.8.0-alpha' }); }); }); @@ -260,8 +260,8 @@ describe('setup and inspection (design §5.2)', () => { it('module-level functions use the bundled catalog', () => { const ctx = { ...weak, platform: 'linux' as const, architecture: 'x64' as const }; - assert.equal(getSandboxConfig('definitely-unknown-tool', ctx), undefined); - const result = getSandboxConfigWithDiagnostics(['definitely-unknown-tool'], ctx); + assert.equal(resolveSandboxPolicy('definitely-unknown-tool', ctx), undefined); + const result = resolveSandboxPolicyWithDiagnostics(['definitely-unknown-tool'], ctx); assert.equal(result.policy, undefined); assert.deepEqual(result.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); }); diff --git a/policy-catalog/tests/unit/store.test.ts b/policy-catalog/tests/unit/store.test.ts index 669b1d8ab..368388872 100644 --- a/policy-catalog/tests/unit/store.test.ts +++ b/policy-catalog/tests/unit/store.test.ts @@ -35,8 +35,8 @@ describe('integrity', () => { const store = storeFor([revision], { digests: { '2000-01-01.1': '0'.repeat(64) } }); assert.equal(errorReason(() => store.revision()), 'integrity'); const catalog = new PolicyCatalog(store, fixedHost()); - assert.equal(errorReason(() => catalog.getSandboxConfig('a', { allowWeakIdentityFallback: true })), 'integrity'); - assert.equal(errorReason(() => catalog.getSandboxConfigWithDiagnostics('a', { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorReason(() => catalog.resolveSandboxPolicy('a', { allowWeakIdentityFallback: true })), 'integrity'); + assert.equal(errorReason(() => catalog.resolveSandboxPolicyWithDiagnostics('a', { allowWeakIdentityFallback: true })), 'integrity'); assert.equal(errorReason(() => catalog.listCatalogEntries()), 'integrity'); assert.equal(errorReason(() => catalog.getCatalogInfo()), 'integrity'); }); @@ -64,12 +64,12 @@ describe('versioning and immutable revisions', () => { const catalog = new PolicyCatalog(storeFor([r1, r2]), fixedHost()); const ctx = { allowWeakIdentityFallback: true, projectRoot: '/p' }; const revisionOf = (extra = {}) => { - const result = catalog.getSandboxConfigWithDiagnostics('a', { ...ctx, ...extra }); + const result = catalog.resolveSandboxPolicyWithDiagnostics('a', { ...ctx, ...extra }); return [result.diagnostics.catalogRevision, result.diagnostics.tools[0].matches[0]?.entryRevision]; }; assert.deepEqual(revisionOf(), ['2000-01-02.1', 2]); assert.deepEqual(revisionOf({ catalogRevision: '2000-01-01.1' }), ['2000-01-01.1', 1]); - assert.equal(errorReason(() => catalog.getSandboxConfig('a', { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision_unavailable'); + assert.equal(errorReason(() => catalog.resolveSandboxPolicy('a', { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision_unavailable'); }); it('loaded revisions are deeply frozen (read-only)', () => { From 416926f0f4867f66809a7c87ec8d18a2bb81f29d Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 16:56:41 -0700 Subject: [PATCH 19/50] refactor(policy-store): move the Rust resolver into src/core/mxc_policy_store PROTOTYPE, pending API review. Move the Rust policy-catalog library and its V1 catalog, schemas, and conformance data into the MXC workspace as the mxc_policy_store crate, the single implementation every MXC SDK will use. - catalog/ in the crate is the single source of truth; build.rs embeds it. - The standalone CLI module and binary are dropped. - Uses the workspace sha2 and libc, and workspace rustfmt. - tests/catalog_validation.rs replaces the npm validate pipeline: integrity, contract, history, optional base-ref immutability (MXC_POLICY_STORE_BASE_REF), manifest listing, deterministic self-resolution, and conformance coverage. No resolver behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- policy-catalog/rust/README.md | 52 --- policy-catalog/rust/src/bin/policy-catalog.rs | 22 -- policy-catalog/rust/src/cli.rs | 230 ------------- policy-catalog/rust/src/lib.rs | 71 ---- src/Cargo.lock | 8 + src/Cargo.toml | 2 + src/core/mxc_policy_store/Cargo.toml | 19 ++ src/core/mxc_policy_store/README.md | 57 ++++ .../core/mxc_policy_store}/build.rs | 45 ++- .../catalog/contract.v1.json | 0 .../mxc_policy_store}/catalog/manifest.json | 0 .../catalog/revisions/2026-09-29.1.json | 0 .../conformance/fixtures/bundled-catalog.json | 0 .../fixtures/synthetic-catalog.json | 0 .../conformance/vectors/canonical-json.json | 0 .../conformance/vectors/paths.json | 0 .../schema/catalog.v1.schema.json | 0 .../schema/manifest.v1.schema.json | 0 .../core/mxc_policy_store}/src/catalog.rs | 247 +++++++++++--- .../core/mxc_policy_store}/src/errors.rs | 4 +- .../core/mxc_policy_store}/src/history.rs | 19 +- .../core/mxc_policy_store}/src/host.rs | 25 +- .../core/mxc_policy_store}/src/json.rs | 11 +- src/core/mxc_policy_store/src/lib.rs | 83 +++++ .../core/mxc_policy_store}/src/model.rs | 28 +- .../core/mxc_policy_store}/src/paths.rs | 46 ++- .../core/mxc_policy_store}/src/purl.rs | 16 +- .../core/mxc_policy_store}/src/resolver.rs | 114 ++++--- .../core/mxc_policy_store}/src/store.rs | 28 +- .../core/mxc_policy_store}/src/text.rs | 18 +- .../core/mxc_policy_store}/src/validate.rs | 35 +- .../mxc_policy_store}/src/version_range.rs | 12 +- .../tests/catalog_validation.rs | 197 +++++++++++ .../mxc_policy_store}/tests/common/mod.rs | 55 ++- .../mxc_policy_store}/tests/conformance.rs | 48 ++- .../core/mxc_policy_store}/tests/library.rs | 320 ++++++++++++++---- .../core/mxc_policy_store}/tests/vectors.rs | 33 +- 37 files changed, 1179 insertions(+), 666 deletions(-) delete mode 100644 policy-catalog/rust/README.md delete mode 100644 policy-catalog/rust/src/bin/policy-catalog.rs delete mode 100644 policy-catalog/rust/src/cli.rs delete mode 100644 policy-catalog/rust/src/lib.rs create mode 100644 src/core/mxc_policy_store/Cargo.toml create mode 100644 src/core/mxc_policy_store/README.md rename {policy-catalog/rust => src/core/mxc_policy_store}/build.rs (60%) rename {policy-catalog => src/core/mxc_policy_store}/catalog/contract.v1.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/catalog/manifest.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/catalog/revisions/2026-09-29.1.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/conformance/fixtures/bundled-catalog.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/conformance/fixtures/synthetic-catalog.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/conformance/vectors/canonical-json.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/conformance/vectors/paths.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/schema/catalog.v1.schema.json (100%) rename {policy-catalog => src/core/mxc_policy_store}/schema/manifest.v1.schema.json (100%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/catalog.rs (84%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/errors.rs (98%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/history.rs (92%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/host.rs (94%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/json.rs (98%) create mode 100644 src/core/mxc_policy_store/src/lib.rs rename {policy-catalog/rust => src/core/mxc_policy_store}/src/model.rs (95%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/paths.rs (91%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/purl.rs (89%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/resolver.rs (87%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/store.rs (95%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/text.rs (86%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/validate.rs (91%) rename {policy-catalog/rust => src/core/mxc_policy_store}/src/version_range.rs (94%) create mode 100644 src/core/mxc_policy_store/tests/catalog_validation.rs rename {policy-catalog/rust => src/core/mxc_policy_store}/tests/common/mod.rs (73%) rename {policy-catalog/rust => src/core/mxc_policy_store}/tests/conformance.rs (72%) rename {policy-catalog/rust => src/core/mxc_policy_store}/tests/library.rs (80%) rename {policy-catalog/rust => src/core/mxc_policy_store}/tests/vectors.rs (81%) diff --git a/policy-catalog/rust/README.md b/policy-catalog/rust/README.md deleted file mode 100644 index 143cf6a6d..000000000 --- a/policy-catalog/rust/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# mxc-policy-catalog (Rust) — PROTOTYPE - -A native Rust implementation of the policy catalog library. It behaves the same -as the TypeScript library in `../src`. It is not a wrapper, and it needs no Node -at runtime. The catalog is compiled into the crate. - -- Library: `mxc_policy_catalog` provides `resolve_sandbox_policy`, `resolve_sandbox_policy_with_diagnostics`, - `get_catalog_info`, `list_catalog_entries`, and `PolicyCatalog` (a store plus an injectable - `HostEnvironment`). Contribution and CI rules are in `mxc_policy_catalog::tooling`. -- Binary: `policy-catalog resolve | inspect | validate`. Its output matches `node dist/cli.js`. - -Run every command below from this `rust/` directory. The toolchain is pinned in -`rust-toolchain.toml` (1.93, with clippy and rustfmt). - -## Build and test - -```sh -cargo build -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test # unit + ../conformance fixtures + ../conformance/vectors -``` - -## Bundled catalog - -The source of truth is `../catalog`. `build.rs` embeds it with `include_str!` -whenever it is present. Inside a packaged `.crate` (detected by -`Cargo.toml.orig`), `build.rs` embeds the crate-local `catalog/` instead. -`catalog/` is a git-ignored copy that `scripts/sync-catalog.mjs` writes. It is -listed in `Cargo.toml` `include`, so it ships in the `.crate`. A test checks -that the embedded data equals `../catalog` (canonical JSON). - -## Package - -```sh -node scripts/sync-catalog.mjs -cargo package # builds and verifies the packaged crate in isolation -``` - -## Functional tests (packaged crate) - -```sh -node ../scripts/rust-functional.mjs -``` - -The driver syncs the catalog and runs `cargo package`. It then extracts the -`.crate` into a temporary directory outside the repository and builds the -packaged `policy-catalog` binary from the extracted crate. Next, it creates a -consumer crate from `functional/` (`Cargo.toml.in`, `src/`, `tests/`) that -depends on the extracted crate by path. Finally, it runs the consumer's tests, -which use both the library API and the packaged CLI. Every build uses a -`CARGO_TARGET_DIR` inside the temporary directory. diff --git a/policy-catalog/rust/src/bin/policy-catalog.rs b/policy-catalog/rust/src/bin/policy-catalog.rs deleted file mode 100644 index 4fbfc8ee0..000000000 --- a/policy-catalog/rust/src/bin/policy-catalog.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! `policy-catalog` command-line harness (PROTOTYPE). See `mxc_policy_catalog::cli`. - -use std::io::Write; -use std::process::ExitCode; - -fn main() -> ExitCode { - let argv: Vec = std::env::args_os() - .skip(1) - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(); - let output = mxc_policy_catalog::cli::run(&argv); - let mut stdout = std::io::stdout().lock(); - let _ = stdout.write_all(output.stdout.as_bytes()); - let _ = stdout.flush(); - let mut stderr = std::io::stderr().lock(); - let _ = stderr.write_all(output.stderr.as_bytes()); - let _ = stderr.flush(); - ExitCode::from(output.exit_code as u8) -} diff --git a/policy-catalog/rust/src/cli.rs b/policy-catalog/rust/src/cli.rs deleted file mode 100644 index fcc7d4d72..000000000 --- a/policy-catalog/rust/src/cli.rs +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! `policy-catalog resolve | inspect | validate`, output-equivalent to the -//! TypeScript CLI (`src/cli.ts`). -//! -//! ```text -//! policy-catalog resolve [--catalog DIR] [--diagnostics] [--platform P] -//! [--architecture A] [--revision R] [--project-root PATH] -//! [--symbol name=value]... [--allow-weak] -//! [--purl URL] [--detected-version V] ... -//! policy-catalog inspect [--catalog DIR] -//! policy-catalog validate [--catalog DIR] [--base-ref REF] -//! ``` -//! -//! Exit codes: 0 success (including "no policy"), 1 library failure or -//! failed validation, 2 usage error. Output is JSON on stdout. - -use crate::errors::PolicyCatalogError; -use crate::json::{Json, JsonObject}; -use crate::model::{ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs}; -use crate::resolver::PolicyCatalog; -use crate::store::{bundled_catalog_store, load_catalog_directory, CatalogStore}; -use crate::validate::{resolve_path, validate_bundled_catalog, validate_catalog_directory}; -use std::sync::Arc; - -pub const USAGE: &str = "usage: policy-catalog [options]"; - -enum Failure { - Usage(String), - Library(PolicyCatalogError), -} - -impl From for Failure { - fn from(error: PolicyCatalogError) -> Self { - Failure::Library(error) - } -} - -/// Captured process output of one CLI invocation. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CliOutput { - pub exit_code: i32, - pub stdout: String, - pub stderr: String, -} - -fn take_value(args: &[String], index: usize, flag: &str) -> Result { - match args.get(index + 1) { - Some(value) if !value.starts_with("--") => Ok(value.clone()), - _ => Err(Failure::Usage(format!("{flag} requires a value"))), - } -} - -fn store_from(catalog_dir: &Option) -> Result, PolicyCatalogError> { - match catalog_dir { - None => bundled_catalog_store(), - Some(dir) => Ok(Arc::new(load_catalog_directory(resolve_path(dir))?)), - } -} - -fn print(value: &Json, out: &mut String) { - out.push_str(&value.to_pretty_string()); - out.push('\n'); -} - -fn reject_rest(args: &[String], command: &str) -> Result<(), Failure> { - match args.first() { - Some(first) => Err(Failure::Usage(format!("{command}: unexpected argument '{first}'"))), - None => Ok(()), - } -} - -/// Runs the CLI over `argv` (without the program name) and returns its output. -pub fn run(argv: &[String]) -> CliOutput { - let mut output = CliOutput::default(); - match main_inner(argv, &mut output.stdout) { - Ok(code) => output.exit_code = code, - Err(Failure::Usage(message)) => { - output.stdout.clear(); - output.stderr = format!("policy-catalog: {message}\n{USAGE}\n"); - output.exit_code = 2; - } - Err(Failure::Library(error)) => { - let mut details = JsonObject::new(); - details.insert("reason", error.reason().as_str().into()); - let mut body = JsonObject::new(); - body.insert("code", error.code().as_str().into()); - body.insert("message", error.message().into()); - body.insert("details", Json::Object(details)); - let mut root = JsonObject::new(); - root.insert("error", Json::Object(body)); - output.stdout.clear(); - print(&Json::Object(root), &mut output.stdout); - output.exit_code = 1; - } - } - output -} - -fn main_inner(argv: &[String], out: &mut String) -> Result { - let command = argv.first().map(String::as_str); - let rest = argv.get(1..).unwrap_or_default(); - let mut catalog_dir: Option = None; - let mut base_ref: Option = None; - let mut args: Vec = Vec::new(); - let mut i = 0; - while i < rest.len() { - if rest[i] == "--catalog" { - catalog_dir = Some(take_value(rest, i, "--catalog")?); - i += 1; - } else if rest[i] == "--base-ref" && command == Some("validate") { - base_ref = Some(take_value(rest, i, "--base-ref")?); - i += 1; - } else { - args.push(rest[i].clone()); - } - i += 1; - } - match command { - Some("inspect") => { - reject_rest(&args, "inspect")?; - let catalog = PolicyCatalog::new(store_from(&catalog_dir)?); - let info = catalog.get_catalog_info()?; - let entries = catalog.list_catalog_entries()?; - let mut root = JsonObject::new(); - root.insert("info", info.to_json()); - root.insert("entries", Json::Array(entries.iter().map(|e| e.to_json()).collect())); - print(&Json::Object(root), out); - Ok(0) - } - Some("validate") => { - reject_rest(&args, "validate")?; - let report = match &catalog_dir { - Some(dir) => validate_catalog_directory(dir, base_ref.as_deref()), - None => validate_bundled_catalog(base_ref.as_deref()), - }; - print(&report.to_json(), out); - Ok(if report.ok { 0 } else { 1 }) - } - Some("resolve") => resolve_command(&args, &catalog_dir, out), - None => Err(Failure::Usage("missing command".to_string())), - Some(other) => Err(Failure::Usage(format!("unknown command '{other}'"))), - } -} - -fn resolve_command(args: &[String], catalog_dir: &Option, out: &mut String) -> Result { - let mut ctx = ResolveContext::new(); - let mut symbols = SymbolMap::new(); - let mut tools: Vec = Vec::new(); - let mut diagnostics = false; - let mut purl: Option = None; - let mut detected_version: Option = None; - let mut i = 0; - while i < args.len() { - let arg = args[i].as_str(); - match arg { - "--diagnostics" => diagnostics = true, - "--allow-weak" => ctx.allow_weak_identity_fallback = true, - "--platform" => { - ctx.platform = Some(take_value(args, i, arg)?); - i += 1; - } - "--architecture" => { - ctx.architecture = Some(take_value(args, i, arg)?); - i += 1; - } - "--revision" => { - ctx.catalog_revision = Some(take_value(args, i, arg)?); - i += 1; - } - "--project-root" => { - ctx.project_root = Some(take_value(args, i, arg)?); - i += 1; - } - "--symbol" => { - let pair = take_value(args, i, arg)?; - i += 1; - match pair.find('=') { - Some(eq) if eq > 0 => symbols.insert(&pair[..eq], &pair[eq + 1..]), - _ => return Err(Failure::Usage("--symbol expects name=value".to_string())), - } - } - "--purl" => { - purl = Some(take_value(args, i, arg)?); - i += 1; - } - "--detected-version" => { - detected_version = Some(take_value(args, i, arg)?); - i += 1; - } - _ => { - if arg.starts_with("--") { - return Err(Failure::Usage(format!("unknown option '{arg}'"))); - } - // --purl / --detected-version apply to the next tool name only. - if purl.is_none() && detected_version.is_none() { - tools.push(ToolInput::Name(arg.to_string())); - } else { - tools.push(ToolInput::Candidate(ToolCandidate { - invocation_name: arg.to_string(), - package_url: purl.take(), - detected_version: detected_version.take(), - })); - } - } - } - i += 1; - } - if purl.is_some() || detected_version.is_some() { - return Err(Failure::Usage( - "--purl/--detected-version must precede a tool name".to_string(), - )); - } - if !symbols.is_empty() { - ctx.symbols = Some(symbols); - } - let catalog = PolicyCatalog::new(store_from(catalog_dir)?); - let tools = ToolInputs(tools); - if diagnostics { - print( - &catalog.resolve_sandbox_policy_with_diagnostics(tools, &ctx)?.to_json(), - out, - ); - } else { - let policy = catalog.resolve_sandbox_policy(tools, &ctx)?; - print(&policy.map_or(Json::Null, |p| p.to_json()), out); - } - Ok(0) -} diff --git a/policy-catalog/rust/src/lib.rs b/policy-catalog/rust/src/lib.rs deleted file mode 100644 index 3325ab527..000000000 --- a/policy-catalog/rust/src/lib.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! PROTOTYPE: native Rust implementation of the MXC known-tool policy -//! catalog library, with behavioral parity to the TypeScript implementation -//! in `policy-catalog/src`. -//! -//! The runtime lookup API ([`resolve_sandbox_policy`], -//! [`resolve_sandbox_policy_with_diagnostics`], [`get_catalog_info`], -//! [`list_catalog_entries`], and [`PolicyCatalog`]) returns a **candidate -//! lower-bound** policy. It never grants access, launches a sandbox, contacts -//! a network service, or writes consumer state. The catalog bundled with this -//! crate is compiled in and integrity-checked on first use. -//! -//! [`tooling`] exposes the contribution/CI rules (validation, history, -//! canonical JSON, paths) shared with the `policy-catalog` binary. - -pub mod catalog; -pub mod errors; -pub mod history; -pub mod host; -pub mod json; -pub mod model; -pub mod paths; -pub mod purl; -pub mod resolver; -pub mod store; -mod text; -pub mod validate; -pub mod version_range; - -pub use errors::{ErrorCode, ErrorReason, PolicyCatalogError}; -pub use host::{FixedHost, HostEnvironment, SystemHost}; -pub use model::{ - Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, Diagnostics, - EntryMatchRecord, FilesystemPolicy, IdentityStrength, MatchedIdentity, Platform, PlatformVariantMetadata, - Provenance, ResolveContext, SandboxConfigResolution, SandboxPolicy, SymbolMap, ToolCandidate, ToolInput, - ToolInputs, ToolRecord, -}; -pub use resolver::{ - get_catalog_info, list_catalog_entries, resolve_sandbox_policy, resolve_sandbox_policy_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::catalog::{ - compare_catalog_revisions, select_variant, validate_catalog_revision, validate_contract, CatalogContract, - CatalogEntry, CatalogRevision, IdentityPredicate, PlatformVariant, - }; - pub use crate::history::{ - check_entry_revisions, check_published_immutability, check_store_history, PublishedRevision, PublishedState, - }; - pub use crate::host::architecture_from_machine; - pub use crate::json::{canonical_json, canonical_sha256, js_number_to_string, Json, JsonObject}; - pub use crate::paths::{case_key, folds_case, is_absolute_path, normalize_path, path_key_segments}; - pub use crate::purl::{parse_purl, ParsedPurl}; - pub use crate::store::{bundled_catalog_files, bundled_catalog_source, validate_manifest}; - pub use crate::validate::{ - check_against_base_ref, read_published_state_at_ref, validate_bundled_catalog, validate_catalog_directory, - CatalogValidationReport, - }; - pub use crate::version_range::{is_valid_version_range, satisfies_version_range}; -} - -/// The command-line harness behind the `policy-catalog` binary. -pub mod cli; diff --git a/src/Cargo.lock b/src/Cargo.lock index 0fd571ea9..cd8ff6abd 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1713,6 +1713,14 @@ dependencies = [ "wxc_common", ] +[[package]] +name = "mxc_policy_store" +version = "0.9.0" +dependencies = [ + "libc", + "sha2 0.10.9", +] + [[package]] name = "mxc_pty" version = "0.9.0" diff --git a/src/Cargo.toml b/src/Cargo.toml index 8f89800e1..2fc60689e 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -25,6 +25,7 @@ members = [ "core/mxc_config_contract", "core/mxc_darwin", "core/mxc_engine", + "core/mxc_policy_store", "core/mxc_pty", "core/mxc_schema_support", "core/mxc-sdk", @@ -81,6 +82,7 @@ lxc_common = { path = "backends/lxc/common" } mxc_build_common = { path = "core/mxc_build_common" } mxc_config_contract = { path = "core/mxc_config_contract" } mxc_engine = { path = "core/mxc_engine" } +mxc_policy_store = { path = "core/mxc_policy_store" } mxc_pty = { path = "core/mxc_pty" } mxc_schema_support = { path = "core/mxc_schema_support" } mxc-sdk = { path = "core/mxc-sdk" } diff --git a/src/core/mxc_policy_store/Cargo.toml b/src/core/mxc_policy_store/Cargo.toml new file mode 100644 index 000000000..dfaeb832c --- /dev/null +++ b/src/core/mxc_policy_store/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mxc_policy_store" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "PROTOTYPE, pending API review: the MXC policy store. Resolves known tools to a best-effort candidate floor SandboxPolicy from a statically bundled, integrity-checked policy catalog." +build = "build.rs" + +[lib] +name = "mxc_policy_store" +path = "src/lib.rs" + +[dependencies] +sha2.workspace = true + +[target.'cfg(unix)'.dependencies] +# uname(2) for the kernel machine type, Linux/macOS only. +libc.workspace = true diff --git a/src/core/mxc_policy_store/README.md b/src/core/mxc_policy_store/README.md new file mode 100644 index 000000000..5c516da0a --- /dev/null +++ b/src/core/mxc_policy_store/README.md @@ -0,0 +1,57 @@ +# mxc_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 (for +> example, the names may drop "Sandbox", and lookup may gain an intent such +> as `git pull` versus `git push`). It is not part of MXC 1.0; a later SDK +> release is targeted. + +The MXC policy store resolves known tools to a **best-effort floor** +`SandboxPolicy`: the access a known tool typically needs, which a caller +composes with its own policy. It is not a guarantee, and it is complementary +to Learning Mode rather than a replacement for it. + +The V1 catalog in `catalog/` is the single source of truth. `build.rs` embeds +it with `include_str!`, and the store checks each revision's published +SHA-256 on first use. Nothing is downloaded. Every MXC SDK uses this crate: + +| SDK | Entry point | +|-----|-------------| +| Rust (`mxc-sdk`) | `mxc_sdk::policy_store::{resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, get_catalog_info, list_catalog_entries}` | +| Node (`@microsoft/mxc-sdk`) | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` through `mxc_ffi` | +| C# (`Microsoft.Mxc.Sdk`) | `PolicyStore.ResolveSandboxPolicy`, `PolicyStore.ResolveSandboxPolicyWithDiagnostics`, `PolicyStore.GetCatalogInfo`, `PolicyStore.ListCatalogEntries` through `mxc_ffi` | + +This crate returns its own catalog-shaped policy model. `mxc-sdk` converts it +to `mxc_sdk::policy::SandboxPolicy`, and `mxc_ffi` hands the same JSON to the +Node and C# SDKs. + +## Layout + +| Path | Contents | +|------|----------| +| `catalog/` | Contract, manifest, and immutable published revisions (V1 data) | +| `schema/` | JSON Schemas for the catalog and manifest (editor validation) | +| `conformance/` | Language-neutral fixtures and path/canonical-JSON vectors | +| `src/` | Resolver, store, integrity, validation, and path rules | +| `tests/` | Library, conformance, vector, and catalog contribution tests | + +## Build and test + +From `src/`: + +```sh +cargo test -p mxc_policy_store +cargo clippy -p mxc_policy_store --all-targets -- -D warnings +``` + +`tests/catalog_validation.rs` is the contribution gate: integrity, contract, +entry-revision history, deterministic resolution, and conformance coverage. +Set `MXC_POLICY_STORE_BASE_REF=` (for example `origin/main`) to also +check that already-published revisions are unchanged. + +## Contributing a revision + +Add a new file under `catalog/revisions/`, list it with its canonical SHA-256 +in `catalog/manifest.json`, add a bundled-catalog conformance case under +`conformance/fixtures/`, and run the tests above. Published revisions are +immutable. diff --git a/policy-catalog/rust/build.rs b/src/core/mxc_policy_store/build.rs similarity index 60% rename from policy-catalog/rust/build.rs rename to src/core/mxc_policy_store/build.rs index 9e035aee3..5a644e600 100644 --- a/policy-catalog/rust/build.rs +++ b/src/core/mxc_policy_store/build.rs @@ -1,48 +1,41 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Embeds the catalog into the crate. +//! Embeds the V1 policy catalog into the crate. //! -//! Source of truth: `../catalog` (the repository's `policy-catalog/catalog`). -//! It is used when present and this is not a packaged crate. A packaged -//! crate (identified by the `Cargo.toml.orig` cargo writes into every -//! `.crate`) always uses its crate-local `catalog/`, which -//! `scripts/sync-catalog.mjs` copies from `../catalog` before -//! `cargo package`; that directory is git-ignored and listed in -//! `Cargo.toml` `include` so it ships in 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}; fn main() { - let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); - let repo_catalog = manifest_dir.join("..").join("catalog"); - let local_catalog = manifest_dir.join("catalog"); - let packaged = manifest_dir.join("Cargo.toml.orig").exists(); + let manifest_dir = + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let source = manifest_dir.join("catalog"); println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed={}", local_catalog.display()); - println!("cargo:rerun-if-changed={}", repo_catalog.display()); + println!("cargo:rerun-if-changed={}", source.display()); - let source = if !packaged && repo_catalog.join("manifest.json").is_file() { - repo_catalog - } else if local_catalog.join("manifest.json").is_file() { - local_catalog - } else { - panic!( - "no catalog to embed: expected {} (repository) or {} (run `node scripts/sync-catalog.mjs` before `cargo package`)", - repo_catalog.display(), - local_catalog.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()) + .map(|e| { + e.expect("dir entry") + .file_name() + .to_string_lossy() + .into_owned() + }) .filter(|name| name.ends_with(".json")) .collect(); revisions.sort(); diff --git a/policy-catalog/catalog/contract.v1.json b/src/core/mxc_policy_store/catalog/contract.v1.json similarity index 100% rename from policy-catalog/catalog/contract.v1.json rename to src/core/mxc_policy_store/catalog/contract.v1.json diff --git a/policy-catalog/catalog/manifest.json b/src/core/mxc_policy_store/catalog/manifest.json similarity index 100% rename from policy-catalog/catalog/manifest.json rename to src/core/mxc_policy_store/catalog/manifest.json diff --git a/policy-catalog/catalog/revisions/2026-09-29.1.json b/src/core/mxc_policy_store/catalog/revisions/2026-09-29.1.json similarity index 100% rename from policy-catalog/catalog/revisions/2026-09-29.1.json rename to src/core/mxc_policy_store/catalog/revisions/2026-09-29.1.json diff --git a/policy-catalog/conformance/fixtures/bundled-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json similarity index 100% rename from policy-catalog/conformance/fixtures/bundled-catalog.json rename to src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json diff --git a/policy-catalog/conformance/fixtures/synthetic-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json similarity index 100% rename from policy-catalog/conformance/fixtures/synthetic-catalog.json rename to src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json diff --git a/policy-catalog/conformance/vectors/canonical-json.json b/src/core/mxc_policy_store/conformance/vectors/canonical-json.json similarity index 100% rename from policy-catalog/conformance/vectors/canonical-json.json rename to src/core/mxc_policy_store/conformance/vectors/canonical-json.json diff --git a/policy-catalog/conformance/vectors/paths.json b/src/core/mxc_policy_store/conformance/vectors/paths.json similarity index 100% rename from policy-catalog/conformance/vectors/paths.json rename to src/core/mxc_policy_store/conformance/vectors/paths.json diff --git a/policy-catalog/schema/catalog.v1.schema.json b/src/core/mxc_policy_store/schema/catalog.v1.schema.json similarity index 100% rename from policy-catalog/schema/catalog.v1.schema.json rename to src/core/mxc_policy_store/schema/catalog.v1.schema.json diff --git a/policy-catalog/schema/manifest.v1.schema.json b/src/core/mxc_policy_store/schema/manifest.v1.schema.json similarity index 100% rename from policy-catalog/schema/manifest.v1.schema.json rename to src/core/mxc_policy_store/schema/manifest.v1.schema.json diff --git a/policy-catalog/rust/src/catalog.rs b/src/core/mxc_policy_store/src/catalog.rs similarity index 84% rename from policy-catalog/rust/src/catalog.rs rename to src/core/mxc_policy_store/src/catalog.rs index 7993ad80d..12fbf3df0 100644 --- a/policy-catalog/rust/src/catalog.rs +++ b/src/core/mxc_policy_store/src/catalog.rs @@ -18,7 +18,8 @@ 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"]; +pub const COMPOSABLE_FILESYSTEM_FIELDS: [&str; 3] = + ["deniedPaths", "readonlyPaths", "readwritePaths"]; const BACKEND_KEYS: [&str; 8] = [ "containment", @@ -111,7 +112,10 @@ impl CatalogPolicy { } pub fn version(&self) -> &str { - self.raw.get("version").and_then(Json::as_str).unwrap_or_default() + self.raw + .get("version") + .and_then(Json::as_str) + .unwrap_or_default() } pub fn has_filesystem(&self) -> bool { @@ -188,7 +192,11 @@ fn non_empty_string(value: Option<&Json>, at: &str) -> Result { 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)")), + _ => { + return fail(format!( + "'{at}' must be an array with at least {min_items} item(s)" + )) + } }; items .iter() @@ -208,8 +216,16 @@ fn parse_revision_id(value: &str) -> Option<(&str, &str)> { 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) { + 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..])) @@ -222,7 +238,10 @@ pub fn compare_catalog_revisions(left: &str, right: &str) -> Result { 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)); + 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!( @@ -245,7 +264,12 @@ pub fn validate_contract(raw: &Json) -> Result { }; only_fields( raw, - &["$comment", "catalogSchemaVersion", "sandboxPolicyVersions", "symbols"], + &[ + "$comment", + "catalogSchemaVersion", + "sandboxPolicyVersions", + "symbols", + ], "contract", )?; if raw.get("catalogSchemaVersion").and_then(Json::as_str) != Some(CATALOG_SCHEMA_VERSION) { @@ -253,7 +277,11 @@ pub fn validate_contract(raw: &Json) -> Result { "contract.catalogSchemaVersion must be '{CATALOG_SCHEMA_VERSION}'" )); } - let sandbox_policy_versions = string_array(raw.get("sandboxPolicyVersions"), "contract.sandboxPolicyVersions", 1)?; + let sandbox_policy_versions = string_array( + raw.get("sandboxPolicyVersions"), + "contract.sandboxPolicyVersions", + 1, + )?; let Some(raw_symbols) = record(raw.get("symbols")) else { return fail("contract.symbols must be an object"); }; @@ -278,7 +306,13 @@ pub fn validate_contract(raw: &Json) -> Result { definition.get("description"), &format!("contract.symbols.{name}.description"), )?; - symbols.push((name.to_string(), SymbolDefinition { source, description })); + symbols.push(( + name.to_string(), + SymbolDefinition { + source, + description, + }, + )); } Ok(CatalogContract { catalog_schema_version: CATALOG_SCHEMA_VERSION.to_string(), @@ -304,9 +338,9 @@ fn validate_template_path(value: &str, at: &str, contract: &CatalogContract) -> 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(['/', '\\']))); + 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" @@ -344,13 +378,22 @@ fn is_same_or_nested(left: &[String], right: &[String]) -> bool { /// Finds the first equal or ancestor/descendant pair of paths in different /// access classes. `classes` is in [`COMPOSABLE_FILESYSTEM_FIELDS`] order. -pub fn find_cross_class_overlap(classes: &[(&str, Vec)], platform: Platform) -> Option { - let fields: Vec<&(&str, Vec)> = classes.iter().filter(|(_, values)| !values.is_empty()).collect(); +pub fn find_cross_class_overlap( + classes: &[(&str, Vec)], + platform: Platform, +) -> Option { + let fields: Vec<&(&str, Vec)> = classes + .iter() + .filter(|(_, values)| !values.is_empty()) + .collect(); for i in 0..fields.len() { for j in i + 1..fields.len() { for left in &fields[i].1 { for right in &fields[j].1 { - if is_same_or_nested(&path_key_segments(left, platform), &path_key_segments(right, platform)) { + if is_same_or_nested( + &path_key_segments(left, platform), + &path_key_segments(right, platform), + ) { return Some(format!( "'{left}' ({}) overlaps '{right}' ({})", fields[i].0, fields[j].0 @@ -424,7 +467,9 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> .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")); + return fail(format!( + "'{port_at}.{key}' must be an integer in 1..65535" + )); } } } @@ -434,7 +479,9 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> Some(start) => end < start, }; if bad { - return fail(format!("'{port_at}.endPort' requires a lower or equal 'port'")); + return fail(format!( + "'{port_at}.endPort' requires a lower or equal 'port'" + )); } } } @@ -443,7 +490,11 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> Ok(()) } -fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContract) -> Result { +fn validate_sandbox_policy( + raw: Option<&Json>, + at: &str, + contract: &CatalogContract, +) -> Result { let Some(raw) = record(raw) else { return fail(format!("'{at}' must be an object")); }; @@ -454,7 +505,11 @@ fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContr )); } } - only_fields(raw, &["version", "filesystem", "network", "ui", "timeoutMs"], at)?; + only_fields( + raw, + &["version", "filesystem", "network", "ui", "timeoutMs"], + at, + )?; let version = non_empty_string(raw.get("version"), &format!("{at}.version"))?; if !contract.sandbox_policy_versions.contains(&version) { return fail(format!( @@ -465,7 +520,11 @@ fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContr 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"))?; + 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}"); @@ -484,7 +543,11 @@ fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContr 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"))?; + 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!( @@ -503,7 +566,11 @@ fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContr 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"))?; + 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!( @@ -513,7 +580,9 @@ fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContr } if let Some(loopback) = ingress.get("hostLoopback") { if !matches!(loopback.as_str(), Some("allow" | "deny")) { - return fail(format!("'{at}.network.ingress.hostLoopback' is unsupported")); + return fail(format!( + "'{at}.network.ingress.hostLoopback' is unsupported" + )); } } } @@ -556,7 +625,10 @@ fn validate_sandbox_policy(raw: Option<&Json>, at: &str, contract: &CatalogContr fn identity_keys(predicate: &IdentityPredicate) -> Vec { match predicate { IdentityPredicate::Purl { value, .. } => { - vec![format!("purl:{}", parse_purl(value).map(|p| p.key).unwrap_or_default())] + vec![format!( + "purl:{}", + parse_purl(value).map(|p| p.key).unwrap_or_default() + )] } IdentityPredicate::InvocationName { names } => names .iter() @@ -584,17 +656,24 @@ fn validate_identity(raw: Option<&Json>, at: &str) -> Result { only_fields(item, &["kind", "names"], &item_at)?; @@ -620,7 +699,11 @@ fn validate_identity(raw: Option<&Json>, at: &str) -> Result, at: &str, contract: &CatalogContract) -> Result> { +fn validate_variants( + raw: Option<&Json>, + at: &str, + contract: &CatalogContract, +) -> 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")), @@ -636,8 +719,16 @@ fn validate_variants(raw: Option<&Json>, at: &str, contract: &CatalogContract) - 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 { + 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" )); @@ -646,10 +737,17 @@ fn validate_variants(raw: Option<&Json>, at: &str, contract: &CatalogContract) - 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")), + None => { + return fail(format!( + "'{item_at}.when.architecture' must be one of x64, arm64" + )) + } }, }; - let selector = format!("{platform}/{}", architecture.map_or("*", Architecture::as_str)); + 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}'") @@ -670,7 +768,8 @@ fn validate_variants(raw: Option<&Json>, at: &str, contract: &CatalogContract) - return fail(format!("'{dep_at}' must be an object")); }; only_fields(dependency, &["entryId", "versionRange"], &dep_at)?; - let entry_id = non_empty_string(dependency.get("entryId"), &format!("{dep_at}.entryId"))?; + 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")); } @@ -678,7 +777,9 @@ fn validate_variants(raw: Option<&Json>, at: &str, contract: &CatalogContract) - if let Some(range) = dependency.get("versionRange") { let range = non_empty_string(Some(range), &format!("{dep_at}.versionRange"))?; if !is_valid_version_range(&range) { - return fail(format!("'{dep_at}.versionRange' is not a valid version range")); + return fail(format!( + "'{dep_at}.versionRange' is not a valid version range" + )); } version_range = Some(range); } @@ -689,8 +790,11 @@ fn validate_variants(raw: Option<&Json>, at: &str, contract: &CatalogContract) - } dependencies = Some(list); } - let sandbox_policy = - validate_sandbox_policy(item.get("sandboxPolicy"), &format!("{item_at}.sandboxPolicy"), contract)?; + let sandbox_policy = validate_sandbox_policy( + item.get("sandboxPolicy"), + &format!("{item_at}.sandboxPolicy"), + contract, + )?; variants.push(PlatformVariant { platform, architecture, @@ -710,7 +814,9 @@ fn is_entry_id(value: &str) -> bool { 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 + .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, '.' | '_' | '-')) } @@ -743,7 +849,11 @@ fn validate_entry(raw: &Json, at: &str, contract: &CatalogContract) -> Result Result Option> { - let for_platform = || entry.platform_variants.iter().filter(move |v| v.platform == platform); + let for_platform = || { + entry + .platform_variants + .iter() + .filter(move |v| v.platform == platform) + }; if let Some(variant) = for_platform().find(|v| v.architecture == Some(architecture)) { - return Some(VariantSelection { variant, exact: true }); + return Some(VariantSelection { + variant, + exact: true, + }); } for_platform() .find(|v| v.architecture.is_none()) - .map(|variant| VariantSelection { variant, exact: false }) + .map(|variant| VariantSelection { + variant, + exact: false, + }) } #[derive(Clone, Copy, Debug)] @@ -903,7 +1025,10 @@ pub fn composition_violation(nodes: &[ClosureNode<'_>]) -> Option { } if versions.len() > 1 { versions.sort_by(|a, b| cmp_utf16(a, b)); - return Some(format!("mixed sandboxPolicy.version values ({})", versions.join(", "))); + return Some(format!( + "mixed sandboxPolicy.version values ({})", + versions.join(", ") + )); } if nodes.len() < 2 { return None; @@ -926,7 +1051,10 @@ pub fn composition_violation(nodes: &[ClosureNode<'_>]) -> Option { // --------------------------------------------------------------------------- /// Validates one catalog revision against the v1 contract (design §7). -pub fn validate_catalog_revision(raw: &Json, contract: &CatalogContract) -> Result { +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"); }; @@ -935,13 +1063,16 @@ pub fn validate_catalog_revision(raw: &Json, contract: &CatalogContract) -> Resu &["catalogSchemaVersion", "catalogRevision", "entries"], "catalog", )?; - if object.get("catalogSchemaVersion").and_then(Json::as_str) != Some(contract.catalog_schema_version.as_str()) { + 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")?; + 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" @@ -982,15 +1113,16 @@ pub fn validate_catalog_revision(raw: &Json, contract: &CatalogContract) -> Resu let Some(selected) = select_variant(entry, platform, architecture) else { continue; }; - let nodes = match dependency_closure(entry, selected, &by_id, platform, architecture) { - Ok(nodes) => nodes, - Err(failure) => { - return fail(format!( - "'{}' on {platform}/{architecture}: {} ({})", - entry.entry_id, failure.reason, failure.detail - )) - } - }; + let nodes = + match dependency_closure(entry, selected, &by_id, platform, architecture) { + Ok(nodes) => nodes, + Err(failure) => { + return fail(format!( + "'{}' on {platform}/{architecture}: {} ({})", + entry.entry_id, failure.reason, failure.detail + )) + } + }; if let Some(violation) = composition_violation(&nodes) { return fail(format!( "'{}' on {platform}/{architecture}: {violation}", @@ -1009,7 +1141,10 @@ pub fn validate_catalog_revision(raw: &Json, contract: &CatalogContract) -> Resu }) .collect(); if let Some(overlap) = find_cross_class_overlap(&classes, platform) { - return fail(format!("'{}' on {platform}/{architecture}: {overlap}", entry.entry_id)); + return fail(format!( + "'{}' on {platform}/{architecture}: {overlap}", + entry.entry_id + )); } } } diff --git a/policy-catalog/rust/src/errors.rs b/src/core/mxc_policy_store/src/errors.rs similarity index 98% rename from policy-catalog/rust/src/errors.rs rename to src/core/mxc_policy_store/src/errors.rs index 79772e9e0..604d85830 100644 --- a/policy-catalog/rust/src/errors.rs +++ b/src/core/mxc_policy_store/src/errors.rs @@ -72,7 +72,9 @@ impl ErrorReason { /// The one code each reason maps to (`ERROR_CODE_FOR_REASON`). pub fn code(self) -> ErrorCode { match self { - ErrorReason::InvalidCatalog | ErrorReason::CompositionConflict => ErrorCode::PolicyValidation, + ErrorReason::InvalidCatalog | ErrorReason::CompositionConflict => { + ErrorCode::PolicyValidation + } ErrorReason::InvalidContext => ErrorCode::MalformedRequest, ErrorReason::UnsupportedHost => ErrorCode::UnsupportedContainment, ErrorReason::Integrity | ErrorReason::RevisionUnavailable => ErrorCode::BackendError, diff --git a/policy-catalog/rust/src/history.rs b/src/core/mxc_policy_store/src/history.rs similarity index 92% rename from policy-catalog/rust/src/history.rs rename to src/core/mxc_policy_store/src/history.rs index 11d168b66..b3c9285ea 100644 --- a/policy-catalog/rust/src/history.rs +++ b/src/core/mxc_policy_store/src/history.rs @@ -4,7 +4,9 @@ //! Entry-revision monotonicity and published-revision immutability //! (TypeScript `src/history.ts`). -use crate::catalog::{compare_catalog_revisions, entry_semantic_key, revision_number, CatalogRevision}; +use crate::catalog::{ + compare_catalog_revisions, entry_semantic_key, revision_number, CatalogRevision, +}; use crate::json::{canonical_json, Json}; use crate::store::CatalogStore; use std::cmp::Ordering; @@ -23,7 +25,11 @@ pub fn check_entry_revisions(previous: &CatalogRevision, next: &CatalogRevision) next.catalog_revision, previous.catalog_revision )); } - let before: HashMap<&str, _> = previous.entries.iter().map(|e| (e.entry_id.as_str(), e)).collect(); + 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; @@ -150,7 +156,10 @@ fn canonical_text(text: Option<&String>) -> Option { /// Published revisions keep their manifest entry, digest, and content; new /// revisions are only appended (design §10). -pub fn check_published_immutability(base: &PublishedState, proposed: &PublishedState) -> Vec { +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); @@ -162,7 +171,9 @@ pub fn check_published_immutability(base: &PublishedState, proposed: &PublishedS continue; }; if now.file != published.file || now.sha256 != published.sha256 { - errors.push(format!("published revision '{name}' manifest entry was modified")); + 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)); diff --git a/policy-catalog/rust/src/host.rs b/src/core/mxc_policy_store/src/host.rs similarity index 94% rename from policy-catalog/rust/src/host.rs rename to src/core/mxc_policy_store/src/host.rs index 181084964..5fee5e706 100644 --- a/policy-catalog/rust/src/host.rs +++ b/src/core/mxc_policy_store/src/host.rs @@ -55,7 +55,10 @@ fn host_platform() -> Result { "linux" => Ok(Platform::Linux), _ => Err(PolicyCatalogError::new( ErrorReason::UnsupportedHost, - format!("host platform '{}' has no catalog selector", node_platform_name()), + format!( + "host platform '{}' has no catalog selector", + node_platform_name() + ), )), } } @@ -115,7 +118,10 @@ fn parse_reg_output(output: &str) -> Option { let start = from + found; let rest = &output[start + "processor_architecture".len()..]; let trimmed = rest.trim_start_matches(crate::text::js_is_space); - if trimmed.len() < rest.len() && trimmed.len() >= 6 && trimmed[..6].eq_ignore_ascii_case("reg_sz") { + 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::text::js_is_space); if value.len() < after.len() { @@ -286,7 +292,10 @@ impl HostEnvironment for FixedHost { } fn symbol(&self, name: &str) -> Option { - self.symbols.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone()) + self.symbols + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) } } @@ -298,8 +307,14 @@ mod tests { 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("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); } diff --git a/policy-catalog/rust/src/json.rs b/src/core/mxc_policy_store/src/json.rs similarity index 98% rename from policy-catalog/rust/src/json.rs rename to src/core/mxc_policy_store/src/json.rs index ffcf58630..438b603c2 100644 --- a/policy-catalog/rust/src/json.rs +++ b/src/core/mxc_policy_store/src/json.rs @@ -459,7 +459,9 @@ impl Parser<'_> { } Some(b'\\') => { self.pos += 1; - let escape = self.peek().ok_or_else(|| self.error("Bad escaped character"))?; + let escape = self + .peek() + .ok_or_else(|| self.error("Bad escaped character"))?; self.pos += 1; match escape { b'"' => out.push('"'), @@ -478,8 +480,11 @@ impl Parser<'_> { 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")); + let code = + 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00); + out.push( + char::from_u32(code).expect("valid surrogate pair"), + ); continue; } self.pos = save; diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/core/mxc_policy_store/src/lib.rs new file mode 100644 index 000000000..9152bc091 --- /dev/null +++ b/src/core/mxc_policy_store/src/lib.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! **PROTOTYPE, pending API review.** The MXC policy store: resolves known +//! tools to a candidate floor [`SandboxPolicy`] from a policy catalog that is +//! bundled statically in this crate. +//! +//! The API names, shapes, and catalog contents are proposed and may change +//! before sign-off (for example, the names may drop "Sandbox", and lookup may +//! gain an intent such as `git pull` versus `git push`). The feature is not +//! part of MXC 1.0. +//! +//! The runtime lookup API ([`resolve_sandbox_policy`], +//! [`resolve_sandbox_policy_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 compose with their own policy. 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 integrity-checked on first use; nothing is downloaded. +//! +//! Most callers reach this crate through an MXC SDK: `mxc_sdk::policy_store` +//! in Rust, and the Node and C# SDKs through `mxc_ffi`. [`tooling`] exposes +//! the catalog contribution rules (validation, history, canonical JSON, +//! paths) that the crate's own tests enforce. + +pub mod catalog; +pub mod errors; +pub mod history; +pub mod host; +pub mod json; +pub mod model; +pub mod paths; +pub mod purl; +pub mod resolver; +pub mod store; +mod text; +pub mod validate; +pub mod version_range; + +pub use errors::{ErrorCode, ErrorReason, PolicyCatalogError}; +pub use host::{FixedHost, HostEnvironment, SystemHost}; +pub use model::{ + Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, + Diagnostics, EntryMatchRecord, FilesystemPolicy, IdentityStrength, MatchedIdentity, Platform, + PlatformVariantMetadata, Provenance, ResolveContext, SandboxConfigResolution, SandboxPolicy, + SymbolMap, ToolCandidate, ToolInput, ToolInputs, ToolRecord, +}; +pub use resolver::{ + get_catalog_info, list_catalog_entries, resolve_sandbox_policy, + resolve_sandbox_policy_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::catalog::{ + compare_catalog_revisions, select_variant, validate_catalog_revision, validate_contract, + CatalogContract, CatalogEntry, CatalogRevision, IdentityPredicate, PlatformVariant, + SymbolSource, + }; + pub use crate::history::{ + check_entry_revisions, check_published_immutability, check_store_history, + PublishedRevision, PublishedState, + }; + pub use crate::host::architecture_from_machine; + pub use crate::json::{ + canonical_json, canonical_sha256, js_number_to_string, Json, JsonObject, + }; + pub use crate::paths::{ + case_key, folds_case, is_absolute_path, normalize_path, path_key_segments, + }; + pub use crate::purl::{parse_purl, ParsedPurl}; + pub use crate::store::{bundled_catalog_files, bundled_catalog_source, validate_manifest}; + pub use crate::validate::{ + check_against_base_ref, read_published_state_at_ref, validate_bundled_catalog, + validate_catalog_directory, CatalogValidationReport, + }; + pub use crate::version_range::{is_valid_version_range, satisfies_version_range}; +} diff --git a/policy-catalog/rust/src/model.rs b/src/core/mxc_policy_store/src/model.rs similarity index 95% rename from policy-catalog/rust/src/model.rs rename to src/core/mxc_policy_store/src/model.rs index 72bdcc505..ad48e6651 100644 --- a/policy-catalog/rust/src/model.rs +++ b/src/core/mxc_policy_store/src/model.rs @@ -203,7 +203,9 @@ impl SymbolMap { } pub fn iter(&self) -> impl Iterator { - self.0.iter().map(|(k, v)| (k, v.as_str().unwrap_or_default())) + self.0 + .iter() + .map(|(k, v)| (k, v.as_str().unwrap_or_default())) } pub fn len(&self) -> usize { @@ -259,7 +261,9 @@ impl ResolveContext { } pub fn symbol(mut self, name: impl Into, value: impl Into) -> Self { - self.symbols.get_or_insert_with(SymbolMap::new).insert(name, value); + self.symbols + .get_or_insert_with(SymbolMap::new) + .insert(name, value); self } @@ -387,7 +391,10 @@ pub struct Diagnostics { impl Diagnostics { pub fn to_json(&self) -> Json { let mut object = JsonObject::new(); - object.insert("catalogRevision", Json::String(self.catalog_revision.clone())); + object.insert( + "catalogRevision", + Json::String(self.catalog_revision.clone()), + ); let tools = self .tools .iter() @@ -474,7 +481,10 @@ impl CatalogIdentityMetadata { pub fn to_json(&self) -> Json { let mut o = JsonObject::new(); match self { - CatalogIdentityMetadata::Purl { value, version_range } => { + CatalogIdentityMetadata::Purl { + value, + version_range, + } => { o.insert("kind", "purl".into()); o.insert("value", value.as_str().into()); if let Some(range) = version_range { @@ -546,7 +556,10 @@ impl CatalogEntryMetadata { vo.insert("architecture", arch.as_str().into()); } vo.insert("dependencyEntryIds", strings(&v.dependency_entry_ids)); - vo.insert("sandboxPolicyVersion", v.sandbox_policy_version.as_str().into()); + vo.insert( + "sandboxPolicyVersion", + v.sandbox_policy_version.as_str().into(), + ); Json::Object(vo) }) .collect(); @@ -566,7 +579,10 @@ pub struct CatalogInfo { 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( + "catalogSchemaVersion", + self.catalog_schema_version.as_str().into(), + ); o.insert("catalogRevision", self.catalog_revision.as_str().into()); Json::Object(o) } diff --git a/policy-catalog/rust/src/paths.rs b/src/core/mxc_policy_store/src/paths.rs similarity index 91% rename from policy-catalog/rust/src/paths.rs rename to src/core/mxc_policy_store/src/paths.rs index 546070a1c..132f17b21 100644 --- a/policy-catalog/rust/src/paths.rs +++ b/src/core/mxc_policy_store/src/paths.rs @@ -27,7 +27,8 @@ fn is_posix_sep(code: u16) -> bool { } 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) + (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 { @@ -39,7 +40,11 @@ fn s(value: &[u16]) -> String { } fn index_of(haystack: &[u16], needle: u16, from: usize) -> Option { - haystack.iter().skip(from).position(|c| *c == needle).map(|i| i + from) + haystack + .iter() + .skip(from) + .position(|c| *c == needle) + .map(|i| i + from) } /// JavaScript `String.prototype.slice(start, end)` with negative-index rules. @@ -96,7 +101,12 @@ fn is_windows_reserved_name(path: &[u16], colon_index: isize) -> bool { } /// Node `normalizeString`: resolves `.` and `..` segments. -fn normalize_string(path: &[u16], allow_above_root: bool, separator: u16, is_separator: fn(u16) -> bool) -> Vec { +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; @@ -126,7 +136,10 @@ fn normalize_string(path: &[u16], allow_above_root: bool, separator: u16, is_sep 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); + 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; @@ -221,13 +234,27 @@ fn win32_normalize_units(path: &[u16]) -> Vec { 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])); + 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]); + return cat(&[ + &[BACKSLASH, BACKSLASH], + first_part, + &bs, + &path[last..], + &bs, + ]); } else { - device = Some(cat(&[&[BACKSLASH, BACKSLASH], first_part, &bs, &path[last..j]])); + device = Some(cat(&[ + &[BACKSLASH, BACKSLASH], + first_part, + &bs, + &path[last..j], + ])); root_end = j; } } @@ -470,7 +497,10 @@ pub fn path_key_segments(value: &str, platform: Platform) -> Vec { } else { &segments[..] }; - segments.iter().map(|segment| case_key(segment, platform)).collect() + segments + .iter() + .map(|segment| case_key(segment, platform)) + .collect() } #[cfg(test)] diff --git a/policy-catalog/rust/src/purl.rs b/src/core/mxc_policy_store/src/purl.rs similarity index 89% rename from policy-catalog/rust/src/purl.rs rename to src/core/mxc_policy_store/src/purl.rs index e3c0366c4..670fc5447 100644 --- a/policy-catalog/rust/src/purl.rs +++ b/src/core/mxc_policy_store/src/purl.rs @@ -62,7 +62,11 @@ pub fn parse_purl(value: &str) -> Option { return None; } Some(ParsedPurl { - key: format!("{}/{}", segments[0].to_ascii_lowercase(), segments[1..].join("/")), + key: format!( + "{}/{}", + segments[0].to_ascii_lowercase(), + segments[1..].join("/") + ), version: version.filter(|v| !v.is_empty()), }) } @@ -80,14 +84,20 @@ mod tests { #[test] fn parses_like_typescript() { - assert_eq!(parse_purl("pkg:npm/npm@10.9.0"), purl("npm/npm", Some("10.9.0"))); + assert_eq!( + parse_purl("pkg:npm/npm@10.9.0"), + purl("npm/npm", Some("10.9.0")) + ); assert_eq!( parse_purl("pkg:NPM/%40scope/pkg@1.0.0?x=y#sub"), purl("npm/%40scope/pkg", Some("1.0.0")) ); assert_eq!(parse_purl("pkg:npm/npm"), purl("npm/npm", None)); assert_eq!(parse_purl("pkg:npm/npm@"), purl("npm/npm", None)); - assert_eq!(parse_purl("pkg:npm/a@%E2%82%AC"), purl("npm/a", Some("\u{20ac}"))); + assert_eq!( + parse_purl("pkg:npm/a@%E2%82%AC"), + purl("npm/a", Some("\u{20ac}")) + ); assert_eq!(parse_purl("npm/npm"), None); assert_eq!(parse_purl("pkg:npm"), None); assert_eq!(parse_purl("pkg:npm//x"), None); diff --git a/policy-catalog/rust/src/resolver.rs b/src/core/mxc_policy_store/src/resolver.rs similarity index 87% rename from policy-catalog/rust/src/resolver.rs rename to src/core/mxc_policy_store/src/resolver.rs index 4e9cc72e1..6e5d8b856 100644 --- a/policy-catalog/rust/src/resolver.rs +++ b/src/core/mxc_policy_store/src/resolver.rs @@ -4,16 +4,18 @@ //! Runtime lookup and inspection (TypeScript `src/resolver.ts`). use crate::catalog::{ - composition_violation, dependency_closure, entry_index, find_cross_class_overlap, policy_symbols, select_variant, - CatalogEntry, ClosureNode, IdentityPredicate, SymbolSource, VariantSelection, COMPOSABLE_FILESYSTEM_FIELDS, + composition_violation, dependency_closure, entry_index, find_cross_class_overlap, + policy_symbols, select_variant, CatalogEntry, ClosureNode, IdentityPredicate, SymbolSource, + VariantSelection, COMPOSABLE_FILESYSTEM_FIELDS, }; use crate::errors::{invalid_context, ErrorReason, PolicyCatalogError, Result}; use crate::host::{HostEnvironment, SystemHost}; use crate::json::cmp_utf16; use crate::model::{ - Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, Diagnostics, - EntryMatchRecord, FilesystemPolicy, MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, ResolveContext, - SandboxConfigResolution, SandboxPolicy, ToolCandidate, ToolInputs, ToolRecord, + Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, + Diagnostics, EntryMatchRecord, FilesystemPolicy, MatchedIdentity, Platform, + PlatformVariantMetadata, Provenance, ResolveContext, SandboxConfigResolution, SandboxPolicy, + ToolCandidate, ToolInputs, ToolRecord, }; use crate::paths::{case_key, is_absolute_path, normalize_path, path_key_segments}; use crate::purl::{parse_purl, ParsedPurl}; @@ -131,12 +133,17 @@ impl PolicyCatalog { .identity .iter() .map(|predicate| match predicate { - IdentityPredicate::Purl { value, version_range } => CatalogIdentityMetadata::Purl { + IdentityPredicate::Purl { + value, + version_range, + } => CatalogIdentityMetadata::Purl { value: value.clone(), version_range: version_range.clone(), }, IdentityPredicate::InvocationName { names } => { - CatalogIdentityMetadata::InvocationName { names: names.clone() } + CatalogIdentityMetadata::InvocationName { + names: names.clone(), + } } }) .collect(), @@ -252,12 +259,16 @@ impl PolicyCatalog { } for m in &matches { let nodes = - dependency_closure(m.entry, m.selection, &by_id, platform, architecture.get()?).map_err(|f| { - PolicyCatalogError::new( - ErrorReason::InvalidCatalog, - format!("dependency resolution failed: {} ({})", f.reason, f.detail), - ) - })?; + dependency_closure(m.entry, m.selection, &by_id, platform, architecture.get()?) + .map_err(|f| { + PolicyCatalogError::new( + ErrorReason::InvalidCatalog, + format!( + "dependency resolution failed: {} ({})", + f.reason, f.detail + ), + ) + })?; for node in nodes { if selected_ids.insert(node.entry.entry_id.as_str()) { selected.push(node); @@ -309,7 +320,10 @@ impl PolicyCatalog { Some(symbols) => Some(compose_policy(&selected, &symbols, platform)?), }; diagnostics.warnings = warnings; - Ok(SandboxConfigResolution { policy, diagnostics }) + Ok(SandboxConfigResolution { + policy, + diagnostics, + }) } #[allow(clippy::too_many_arguments)] @@ -346,15 +360,17 @@ impl PolicyCatalog { 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) - } + 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 { .. })); + 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", @@ -426,20 +442,23 @@ impl PolicyCatalog { Ok(matches) } - fn validate_context(&self, ctx: &ResolveContext) -> Result<(Option, Option)> { + 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")))?, - ), + 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")))?, - ), + 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( @@ -485,7 +504,11 @@ impl PolicyCatalog { let value = if definition.source == SymbolSource::Context { ctx.project_root.clone() } else { - let mut value = ctx.symbols.as_ref().and_then(|s| s.get(&name)).map(str::to_string); + let mut value = ctx + .symbols + .as_ref() + .and_then(|s| s.get(&name)) + .map(str::to_string); if value.is_none() && definition.source == SymbolSource::Host && self.host.platform().ok() == Some(platform) @@ -512,11 +535,12 @@ impl PolicyCatalog { if !missing.is_empty() { missing.sort_by(|a, b| cmp_utf16(&a.0, &b.0)); for (name, entry_ids) 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 hint = + if contract.symbol(&name).map(|d| d.source) == Some(SymbolSource::Context) { + "ResolveContext.projectRoot".to_string() + } else { + format!("ResolveContext.symbols.{name}") + }; warnings.push(format!( "required symbol '{name}' (needed by {}) is unresolved; supply {hint}; no policy was returned", entry_ids.join(", ") @@ -530,7 +554,10 @@ impl PolicyCatalog { /// Distinct dependency edges among the selected entries, ordered by /// entryId, entryRevision, then requiredVersionRange (absent first). -fn dependency_records(nodes: &[ClosureNode<'_>], by_id: &HashMap<&str, &CatalogEntry>) -> Vec { +fn dependency_records( + nodes: &[ClosureNode<'_>], + by_id: &HashMap<&str, &CatalogEntry>, +) -> Vec { let mut records: Vec = Vec::new(); for node in nodes { for dependency in node.variant.dependencies.iter().flatten() { @@ -552,12 +579,14 @@ fn dependency_records(nodes: &[ClosureNode<'_>], by_id: &HashMap<&str, &CatalogE .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( + || 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), + }, + ) }); records } @@ -621,7 +650,10 @@ fn bundled() -> Result<&'static PolicyCatalog> { } /// Composed candidate policy for one or several tools, from the bundled catalog. -pub fn resolve_sandbox_policy(tools: impl Into, ctx: &ResolveContext) -> Result> { +pub fn resolve_sandbox_policy( + tools: impl Into, + ctx: &ResolveContext, +) -> Result> { bundled()?.resolve_sandbox_policy(tools, ctx) } diff --git a/policy-catalog/rust/src/store.rs b/src/core/mxc_policy_store/src/store.rs similarity index 95% rename from policy-catalog/rust/src/store.rs rename to src/core/mxc_policy_store/src/store.rs index 1bd806fa0..8c8c4e5a6 100644 --- a/policy-catalog/rust/src/store.rs +++ b/src/core/mxc_policy_store/src/store.rs @@ -5,8 +5,8 @@ //! (TypeScript `src/store.ts`), plus the catalog bundled into this crate. use crate::catalog::{ - compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, validate_contract, CatalogContract, - CatalogRevision, CATALOG_SCHEMA_VERSION, + compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, + validate_contract, CatalogContract, CatalogRevision, CATALOG_SCHEMA_VERSION, }; use crate::errors::{invalid_catalog, ErrorReason, PolicyCatalogError, Result}; use crate::json::{canonical_sha256, Json, JsonObject}; @@ -88,7 +88,10 @@ impl MemorySource { .to_string() }); let mut manifest = JsonObject::new(); - manifest.insert("catalogSchemaVersion", Json::String(CATALOG_SCHEMA_VERSION.into())); + manifest.insert( + "catalogSchemaVersion", + Json::String(CATALOG_SCHEMA_VERSION.into()), + ); manifest.insert("defaultRevision", Json::String(default)); manifest.insert("revisions", Json::Array(listed)); Self { @@ -215,10 +218,18 @@ pub fn validate_manifest(raw: &Json) -> Result { return invalid(format!("'{at}.file' must be '{expected_file}'")); } let sha256 = match item.get("sha256").and_then(Json::as_str) { - Some(d) if d.len() == 64 && d.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => { + Some(d) + if d.len() == 64 + && d.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => + { d.to_string() } - _ => return invalid(format!("'{at}.sha256' must be a lower-case hex SHA-256 digest")), + _ => { + return invalid(format!( + "'{at}.sha256' must be a lower-case hex SHA-256 digest" + )) + } }; revisions.push(ManifestRevision { catalog_revision, @@ -307,7 +318,12 @@ impl CatalogStore { 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 { + 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"), diff --git a/policy-catalog/rust/src/text.rs b/src/core/mxc_policy_store/src/text.rs similarity index 86% rename from policy-catalog/rust/src/text.rs rename to src/core/mxc_policy_store/src/text.rs index dc0319a60..9d226cdea 100644 --- a/policy-catalog/rust/src/text.rs +++ b/src/core/mxc_policy_store/src/text.rs @@ -9,7 +9,13 @@ 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}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' ) } @@ -34,7 +40,10 @@ pub fn symbol_matches(value: &str) -> Vec<(usize, usize, &str)> { 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) { + 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) @@ -85,7 +94,10 @@ mod tests { #[test] fn symbols() { - let found: Vec<&str> = symbol_matches("${a}/${b_1}${C}${x").iter().map(|m| m.2).collect(); + let found: Vec<&str> = symbol_matches("${a}/${b_1}${C}${x") + .iter() + .map(|m| m.2) + .collect(); assert_eq!(found, ["a", "b_1"]); assert_eq!(replace_symbols("${a}/x/${a}", |_| "$&".into()), "$&/x/$&"); assert!(is_symbol_name("git_prefix")); diff --git a/policy-catalog/rust/src/validate.rs b/src/core/mxc_policy_store/src/validate.rs similarity index 91% rename from policy-catalog/rust/src/validate.rs rename to src/core/mxc_policy_store/src/validate.rs index 07010a2ae..4e69548cd 100644 --- a/policy-catalog/rust/src/validate.rs +++ b/src/core/mxc_policy_store/src/validate.rs @@ -6,7 +6,9 @@ //! runtime lookup path never runs git. use crate::errors::{invalid_catalog, PolicyCatalogError}; -use crate::history::{check_published_immutability, check_store_history, PublishedRevision, PublishedState}; +use crate::history::{ + check_published_immutability, check_store_history, PublishedRevision, PublishedState, +}; use crate::json::{Json, JsonObject}; use crate::model::Platform; use crate::paths::{is_absolute_path, normalize_path}; @@ -51,7 +53,12 @@ impl CatalogValidationReport { } o.insert( "errors", - Json::Array(self.errors.iter().map(|s| Json::String(s.clone())).collect()), + Json::Array( + self.errors + .iter() + .map(|s| Json::String(s.clone())) + .collect(), + ), ); Json::Object(o) } @@ -163,17 +170,24 @@ pub fn read_published_state_at_ref( ) -> Result, PolicyCatalogError> { if base_ref.is_empty() || base_ref.starts_with('-') - || base_ref.chars().any(|c| c == '\0' || crate::text::js_is_space(c)) + || base_ref + .chars() + .any(|c| c == '\0' || crate::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 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")))?; + .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!( @@ -217,7 +231,11 @@ pub fn read_published_state_at_ref( /// 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) { +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()), @@ -278,7 +296,10 @@ fn run_checks( /// 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 { +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) diff --git a/policy-catalog/rust/src/version_range.rs b/src/core/mxc_policy_store/src/version_range.rs similarity index 94% rename from policy-catalog/rust/src/version_range.rs rename to src/core/mxc_policy_store/src/version_range.rs index 8a1b02037..3466539ac 100644 --- a/policy-catalog/rust/src/version_range.rs +++ b/src/core/mxc_policy_store/src/version_range.rs @@ -37,7 +37,10 @@ struct Comparator { /// Consumes `\d+` (ASCII) from `s`, returning the digits and the rest. fn digits(s: &str) -> Option<(&str, &str)> { - let end = s.bytes().position(|b| !b.is_ascii_digit()).unwrap_or(s.len()); + let end = s + .bytes() + .position(|b| !b.is_ascii_digit()) + .unwrap_or(s.len()); if end == 0 { None } else { @@ -127,7 +130,8 @@ fn parse_version_evidence(value: &str) -> Option { } } if rest.is_empty() - || ((rest.starts_with('-') || rest.starts_with('+')) && !rest[1..].chars().any(js_is_line_terminator)) + || ((rest.starts_with('-') || rest.starts_with('+')) + && !rest[1..].chars().any(js_is_line_terminator)) { Some(version) } else { @@ -176,7 +180,9 @@ mod tests { for range in [">=10 <12", "22", ">=1.2.3", "=1.0.0", "<2 || >=4"] { assert!(is_valid_version_range(range), "{range}"); } - for range in ["", "^1.2.3", "~1", "1.x", ">= 1", "1 ||", "latest", "1.2.3.4", "1"] { + for range in [ + "", "^1.2.3", "~1", "1.x", ">= 1", "1 ||", "latest", "1.2.3.4", "1", + ] { assert!(!is_valid_version_range(range), "{range}"); } } diff --git a/src/core/mxc_policy_store/tests/catalog_validation.rs b/src/core/mxc_policy_store/tests/catalog_validation.rs new file mode 100644 index 000000000..2c2e4ef01 --- /dev/null +++ b/src/core/mxc_policy_store/tests/catalog_validation.rs @@ -0,0 +1,197 @@ +// 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 it supports; +//! - every entry has a bundled-catalog conformance case. +//! +//! The JSON Schemas in `schema/` document the same shape for editors. The +//! semantic validation exercised here is a superset of them. + +mod common; + +use common::{crate_dir, read_json}; +use mxc_policy_store::tooling::{ + select_variant, validate_bundled_catalog, IdentityPredicate, Json, +}; +use mxc_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_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_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 { + if select_variant(entry, platform, architecture).is_none() { + continue; + } + 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_sandbox_policy_with_diagnostics(candidate.clone(), &ctx); + let second = + catalog.resolve_sandbox_policy_with_diagnostics(candidate.clone(), &ctx); + match (first, second) { + (Ok(first), Ok(second)) => { + if first.policy.is_none() { + failures.push(format!( + "{label} produced no policy: {:?}", + 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] + .matches + .iter() + .any(|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 tools = case + .get("expect") + .and_then(|e| e.get("diagnostics")) + .and_then(|d| d.get("tools")) + .and_then(Json::as_array); + for tool in tools.into_iter().flatten() { + for m in tool + .get("matches") + .and_then(Json::as_array) + .into_iter() + .flatten() + { + 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:?}" + ); +} diff --git a/policy-catalog/rust/tests/common/mod.rs b/src/core/mxc_policy_store/tests/common/mod.rs similarity index 73% rename from policy-catalog/rust/tests/common/mod.rs rename to src/core/mxc_policy_store/tests/common/mod.rs index e11beb112..3f0301ddb 100644 --- a/policy-catalog/rust/tests/common/mod.rs +++ b/src/core/mxc_policy_store/tests/common/mod.rs @@ -1,34 +1,38 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Shared helpers for the integration tests. Tests read the repository's -//! `../conformance` and `../catalog` through `CARGO_MANIFEST_DIR`; they do -//! not ship in the `.crate`. +//! Shared helpers for the integration tests. Tests read the crate's +//! `conformance/` and `catalog/` through `CARGO_MANIFEST_DIR`. #![allow(dead_code)] -use mxc_policy_catalog::tooling::{Json, JsonObject}; -use mxc_policy_catalog::{ - Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, PolicyCatalogError, - ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs, +use mxc_policy_store::tooling::{Json, JsonObject}; +use mxc_policy_store::{ + Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, + PolicyCatalogError, ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs, }; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -pub fn repo_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +pub fn crate_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) } pub fn read_json(path: PathBuf) -> Json { - let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + 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_policy_catalog::tooling::bundled_catalog_files(); - let text = files.iter().find(|(name, _)| *name == "contract.v1.json").unwrap().1; + let files = mxc_policy_store::tooling::bundled_catalog_files(); + let text = files + .iter() + .find(|(name, _)| *name == "contract.v1.json") + .unwrap() + .1; Json::parse(text).unwrap() } @@ -62,7 +66,7 @@ pub fn catalog_for(revision: Json, host: Arc) -> PolicyCatalog { } pub fn bundled_catalog(host: Arc) -> PolicyCatalog { - PolicyCatalog::with_host(mxc_policy_catalog::bundled_catalog_store().unwrap(), host) + PolicyCatalog::with_host(mxc_policy_store::bundled_catalog_store().unwrap(), host) } pub fn j(text: &str) -> Json { @@ -103,7 +107,9 @@ pub fn entry(entry_id: &str, overrides: &str) -> Json { 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()))); + assert!(error + .message() + .starts_with(&format!("[{}] ", error.code().as_str()))); error.reason() } @@ -111,9 +117,19 @@ fn candidate(value: &Json) -> ToolInput { match value { Json::String(s) => ToolInput::Name(s.clone()), Json::Object(o) => ToolInput::Candidate(ToolCandidate { - invocation_name: o.get("invocationName").and_then(Json::as_str).unwrap().to_string(), - package_url: o.get("packageUrl").and_then(Json::as_str).map(str::to_string), - detected_version: o.get("detectedVersion").and_then(Json::as_str).map(str::to_string), + invocation_name: o + .get("invocationName") + .and_then(Json::as_str) + .unwrap() + .to_string(), + package_url: o + .get("packageUrl") + .and_then(Json::as_str) + .map(str::to_string), + detected_version: o + .get("detectedVersion") + .and_then(Json::as_str) + .map(str::to_string), }), other => panic!("unsupported tool input {other}"), } @@ -136,7 +152,10 @@ pub fn context_from(value: Option<&Json>) -> ResolveContext { ctx.platform = s("platform"); ctx.architecture = s("architecture"); ctx.catalog_revision = s("catalogRevision"); - ctx.allow_weak_identity_fallback = value.get("allowWeakIdentityFallback").and_then(Json::as_bool) == Some(true); + ctx.allow_weak_identity_fallback = value + .get("allowWeakIdentityFallback") + .and_then(Json::as_bool) + == Some(true); if let Some(symbols) = value.get("symbols").and_then(Json::as_object) { let mut map = SymbolMap::new(); for (k, v) in symbols.iter() { diff --git a/policy-catalog/rust/tests/conformance.rs b/src/core/mxc_policy_store/tests/conformance.rs similarity index 72% rename from policy-catalog/rust/tests/conformance.rs rename to src/core/mxc_policy_store/tests/conformance.rs index 00c50bb40..ef75641b1 100644 --- a/policy-catalog/rust/tests/conformance.rs +++ b/src/core/mxc_policy_store/tests/conformance.rs @@ -8,18 +8,21 @@ mod common; use common::*; -use mxc_policy_catalog::tooling::{Json, JsonObject}; -use mxc_policy_catalog::{Architecture, Platform, PolicyCatalogError}; +use mxc_policy_store::tooling::{Json, JsonObject}; +use mxc_policy_store::{Architecture, Platform, PolicyCatalogError}; fn failure(result: Result) -> Option<(String, String)> { - result - .err() - .map(|e| (e.code().as_str().to_string(), e.reason().as_str().to_string())) + result.err().map(|e| { + ( + e.code().as_str().to_string(), + e.reason().as_str().to_string(), + ) + }) } #[test] fn all_conformance_fixtures() { - let dir = repo_dir().join("conformance").join("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()) @@ -31,7 +34,10 @@ fn all_conformance_fixtures() { for name in names { let fixture = read_json(dir.join(&name)); 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()); + let case_name = format!( + "{name}: {}", + case.get("name").and_then(Json::as_str).unwrap() + ); let host_platform = case .get("host") .and_then(|h| h.get("platform")) @@ -53,8 +59,16 @@ fn all_conformance_fixtures() { let ctx = context_from(case.get("context")); 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(), + 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_sandbox_policy_with_diagnostics(tools.clone(), &ctx)), @@ -81,13 +95,23 @@ fn all_conformance_fixtures() { let actual = catalog .resolve_sandbox_policy_with_diagnostics(tools.clone(), &ctx) .unwrap(); - assert_eq!(actual.to_json(), expected, "{case_name}\nactual: {}", actual.to_json()); + assert_eq!( + actual.to_json(), + expected, + "{case_name}\nactual: {}", + actual.to_json() + ); let policy = catalog.resolve_sandbox_policy(tools.clone(), &ctx).unwrap(); assert_eq!(policy.map(|p| p.to_json()), expected_policy, "{case_name}"); if !matches!(raw_tools, Json::Array(_)) { let as_array = tools_from(&Json::Array(vec![raw_tools.clone()])); - let again = catalog.resolve_sandbox_policy_with_diagnostics(as_array, &ctx).unwrap(); - assert_eq!(again, actual, "{case_name}: single input == one-element array"); + let again = catalog + .resolve_sandbox_policy_with_diagnostics(as_array, &ctx) + .unwrap(); + assert_eq!( + again, actual, + "{case_name}: single input == one-element array" + ); } cases += 1; } diff --git a/policy-catalog/rust/tests/library.rs b/src/core/mxc_policy_store/tests/library.rs similarity index 80% rename from policy-catalog/rust/tests/library.rs rename to src/core/mxc_policy_store/tests/library.rs index 91808ef11..4d3ce789e 100644 --- a/policy-catalog/rust/tests/library.rs +++ b/src/core/mxc_policy_store/tests/library.rs @@ -7,14 +7,16 @@ mod common; use common::*; -use mxc_policy_catalog::tooling::{ - canonical_json, canonical_sha256, check_entry_revisions, check_published_immutability, check_store_history, - validate_catalog_revision, validate_contract, Json, PublishedRevision, PublishedState, +use mxc_policy_store::tooling::{ + canonical_json, canonical_sha256, check_entry_revisions, check_published_immutability, + check_store_history, validate_catalog_revision, validate_contract, Json, PublishedRevision, + PublishedState, }; -use mxc_policy_catalog::{ - get_catalog_info, list_catalog_entries, resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, - Architecture, ErrorReason, FixedHost, HostEnvironment, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, - ToolCandidate, ToolInput, ToolInputs, +use mxc_policy_store::{ + get_catalog_info, list_catalog_entries, resolve_sandbox_policy, + resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, FixedHost, HostEnvironment, + Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, ToolCandidate, ToolInput, + ToolInputs, }; use std::collections::HashMap; use std::sync::Arc; @@ -53,8 +55,14 @@ fn variants(list: &[String]) -> String { format!(r#"{{"platformVariants":[{}]}}"#, list.join(",")) } -fn fs_paths(policy: &Option) -> Json { - policy.as_ref().unwrap().filesystem.as_ref().unwrap().to_json() +fn fs_paths(policy: &Option) -> Json { + policy + .as_ref() + .unwrap() + .filesystem + .as_ref() + .unwrap() + .to_json() } // --------------------------------------------------------------------------- @@ -80,7 +88,9 @@ fn arch_catalog(host: Arc) -> PolicyCatalog { fn omitted_context_uses_host_platform_native_arch_default_revision_no_weak() { let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); assert_eq!( - catalog.resolve_sandbox_policy("a", &ResolveContext::new()).unwrap(), + catalog + .resolve_sandbox_policy("a", &ResolveContext::new()) + .unwrap(), None ); let result = catalog @@ -135,7 +145,10 @@ impl HostEnvironment for FailingHost { Ok(Platform::Linux) } fn native_architecture(&self) -> Result { - Err(PolicyCatalogError::new(ErrorReason::UnsupportedHost, "unknown machine")) + Err(PolicyCatalogError::new( + ErrorReason::UnsupportedHost, + "unknown machine", + )) } fn symbol(&self, _: &str) -> Option { None @@ -151,9 +164,15 @@ fn host_detection_failure_is_an_error_and_only_attempted_when_needed() { .unwrap_err(); assert!(error.message().contains("unknown machine")); assert_eq!(error.reason(), ErrorReason::UnsupportedHost); - assert_eq!(catalog.resolve_sandbox_policy("nothing", &weak()).unwrap(), None); + assert_eq!( + catalog.resolve_sandbox_policy("nothing", &weak()).unwrap(), + None + ); let policy = catalog - .resolve_sandbox_policy("t", &weak().architecture(Architecture::X64).project_root("/p")) + .resolve_sandbox_policy( + "t", + &weak().architecture(Architecture::X64).project_root("/p"), + ) .unwrap(); assert_eq!(fs_paths(&policy), j(r#"{"readwritePaths":["/p"]}"#)); } @@ -180,7 +199,9 @@ fn host_symbols_only_for_current_host_platform_and_caller_overrides() { {{"when":{{"platform":"macos"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{user_home}}/.cfg"]}}}}}}]}}"# ), )]); - let host = Arc::new(FixedHost::new(Platform::Linux, Architecture::X64).with_symbol("user_home", "/home/me")); + let host = Arc::new( + FixedHost::new(Platform::Linux, Architecture::X64).with_symbol("user_home", "/home/me"), + ); let catalog = catalog_for(rev, host); assert_eq!( fs_paths(&catalog.resolve_sandbox_policy("t", &weak()).unwrap()), @@ -188,7 +209,12 @@ fn host_symbols_only_for_current_host_platform_and_caller_overrides() { ); assert_eq!( catalog - .resolve_sandbox_policy("t", &weak().platform(Platform::Macos).architecture(Architecture::Arm64)) + .resolve_sandbox_policy( + "t", + &weak() + .platform(Platform::Macos) + .architecture(Architecture::Arm64) + ) .unwrap(), None ); @@ -213,7 +239,9 @@ fn string_object_and_one_element_array_are_equivalent() { .architecture(Architecture::X64) .project_root("/p") .symbol("git_prefix", "/g"); - let a = catalog.resolve_sandbox_policy_with_diagnostics("git", &ctx).unwrap(); + let a = catalog + .resolve_sandbox_policy_with_diagnostics("git", &ctx) + .unwrap(); assert_eq!( catalog .resolve_sandbox_policy_with_diagnostics(ToolCandidate::new("git"), &ctx) @@ -231,7 +259,10 @@ fn string_object_and_one_element_array_are_equivalent() { .architecture(Architecture::X64) .project_root("/p") .symbol("git_prefix", "/g"); - assert_eq!(catalog.resolve_sandbox_policy("git", &strict).unwrap(), None); + assert_eq!( + catalog.resolve_sandbox_policy("git", &strict).unwrap(), + None + ); assert_eq!( catalog .resolve_sandbox_policy(ToolCandidate::new("git"), &strict) @@ -273,8 +304,14 @@ fn invalid_context_and_inputs_are_failures_not_absence() { }, ), ("git".into(), ResolveContext::new().symbol("nope", "/x")), - ("git".into(), ResolveContext::new().symbol("project_root", "/x")), - ("git".into(), ResolveContext::new().symbol("__proto__", "/x")), + ( + "git".into(), + ResolveContext::new().symbol("project_root", "/x"), + ), + ( + "git".into(), + ResolveContext::new().symbol("__proto__", "/x"), + ), ("git".into(), ResolveContext::new().project_root("")), ]; for (tools, ctx) in cases { @@ -286,7 +323,9 @@ fn invalid_context_and_inputs_are_failures_not_absence() { } // A relative or `${`-containing symbol value is rejected once it is needed. for value in ["bin", "/x/${node_prefix}"] { - let ctx = weak().architecture(Architecture::X64).symbol("node_prefix", value); + let ctx = weak() + .architecture(Architecture::X64) + .symbol("node_prefix", value); let error = catalog.resolve_sandbox_policy("node", &ctx).unwrap_err(); assert_eq!( error.message(), @@ -320,7 +359,9 @@ fn results_are_caller_owned_and_deterministic() { .unwrap() .push("/mutated".into()); first.diagnostics.warnings.push("mutated".into()); - let second = catalog.resolve_sandbox_policy_with_diagnostics(tool, &ctx).unwrap(); + let second = catalog + .resolve_sandbox_policy_with_diagnostics(tool, &ctx) + .unwrap(); assert_eq!( fs_paths(&second.policy), j(r#"{"readonlyPaths":["/n"],"readwritePaths":["/p","/c"]}"#) @@ -357,13 +398,17 @@ fn dedupes_normalized_paths_with_platform_casing() { ) }; let linux = catalog_for(revision(vec![tmpl("linux", "tool:l")]), linux_x64()); - let ctx = weak().symbol("git_prefix", "/Tools").symbol("node_prefix", "/tools"); + let ctx = weak() + .symbol("git_prefix", "/Tools") + .symbol("node_prefix", "/tools"); assert_eq!( fs_paths(&linux.resolve_sandbox_policy("l", &ctx).unwrap()), j(r#"{"readonlyPaths":["/Tools","/tools"]}"#) ); let mac = catalog_for(revision(vec![tmpl("macos", "tool:m")]), linux_x64()); - let ctx = ctx.platform(Platform::Macos).architecture(Architecture::Arm64); + let ctx = ctx + .platform(Platform::Macos) + .architecture(Architecture::Arm64); assert_eq!( fs_paths(&mac.resolve_sandbox_policy("m", &ctx).unwrap()), j(r#"{"readonlyPaths":["/Tools"]}"#) @@ -388,7 +433,9 @@ fn overlap_detection_follows_platform_casing() { .project_root("/tools/work") .symbol("git_prefix", "/Tools"); assert_eq!( - reason_of(make("macos").resolve_sandbox_policy("m", &ctx.clone().platform(Platform::Macos))), + reason_of( + make("macos").resolve_sandbox_policy("m", &ctx.clone().platform(Platform::Macos)) + ), ErrorReason::CompositionConflict ); assert_eq!( @@ -417,7 +464,10 @@ fn invocation_name_casing_per_platform() { ); let on = |platform: Platform, name: &str| -> Vec { catalog - .resolve_sandbox_policy_with_diagnostics(name, &weak().platform(platform).architecture(Architecture::X64)) + .resolve_sandbox_policy_with_diagnostics( + name, + &weak().platform(platform).architecture(Architecture::X64), + ) .unwrap() .diagnostics .tools[0] @@ -493,7 +543,10 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { r#""dependencies":[{"entryId":"tool:c"}]"#, )]), ), - entry("tool:c", &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")])), + entry( + "tool:c", + &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")]), + ), ]), linux_x64(), ); @@ -511,7 +564,10 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { r#"[{"entryId":"tool:c","entryRevision":1},{"entryId":"tool:c","entryRevision":1,"requiredVersionRange":">=2"}]"# ) ); - assert_eq!(result.policy.unwrap().to_json(), j(&format!(r#"{{"version":"{V}"}}"#))); + assert_eq!( + result.policy.unwrap().to_json(), + j(&format!(r#"{{"version":"{V}"}}"#)) + ); } #[test] @@ -525,32 +581,53 @@ fn catalog_file_order_does_not_matter() { ) }; let ctx = weak().project_root("/r"); - let forward = catalog_for(revision(vec![e("tool:b", "b"), e("tool:a", "a")]), linux_x64()) - .resolve_sandbox_policy_with_diagnostics("x", &ctx) - .unwrap(); - let backward = catalog_for(revision(vec![e("tool:a", "a"), e("tool:b", "b")]), linux_x64()) - .resolve_sandbox_policy_with_diagnostics("x", &ctx) - .unwrap(); + let forward = catalog_for( + revision(vec![e("tool:b", "b"), e("tool:a", "a")]), + linux_x64(), + ) + .resolve_sandbox_policy_with_diagnostics("x", &ctx) + .unwrap(); + let backward = catalog_for( + revision(vec![e("tool:a", "a"), e("tool:b", "b")]), + linux_x64(), + ) + .resolve_sandbox_policy_with_diagnostics("x", &ctx) + .unwrap(); assert_eq!(forward, backward); - assert_eq!(fs_paths(&forward.policy), j(r#"{"readonlyPaths":["/r/a","/r/b"]}"#)); + assert_eq!( + fs_paths(&forward.policy), + j(r#"{"readonlyPaths":["/r/a","/r/b"]}"#) + ); } #[test] fn mixed_versions_across_inputs_conflict() { let catalog = catalog_for( revision(vec![ - entry("tool:a", &variants(&[linux(r#"{"version":"0.8.0-alpha"}"#, "")])), - entry("tool:b", &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")])), + entry( + "tool:a", + &variants(&[linux(r#"{"version":"0.8.0-alpha"}"#, "")]), + ), + entry( + "tool:b", + &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")]), + ), ]), linux_x64(), ); - let error = catalog.resolve_sandbox_policy(vec!["a", "b"], &weak()).unwrap_err(); + let error = catalog + .resolve_sandbox_policy(vec!["a", "b"], &weak()) + .unwrap_err(); assert_eq!( error.message(), "[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)" ); assert_eq!( - catalog.resolve_sandbox_policy("a", &weak()).unwrap().unwrap().to_json(), + catalog + .resolve_sandbox_policy("a", &weak()) + .unwrap() + .unwrap() + .to_json(), j(r#"{"version":"0.8.0-alpha"}"#) ); } @@ -569,7 +646,11 @@ fn inspection_over_the_bundled_catalog() { 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"]); - let npm = entries.iter().find(|e| e.entry_id == "tool:npm").unwrap().to_json(); + let npm = entries + .iter() + .find(|e| e.entry_id == "tool:npm") + .unwrap() + .to_json(); assert_eq!( npm.get("platformVariants").unwrap().as_array().unwrap()[0], j(&format!( @@ -582,17 +663,28 @@ fn inspection_over_the_bundled_catalog() { #[test] fn module_level_functions_use_the_bundled_catalog() { - let ctx = weak().platform(Platform::Linux).architecture(Architecture::X64); - assert_eq!(resolve_sandbox_policy("definitely-unknown-tool", &ctx).unwrap(), None); - let result = - resolve_sandbox_policy_with_diagnostics(vec![ToolInput::from("definitely-unknown-tool")], &ctx).unwrap(); + let ctx = weak() + .platform(Platform::Linux) + .architecture(Architecture::X64); + assert_eq!( + resolve_sandbox_policy("definitely-unknown-tool", &ctx).unwrap(), + None + ); + let result = resolve_sandbox_policy_with_diagnostics( + vec![ToolInput::from("definitely-unknown-tool")], + &ctx, + ) + .unwrap(); assert_eq!(result.policy, None); assert_eq!( result.diagnostics.to_json().get("tools").unwrap(), &j(r#"[{"inputIndex":0,"matches":[]}]"#) ); assert_eq!( - reason_of(resolve_sandbox_policy("git", &ctx.catalog_revision("1999-01-01.1"))), + reason_of(resolve_sandbox_policy( + "git", + &ctx.catalog_revision("1999-01-01.1") + )), ErrorReason::RevisionUnavailable ); } @@ -660,7 +752,11 @@ fn validation_selectors() { vec![entry("tool:a", &variants(&[v(""), v("")]))], "second architecture-neutral", ); - validate(vec![entry("tool:a", &variants(&[v(""), v("x64"), v("arm64")]))]).unwrap(); + 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", @@ -678,7 +774,10 @@ fn validation_policy_rules() { p(&format!(r#"{{"version":"{V}","containment":"lxc"}}"#)), "containment backend", ); - validate_err(p(r#"{"version":"0.9.0"}"#), "not a SandboxPolicy version registered"); + validate_err( + p(r#"{"version":"0.9.0"}"#), + "not a SandboxPolicy version registered", + ); validate_err( p(r#"{"version":"0.10.0-alpha"}"#), "not a SandboxPolicy version registered", @@ -694,7 +793,9 @@ fn validation_policy_rules() { "unsupported field", ); validate_err( - p(&format!(r#"{{"version":"{V}","network":{{"allowOutbound":true}}}}"#)), + p(&format!( + r#"{{"version":"{V}","network":{{"allowOutbound":true}}}}"# + )), "unsupported field", ); validate_err( @@ -732,7 +833,9 @@ fn validation_policy_rules() { "must be an integer in 1..65535", ); validate_err( - net(r#"{"egress":{"allow":[{"to":[{"cidr":"10.0.0.0/8"}],"ports":[{"port":10,"endPort":5}]}]}}"#), + 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(); @@ -751,12 +854,21 @@ fn validation_policy_rules() { fn validation_identity_rules() { validate(vec![ entry("tool:a", ""), - entry("tool:b", r#"{"identity":[{"kind":"invocation-name","names":["A"]}]}"#), + entry( + "tool:b", + r#"{"identity":[{"kind":"invocation-name","names":["A"]}]}"#, + ), ]) .unwrap(); validate(vec![ - entry("tool:a", r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"}]}"#), - entry("tool:b", r#"{"identity":[{"kind":"purl","value":"pkg:NPM/x"}]}"#), + entry( + "tool:a", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"}]}"#, + ), + entry( + "tool:b", + r#"{"identity":[{"kind":"purl","value":"pkg:NPM/x"}]}"#, + ), ]) .unwrap(); validate_err( @@ -795,13 +907,19 @@ fn validation_identity_rules() { "bare invocation name", ); validate_err( - vec![entry("tool:a", r#"{"identity":[{"kind":"sha256","value":"x"}]}"#)], + vec![entry( + "tool:a", + r#"{"identity":[{"kind":"sha256","value":"x"}]}"#, + )], "not a supported identity kind", ); validate_err( vec![ entry("tool:a", ""), - entry("tool:a", r#"{"identity":[{"kind":"invocation-name","names":["z"]}]}"#), + entry( + "tool:a", + r#"{"identity":[{"kind":"invocation-name","names":["z"]}]}"#, + ), ], "duplicate entryId", ); @@ -833,13 +951,20 @@ fn validation_composition_vocabulary() { }; err( with_dep( - &format!(r#"{{"version":"{V}","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}"#), - &format!(r#"{{"version":"{V}","filesystem":{{"deniedPaths":["${{project_root}}/secrets"]}}}}"#), + &format!( + r#"{{"version":"{V}","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}"# + ), + &format!( + r#"{{"version":"{V}","filesystem":{{"deniedPaths":["${{project_root}}/secrets"]}}}}"# + ), ), "'${project_root}/secrets' (deniedPaths) overlaps '${project_root}' (readwritePaths)", ); err( - with_dep(&format!(r#"{{"version":"{V}"}}"#), r#"{"version":"0.8.0-alpha"}"#), + with_dep( + &format!(r#"{{"version":"{V}"}}"#), + r#"{"version":"0.8.0-alpha"}"#, + ), "mixed sandboxPolicy.version", ); err( @@ -864,9 +989,12 @@ fn validation_composition_vocabulary() { #[test] fn bundled_store_verifies_and_canonical_digest_ignores_formatting() { - let store = mxc_policy_catalog::bundled_catalog_store().unwrap(); + let store = mxc_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!( + 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}"# @@ -875,13 +1003,17 @@ fn bundled_store_verifies_and_canonical_digest_ignores_formatting() { canonical_sha256(&j(r#"{"a":1,"b":2}"#)), canonical_sha256(&j(r#"{"b":2,"a":1}"#)) ); - assert_ne!(canonical_sha256(&j(r#"{"a":1}"#)), canonical_sha256(&j(r#"{"a":2}"#))); + assert_ne!( + canonical_sha256(&j(r#"{"a":1}"#)), + canonical_sha256(&j(r#"{"a":2}"#)) + ); } #[test] fn tampered_revision_is_an_integrity_error_everywhere() { let digests = HashMap::from([("2000-01-01.1".to_string(), "0".repeat(64))]); - let store = Arc::new(store_with(&[revision(vec![entry("tool:a", "")])], None, &digests).unwrap()); + let store = + Arc::new(store_with(&[revision(vec![entry("tool:a", "")])], None, &digests).unwrap()); assert_eq!(reason_of(store.revision(None)), ErrorReason::Integrity); let catalog = PolicyCatalog::with_host(store, linux_x64()); assert_eq!( @@ -892,8 +1024,14 @@ fn tampered_revision_is_an_integrity_error_everywhere() { reason_of(catalog.resolve_sandbox_policy_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); + assert_eq!( + reason_of(catalog.list_catalog_entries()), + ErrorReason::Integrity + ); + assert_eq!( + reason_of(catalog.get_catalog_info()), + ErrorReason::Integrity + ); } #[test] @@ -905,16 +1043,20 @@ fn revision_id_mismatch_and_invalid_manifest() { } let digests = HashMap::from([("2000-01-01.1".to_string(), canonical_sha256(&other))]); assert_eq!( - reason_of(store_with(&[relabeled.clone()], None, &digests).unwrap().revision(None)), + reason_of( + store_with(&[relabeled.clone()], None, &digests) + .unwrap() + .revision(None) + ), ErrorReason::Integrity ); // Correct digest but wrong declared id → integrity (revision-id check). let digests = HashMap::from([("2000-01-01.1".to_string(), canonical_sha256(&other))]); - let source = mxc_policy_catalog::MemorySource { + let source = mxc_policy_store::MemorySource { files: HashMap::from([("revisions/2000-01-01.1.json".to_string(), other.clone())]), - ..mxc_policy_catalog::MemorySource::publishing(contract(), &[relabeled], None, &digests) + ..mxc_policy_store::MemorySource::publishing(contract(), &[relabeled], None, &digests) }; - let error = mxc_policy_catalog::CatalogStore::new(source) + let error = mxc_policy_store::CatalogStore::new(source) .unwrap() .revision(None) .unwrap_err(); @@ -942,7 +1084,10 @@ fn revision_id_mismatch_and_invalid_manifest() { #[test] fn explicit_revisions_are_never_substituted() { - let r1 = revision_with(vec![entry("tool:a", ""), entry("tool:b", "")], "2000-01-01.1"); + 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"}"#), @@ -953,7 +1098,9 @@ fn explicit_revisions_are_never_substituted() { 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_sandbox_policy_with_diagnostics("a", ctx).unwrap(); + let r = catalog + .resolve_sandbox_policy_with_diagnostics("a", ctx) + .unwrap(); ( r.diagnostics.catalog_revision.clone(), r.diagnostics.tools[0].matches[0].entry_revision, @@ -982,11 +1129,17 @@ fn entry_revision_history() { "2000-01-01.1", )); let changed = v(revision_with( - vec![entry("tool:a", r#"{"displayName":"x"}"#), entry("tool:b", "")], + 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}"#)], + vec![ + entry("tool:a", ""), + entry("tool:b", r#"{"entryRevision":2}"#), + ], "2000-01-02.1", )); let ok = v(revision_with( @@ -1010,8 +1163,15 @@ fn entry_revision_history() { #[test] fn published_immutability() { - let published = PublishedRevision::new("2000-01-01.1", "revisions/2000-01-01.1.json", &"a".repeat(64)); - let files = HashMap::from([("revisions/2000-01-01.1.json".to_string(), "{\"x\":1}\n".to_string())]); + let published = PublishedRevision::new( + "2000-01-01.1", + "revisions/2000-01-01.1.json", + &"a".repeat(64), + ); + 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(), @@ -1019,14 +1179,24 @@ fn published_immutability() { let appended = PublishedState { revisions: vec![ published.clone(), - PublishedRevision::new("2000-01-02.1", "revisions/2000-01-02.1.json", &"b".repeat(64)), + PublishedRevision::new( + "2000-01-02.1", + "revisions/2000-01-02.1.json", + &"b".repeat(64), + ), ], - files: HashMap::from([("revisions/2000-01-01.1.json".to_string(), "{\r\n \"x\": 1}".to_string())]), + 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())]), + files: HashMap::from([( + "revisions/2000-01-01.1.json".to_string(), + "{\"x\":2}\n".to_string(), + )]), }; assert_eq!( check_published_immutability(&base, &edited), diff --git a/policy-catalog/rust/tests/vectors.rs b/src/core/mxc_policy_store/tests/vectors.rs similarity index 81% rename from policy-catalog/rust/tests/vectors.rs rename to src/core/mxc_policy_store/tests/vectors.rs index e14754407..a87ebca7e 100644 --- a/policy-catalog/rust/tests/vectors.rs +++ b/src/core/mxc_policy_store/tests/vectors.rs @@ -7,15 +7,16 @@ mod common; use common::*; -use mxc_policy_catalog::tooling::{ - canonical_json, canonical_sha256, is_absolute_path, normalize_path, parse_purl, path_key_segments, Json, ParsedPurl, +use mxc_policy_store::tooling::{ + canonical_json, canonical_sha256, is_absolute_path, normalize_path, parse_purl, + path_key_segments, Json, ParsedPurl, }; -use mxc_policy_catalog::Platform; +use mxc_policy_store::Platform; #[test] fn canonical_json_vectors() { let vectors = read_json( - repo_dir() + crate_dir() .join("conformance") .join("vectors") .join("canonical-json.json"), @@ -40,9 +41,17 @@ fn canonical_json_vectors() { #[test] fn path_vectors() { - let vectors = read_json(repo_dir().join("conformance").join("vectors").join("paths.json")); + 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(); + 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(); @@ -86,16 +95,12 @@ fn malformed_percent_encoding_is_an_invalid_purl() { ); } -/// The embedded catalog must equal the repository's `../catalog` (canonical -/// content), so a stale crate-local copy can never ship unnoticed. +/// 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 = repo_dir().join("catalog"); - if !dir.join("manifest.json").is_file() { - eprintln!("skipped: ../catalog not present (packaged crate)"); - return; - } - let embedded = mxc_policy_catalog::tooling::bundled_catalog_files(); + let dir = crate_dir().join("catalog"); + let embedded = mxc_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() From ccb005b5c49b6718e95554850ffaeea86ea47d2c Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 17:02:40 -0700 Subject: [PATCH 20/50] feat(mxc-sdk): add the prototype policy_store module PROTOTYPE, pending API review. mxc_sdk::policy_store exposes resolve_sandbox_policy and resolve_sandbox_policy_with_diagnostics returning the SDK's own SandboxPolicy, plus get_catalog_info and list_catalog_entries over the bundled catalog. The store's catalog-shaped policy converts to mxc_sdk::SandboxPolicy through its camelCase JSON; a value the SDK type cannot hold (a timeoutMs above u32) is a policy_validation error rather than a silent truncation. mxc_policy_store gains request.rs, the strict JSON request envelope for bindings, and its fixture tests now parse through it. A hidden policy_store::binding module produces the JSON the FFI layer returns, and is checked against the bundled conformance fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Cargo.lock | 1 + src/core/mxc-sdk/Cargo.toml | 2 + src/core/mxc-sdk/src/lib.rs | 2 + src/core/mxc-sdk/src/policy_store.rs | 199 ++++++++++++++++++ src/core/mxc-sdk/tests/policy_store.rs | 154 ++++++++++++++ src/core/mxc_policy_store/src/lib.rs | 1 + src/core/mxc_policy_store/src/request.rs | 199 ++++++++++++++++++ src/core/mxc_policy_store/tests/common/mod.rs | 53 +---- 8 files changed, 563 insertions(+), 48 deletions(-) create mode 100644 src/core/mxc-sdk/src/policy_store.rs create mode 100644 src/core/mxc-sdk/tests/policy_store.rs create mode 100644 src/core/mxc_policy_store/src/request.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index cd8ff6abd..d5c502f92 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1630,6 +1630,7 @@ dependencies = [ "bwrap_common", "libc", "mxc_engine", + "mxc_policy_store", "serde_json", "wxc_common", ] diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index de7bd7559..0ff0a06d6 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -22,6 +22,8 @@ test-support = ["wxc_common/test-support"] [dependencies] wxc_common.workspace = true mxc_engine = { workspace = true } +mxc_policy_store.workspace = true +serde_json.workspace = true [dev-dependencies] serde_json = { workspace = true } diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 832b55deb..3cb69a953 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -155,6 +155,8 @@ //! `mxc-sdk` re-exports the curated surface and wraps the engine's streaming //! handle in [`Sandbox`]. +pub mod policy_store; + pub mod sandbox; pub mod telemetry; diff --git a/src/core/mxc-sdk/src/policy_store.rs b/src/core/mxc-sdk/src/policy_store.rs new file mode 100644 index 000000000..e7283cdcf --- /dev/null +++ b/src/core/mxc-sdk/src/policy_store.rs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! **PROTOTYPE, pending API review.** The MXC policy store: resolve known +//! tools to a candidate floor [`SandboxPolicy`] from the policy catalog +//! bundled statically in this SDK. +//! +//! The result is a **best-effort floor**, not a guarantee: the access a known +//! tool typically needs, which a caller composes with its own policy. It is +//! complementary to Learning Mode, not a replacement. Resolution never grants +//! access, launches a sandbox, contacts a network service, or writes state; +//! the V1 catalog is compiled in and nothing is downloaded. +//! +//! The names and shapes here are proposed and may change before sign-off (for +//! example, the names may drop "Sandbox", and lookup may gain an intent such +//! as `git pull` versus `git push`). This API is not part of MXC 1.0. +//! +//! ```no_run +//! use mxc_sdk::policy_store::{resolve_sandbox_policy, ResolveContext}; +//! +//! let ctx = ResolveContext::new() +//! .project_root("/work/repo") +//! .symbol("git_prefix", "/usr/bin"); +//! if let Some(policy) = resolve_sandbox_policy("git", &ctx)? { +//! // Compose `policy` with the caller's own policy, then build a request. +//! let _ = policy; +//! } +//! # Ok::<(), mxc_sdk::policy_store::PolicyCatalogError>(()) +//! ``` + +use crate::SandboxPolicy; +use mxc_policy_store::tooling::Json; + +pub use mxc_policy_store::{ + Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, + Diagnostics, EntryMatchRecord, ErrorCode, ErrorReason, IdentityStrength, MatchedIdentity, + Platform, PlatformVariantMetadata, PolicyCatalogError, Provenance, ResolveContext, SymbolMap, + ToolCandidate, ToolInput, ToolInputs, ToolRecord, +}; + +/// Result of [`resolve_sandbox_policy_with_diagnostics`]: the same policy +/// [`resolve_sandbox_policy`] returns, plus match attribution and warnings +/// from the same resolution pass. +#[derive(Debug, Clone)] +pub struct SandboxConfigResolution { + /// The composed policy, or `None` when nothing could be resolved. + pub policy: Option, + /// Which entries matched, the dependencies they pulled in, and warnings. + pub diagnostics: Diagnostics, +} + +/// Resolves one tool or a list of tools to a single composed floor policy +/// from the bundled catalog. Returns `None` when no policy can be resolved. +pub fn resolve_sandbox_policy( + tools: impl Into, + context: &ResolveContext, +) -> Result, PolicyCatalogError> { + mxc_policy_store::resolve_sandbox_policy(tools, context)? + .map(|policy| to_sdk_policy(&policy)) + .transpose() +} + +/// Like [`resolve_sandbox_policy`], and also reports which catalog entries +/// matched each input, the dependencies they pulled in, and warnings. +pub fn resolve_sandbox_policy_with_diagnostics( + tools: impl Into, + context: &ResolveContext, +) -> Result { + let resolution = mxc_policy_store::resolve_sandbox_policy_with_diagnostics(tools, context)?; + Ok(SandboxConfigResolution { + policy: resolution.policy.as_ref().map(to_sdk_policy).transpose()?, + diagnostics: resolution.diagnostics, + }) +} + +/// The bundled catalog's schema version and default revision. +pub fn get_catalog_info() -> Result { + mxc_policy_store::get_catalog_info() +} + +/// Metadata for every entry in the bundled catalog's default revision. It +/// never exposes a policy body; use the resolve functions for policies. +pub fn list_catalog_entries() -> Result, PolicyCatalogError> { + mxc_policy_store::list_catalog_entries() +} + +/// Converts the store's catalog-shaped policy to the SDK [`SandboxPolicy`]. +/// The store emits the same camelCase JSON shape the SDK accepts, so this is +/// a lossless round trip for every field the catalog contract allows. +fn to_sdk_policy( + policy: &mxc_policy_store::SandboxPolicy, +) -> Result { + policy_from_json(&policy.to_json()) +} + +fn policy_from_json(json: &Json) -> Result { + serde_json::from_str(&json.to_compact_string()).map_err(|error| { + PolicyCatalogError::new( + ErrorReason::InvalidCatalog, + format!("the resolved policy is not a valid SDK SandboxPolicy: {error}"), + ) + }) +} + +/// JSON entry points for language bindings that reach the store through +/// `mxc_ffi`. Not a stable API; the C ABI and its bindings are co-versioned. +#[doc(hidden)] +pub mod binding { + use super::{policy_from_json, PolicyCatalogError}; + use mxc_policy_store::request::parse_resolve_request; + use mxc_policy_store::tooling::{Json, JsonObject}; + + /// `{"tools", "context"?}` → `{"policy"?}`. `policy` is omitted when + /// nothing resolves, so absence survives the boundary. + pub fn resolve_sandbox_policy_json(request: &str) -> Result { + let (tools, context) = parse_resolve_request(request)?; + let mut out = JsonObject::new(); + if let Some(policy) = mxc_policy_store::resolve_sandbox_policy(tools, &context)? { + let json = policy.to_json(); + policy_from_json(&json)?; + out.insert("policy", json); + } + Ok(Json::Object(out).to_compact_string()) + } + + /// `{"tools", "context"?}` → `{"policy"?, "diagnostics"}`. + pub fn resolve_sandbox_policy_with_diagnostics_json( + request: &str, + ) -> Result { + let (tools, context) = parse_resolve_request(request)?; + let resolution = + mxc_policy_store::resolve_sandbox_policy_with_diagnostics(tools, &context)?; + if let Some(policy) = &resolution.policy { + policy_from_json(&policy.to_json())?; + } + Ok(resolution.to_json().to_compact_string()) + } + + /// `{"catalogSchemaVersion", "catalogRevision"}`. + pub fn catalog_info_json() -> Result { + Ok(mxc_policy_store::get_catalog_info()? + .to_json() + .to_compact_string()) + } + + /// An array of entry metadata objects. + pub fn list_catalog_entries_json() -> Result { + let entries = mxc_policy_store::list_catalog_entries()?; + Ok(Json::Array(entries.iter().map(|e| e.to_json()).collect()).to_compact_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store_policy(extra: &str) -> mxc_policy_store::SandboxPolicy { + let raw = Json::parse(&format!(r#"{{"version":"0.9.0-alpha"{extra}}}"#)).unwrap(); + let get = |key: &str| raw.get(key).cloned(); + mxc_policy_store::SandboxPolicy { + version: "0.9.0-alpha".into(), + filesystem: None, + network: get("network"), + ui: get("ui"), + timeout_ms: get("timeoutMs").and_then(|t| t.as_f64()), + } + } + + /// Every non-filesystem field the catalog contract admits converts. + #[test] + fn catalog_contract_fields_convert_to_the_sdk_policy() { + let policy = to_sdk_policy(&store_policy( + r#","network":{"egress":{"default":"deny","allow":[{"to":[{"cidr":"10.0.0.0/8","except":["10.1.0.0/16"]}],"ports":[{"protocol":"tcp","port":443,"endPort":444}]}],"deny":[{"ports":[{"protocol":"any"}]}]},"ingress":{"default":"deny","hostLoopback":"allow"}},"ui":{"allowWindows":true,"clipboard":"read"},"timeoutMs":60000"#, + )) + .unwrap(); + let network = policy.network.unwrap(); + let egress = network.egress.unwrap(); + assert_eq!( + egress.allow.unwrap()[0].ports.as_ref().unwrap()[0].end_port, + Some(444) + ); + assert_eq!(egress.deny.unwrap().len(), 1); + assert_eq!( + network.ingress.unwrap().host_loopback, + Some(crate::NetworkAction::Allow) + ); + let ui = policy.ui.unwrap(); + assert!(ui.allow_windows); + assert!(!ui.allow_input_injection); + assert_eq!(policy.timeout_ms, Some(60000)); + } + + /// The catalog admits any positive integer timeout; the SDK holds `u32`. + #[test] + fn an_out_of_range_timeout_is_a_policy_validation_error() { + let error = to_sdk_policy(&store_policy(r#","timeoutMs":5000000000"#)).unwrap_err(); + assert_eq!(error.reason(), ErrorReason::InvalidCatalog); + } +} diff --git a/src/core/mxc-sdk/tests/policy_store.rs b/src/core/mxc-sdk/tests/policy_store.rs new file mode 100644 index 000000000..67c6e3f94 --- /dev/null +++ b/src/core/mxc-sdk/tests/policy_store.rs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The prototype policy store surface of `mxc-sdk`: typed SDK policies, and +//! the JSON binding path used by `mxc_ffi`, checked against the store's +//! bundled-catalog conformance fixtures. + +use mxc_sdk::policy_store::{ + binding, get_catalog_info, list_catalog_entries, resolve_sandbox_policy, + resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, Platform, ResolveContext, + ToolCandidate, +}; +use serde_json::Value; +use std::path::PathBuf; + +fn linux_context() -> ResolveContext { + ResolveContext::new() + .platform(Platform::Linux) + .architecture(Architecture::X64) + .project_root("/work/repo") + .symbol("git_prefix", "/usr/bin") +} + +#[test] +fn resolves_to_the_sdk_sandbox_policy_type() { + let ctx = linux_context() + .symbol("node_prefix", "/opt/node/bin") + .symbol("npm_prefix", "/opt/node/bin") + .symbol("npm_cache", "/home/u/.npm"); + let policy = resolve_sandbox_policy( + ToolCandidate::new("npm").with_package_url("pkg:npm/npm"), + &ctx, + ) + .unwrap() + .expect("npm resolves by its strong identity"); + assert_eq!(policy.version, "0.9.0-alpha"); + let filesystem = policy.filesystem.expect("filesystem section"); + assert_eq!(filesystem.readonly_paths, ["/opt/node/bin"]); + assert_eq!(filesystem.readwrite_paths, ["/work/repo", "/home/u/.npm"]); + assert!(filesystem.denied_paths.is_empty()); + assert!(policy.network.is_none()); + assert!(policy.ui.is_none()); + assert_eq!(policy.timeout_ms, None); +} + +#[test] +fn diagnostics_carry_the_same_policy_and_attribution() { + let ctx = linux_context().allow_weak(true); + let resolution = resolve_sandbox_policy_with_diagnostics("git", &ctx).unwrap(); + let policy = resolution.policy.expect("git resolves"); + let plain = resolve_sandbox_policy("git", &ctx).unwrap().unwrap(); + assert_eq!( + policy.filesystem.unwrap().readonly_paths, + plain.filesystem.unwrap().readonly_paths + ); + assert_eq!(resolution.diagnostics.tools.len(), 1); + assert_eq!( + resolution.diagnostics.tools[0].matches[0].entry_id, + "tool:git" + ); +} + +#[test] +fn absence_is_none_and_bad_context_is_an_error() { + assert!(resolve_sandbox_policy("not-a-known-tool", &linux_context()) + .unwrap() + .is_none()); + let error = + resolve_sandbox_policy("git", &ResolveContext::new().platform_str("solaris")).unwrap_err(); + assert_eq!(error.reason(), ErrorReason::InvalidContext); +} + +trait PlatformStr { + fn platform_str(self, value: &str) -> Self; +} + +impl PlatformStr for ResolveContext { + fn platform_str(mut self, value: &str) -> Self { + self.platform = Some(value.to_string()); + self + } +} + +#[test] +fn inspection_reports_the_bundled_catalog() { + let info = get_catalog_info().unwrap(); + assert_eq!(info.catalog_schema_version, "1"); + let ids: Vec = list_catalog_entries() + .unwrap() + .into_iter() + .map(|e| e.entry_id) + .collect(); + assert!(ids.contains(&"tool:git".to_string()), "{ids:?}"); +} + +fn fixture() -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../mxc_policy_store/conformance/fixtures/bundled-catalog.json"); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +/// Every host-independent bundled conformance case gives the same result +/// through the JSON binding path the Node and C# SDKs use. +#[test] +fn binding_json_matches_the_bundled_conformance_fixtures() { + let fixture = fixture(); + let mut ran = 0; + for case in fixture["cases"].as_array().unwrap() { + if case.get("host").is_some() { + continue; + } + let name = case["name"].as_str().unwrap(); + let mut request = serde_json::Map::new(); + request.insert("tools".into(), case["tools"].clone()); + if let Some(context) = case.get("context") { + request.insert("context".into(), context.clone()); + } + let request = Value::Object(request).to_string(); + let plain = binding::resolve_sandbox_policy_json(&request); + let full = binding::resolve_sandbox_policy_with_diagnostics_json(&request); + if let Some(expected) = case.get("expectError") { + for error in [plain.unwrap_err(), full.unwrap_err()] { + assert_eq!(error.code().as_str(), expected["code"], "{name}"); + assert_eq!(error.reason().as_str(), expected["reason"], "{name}"); + } + } else { + let expect = &case["expect"]; + let plain: Value = serde_json::from_str(&plain.unwrap()).unwrap(); + let full: Value = serde_json::from_str(&full.unwrap()).unwrap(); + let policy = |v: &Value| v.get("policy").cloned().unwrap_or(Value::Null); + assert_eq!(policy(&plain), expect["policy"], "{name}"); + assert_eq!(policy(&full), expect["policy"], "{name}"); + assert_eq!(full["diagnostics"], expect["diagnostics"], "{name}"); + } + ran += 1; + } + assert!(ran >= 10, "ran {ran} cases"); +} + +#[test] +fn binding_inspection_and_malformed_requests() { + let info: Value = serde_json::from_str(&binding::catalog_info_json().unwrap()).unwrap(); + assert_eq!(info["catalogSchemaVersion"], "1"); + let entries: Value = + serde_json::from_str(&binding::list_catalog_entries_json().unwrap()).unwrap(); + assert!(entries + .as_array() + .unwrap() + .iter() + .any(|e| e["entryId"] == "tool:git")); + let error = + binding::resolve_sandbox_policy_json(r#"{"tools":"git","extra":true}"#).unwrap_err(); + assert_eq!(error.reason(), ErrorReason::InvalidContext); +} diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/core/mxc_policy_store/src/lib.rs index 9152bc091..b18c4e15a 100644 --- a/src/core/mxc_policy_store/src/lib.rs +++ b/src/core/mxc_policy_store/src/lib.rs @@ -32,6 +32,7 @@ pub mod json; pub mod model; pub mod paths; pub mod purl; +pub mod request; pub mod resolver; pub mod store; mod text; diff --git a/src/core/mxc_policy_store/src/request.rs b/src/core/mxc_policy_store/src/request.rs new file mode 100644 index 000000000..a08f67593 --- /dev/null +++ b/src/core/mxc_policy_store/src/request.rs @@ -0,0 +1,199 @@ +// 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"?}`. 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::errors::{invalid_context, Result}; +use crate::json::{Json, JsonObject}; +use crate::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"], + &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)?, + })) + } + _ => 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::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}], + "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")) + ); + 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":"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/core/mxc_policy_store/tests/common/mod.rs b/src/core/mxc_policy_store/tests/common/mod.rs index 3f0301ddb..e3cd84552 100644 --- a/src/core/mxc_policy_store/tests/common/mod.rs +++ b/src/core/mxc_policy_store/tests/common/mod.rs @@ -9,7 +9,7 @@ use mxc_policy_store::tooling::{Json, JsonObject}; use mxc_policy_store::{ Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, - PolicyCatalogError, ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs, + PolicyCatalogError, ResolveContext, ToolInputs, }; use std::collections::HashMap; use std::path::PathBuf; @@ -113,55 +113,12 @@ pub fn reason_of(result: Result) -> E error.reason() } -fn candidate(value: &Json) -> ToolInput { - match value { - Json::String(s) => ToolInput::Name(s.clone()), - Json::Object(o) => ToolInput::Candidate(ToolCandidate { - invocation_name: o - .get("invocationName") - .and_then(Json::as_str) - .unwrap() - .to_string(), - package_url: o - .get("packageUrl") - .and_then(Json::as_str) - .map(str::to_string), - detected_version: o - .get("detectedVersion") - .and_then(Json::as_str) - .map(str::to_string), - }), - other => panic!("unsupported tool input {other}"), - } -} - -/// Fixture `tools` (single input or array) as library inputs. +/// Fixture `tools` (single input or array), through the binding parser. pub fn tools_from(value: &Json) -> ToolInputs { - match value { - Json::Array(items) => ToolInputs(items.iter().map(candidate).collect()), - single => ToolInputs(vec![candidate(single)]), - } + mxc_policy_store::request::parse_tool_inputs(value).expect("fixture tools parse") } -/// Fixture `context` as a library context. +/// Fixture `context`, through the binding parser. pub fn context_from(value: Option<&Json>) -> ResolveContext { - let mut ctx = ResolveContext::new(); - let Some(value) = value else { return ctx }; - let s = |k: &str| value.get(k).and_then(Json::as_str).map(str::to_string); - ctx.project_root = s("projectRoot"); - ctx.platform = s("platform"); - ctx.architecture = s("architecture"); - ctx.catalog_revision = s("catalogRevision"); - ctx.allow_weak_identity_fallback = value - .get("allowWeakIdentityFallback") - .and_then(Json::as_bool) - == Some(true); - if let Some(symbols) = value.get("symbols").and_then(Json::as_object) { - let mut map = SymbolMap::new(); - for (k, v) in symbols.iter() { - map.insert(k, v.as_str().unwrap()); - } - ctx.symbols = Some(map); - } - ctx + mxc_policy_store::request::parse_resolve_context(value).expect("fixture context parses") } From a08942a63eec828f1fc150785bd3efe98936a420 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 17:04:47 -0700 Subject: [PATCH 21/50] feat(mxc_ffi): expose the prototype policy store over the C ABI PROTOTYPE, pending API review. Adds mxc_resolve_sandbox_policy_json, mxc_resolve_sandbox_policy_with_diagnostics_json, mxc_policy_catalog_info_json, and mxc_list_policy_catalog_entries_json, each filling an MxcPolicyStoreResult (status, result JSON, stable failure reason, MxcErrorDetail) released by mxc_policy_store_result_free. Store error codes map onto the existing MXC_STATUS_* values. The module is a csbindgen input so the C# SDK binds it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ffi/mxc_ffi/build.rs | 2 + src/ffi/mxc_ffi/src/lib.rs | 8 + src/ffi/mxc_ffi/src/policy_store.rs | 227 ++++++++++++++++++++++++++ src/ffi/mxc_ffi/tests/policy_store.rs | 140 ++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100644 src/ffi/mxc_ffi/src/policy_store.rs create mode 100644 src/ffi/mxc_ffi/tests/policy_store.rs diff --git a/src/ffi/mxc_ffi/build.rs b/src/ffi/mxc_ffi/build.rs index bc429df41..e187b3d3c 100644 --- a/src/ffi/mxc_ffi/build.rs +++ b/src/ffi/mxc_ffi/build.rs @@ -18,6 +18,7 @@ fn main() { println!("cargo:rerun-if-changed=src/error_detail.rs"); println!("cargo:rerun-if-changed=src/streaming.rs"); println!("cargo:rerun-if-changed=src/state_aware.rs"); + println!("cargo:rerun-if-changed=src/policy_store.rs"); println!("cargo:rerun-if-changed=build.rs"); #[cfg(feature = "dotnetsdk")] @@ -49,6 +50,7 @@ fn generate_csharp_bindings() { .input_extern_file("src/error_detail.rs") .input_extern_file("src/streaming.rs") .input_extern_file("src/state_aware.rs") + .input_extern_file("src/policy_store.rs") .csharp_dll_name("mxc_ffi") .csharp_namespace("Microsoft.Mxc.Sdk.Native") .csharp_class_name("NativeMethods") diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 488e84c10..cc5bc9144 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -15,6 +15,12 @@ //! drives the envelope phases (provision / start / stop / deprovision), and //! [`mxc_state_aware_exec`] runs the exec phase as a live streaming handle //! (reusing the streaming externs). +//! - **Policy store** (`policy_store` module, PROTOTYPE pending API review) — +//! [`mxc_resolve_sandbox_policy_json`], +//! [`mxc_resolve_sandbox_policy_with_diagnostics_json`], +//! [`mxc_policy_catalog_info_json`], and +//! [`mxc_list_policy_catalog_entries_json`] resolve known tools against the +//! statically bundled policy catalog, for the Node and C# SDKs. //! //! ## Contract //! @@ -61,10 +67,12 @@ use std::sync::OnceLock; use mxc_sdk::{available_backends, platform_support, run, ErrorCode, SandboxRequest, WaitOutcome}; mod error_detail; +mod policy_store; mod request; mod state_aware; mod streaming; pub use error_detail::*; +pub use policy_store::*; 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..f314ca89e --- /dev/null +++ b/src/ffi/mxc_ffi/src/policy_store.rs @@ -0,0 +1,227 @@ +// 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"?}` +//! (see `mxc_policy_store::request`). `mxc_resolve_sandbox_policy_json` +//! returns `{"policy"?}`, omitting `policy` when nothing resolves; the +//! diagnostics variant returns `{"policy"?, "diagnostics"}`. The policy JSON +//! is the SDK `SandboxPolicy` shape. + +use std::ffi::c_char; +use std::panic::catch_unwind; +use std::ptr; + +use mxc_sdk::policy_store::{binding, 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 one composed floor policy 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_sandbox_policy_json( + request_json_utf8: *const c_char, + out: *mut MxcPolicyStoreResult, +) -> i32 { + unsafe { + write_result( + "mxc_resolve_sandbox_policy_json", + out, + || match request_str(request_json_utf8) { + Ok(request) => complete(binding::resolve_sandbox_policy_json(request)), + Err(failure) => failure, + }, + ) + } +} + +/// Like [`mxc_resolve_sandbox_policy_json`], with match attribution, +/// dependencies, and warnings. +/// +/// # Safety +/// Same contract as [`mxc_resolve_sandbox_policy_json`]. +#[no_mangle] +pub unsafe extern "C" fn mxc_resolve_sandbox_policy_with_diagnostics_json( + request_json_utf8: *const c_char, + out: *mut MxcPolicyStoreResult, +) -> i32 { + unsafe { + write_result( + "mxc_resolve_sandbox_policy_with_diagnostics_json", + out, + || match request_str(request_json_utf8) { + Ok(request) => complete(binding::resolve_sandbox_policy_with_diagnostics_json( + request, + )), + Err(failure) => failure, + }, + ) + } +} + +/// The bundled catalog's `{"catalogSchemaVersion", "catalogRevision"}`. +/// +/// # 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..0812ce73d --- /dev/null +++ b/src/ffi/mxc_ffi/tests/policy_store.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The prototype policy store C ABI. + +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) +} + +const GIT_LINUX: &str = r#"{"tools":"git","context":{"platform":"linux","architecture":"x64","allowWeakIdentityFallback":true,"projectRoot":"/w","symbols":{"git_prefix":"/usr/bin"}}}"#; + +#[test] +fn resolves_a_policy() { + let (status, json, reason, message) = call(mxc_resolve_sandbox_policy_json, GIT_LINUX); + assert_eq!(status, MXC_STATUS_SUCCESS, "{message:?}"); + assert_eq!(reason, None); + let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + assert_eq!(json["policy"]["version"], "0.9.0-alpha"); + assert_eq!(json["policy"]["filesystem"]["readonlyPaths"][0], "/usr/bin"); +} + +#[test] +fn absence_omits_the_policy() { + let (status, json, _, _) = call( + mxc_resolve_sandbox_policy_json, + r#"{"tools":"unknown-tool","context":{"platform":"linux","architecture":"x64"}}"#, + ); + assert_eq!(status, MXC_STATUS_SUCCESS); + assert_eq!(json.as_deref(), Some("{}")); +} + +#[test] +fn diagnostics_include_attribution() { + let (status, json, _, _) = call(mxc_resolve_sandbox_policy_with_diagnostics_json, GIT_LINUX); + assert_eq!(status, MXC_STATUS_SUCCESS); + let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); + assert_eq!( + json["diagnostics"]["tools"][0]["matches"][0]["entryId"], + "tool:git" + ); + assert!(json["policy"].is_object()); +} + +#[test] +fn store_failures_carry_status_and_reason() { + let (status, json, reason, message) = call( + mxc_resolve_sandbox_policy_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_sandbox_policy_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_sandbox_policy_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_sandbox_policy_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_sandbox_policy_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_sandbox_policy_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"); + + 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); +} From 051c907144d705f9e5b17023795faf9bc2b844fa Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 17:09:14 -0700 Subject: [PATCH 22/50] feat(node-sdk): add the prototype policy store APIs PROTOTYPE, pending API review. Adds resolveSandboxPolicy, resolveSandboxPolicyWithDiagnostics, getCatalogInfo, and listCatalogEntries to @microsoft/mxc-sdk. They call the mxc_ffi policy store entry points through koffi, return the SDK's own SandboxPolicy type, and surface failures as MxcError with the stable store reason in details.reason. The V1 catalog is compiled into mxc_ffi, so nothing is downloaded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/node/package.json | 2 +- sdk/node/src/bindings/policy-store.ts | 116 ++++++++++++++++ sdk/node/src/index.ts | 19 +++ sdk/node/src/policy-store.ts | 168 +++++++++++++++++++++++ sdk/node/tests/unit/policy-store.test.ts | 107 +++++++++++++++ 5 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 sdk/node/src/bindings/policy-store.ts create mode 100644 sdk/node/src/policy-store.ts create mode 100644 sdk/node/tests/unit/policy-store.test.ts diff --git a/sdk/node/package.json b/sdk/node/package.json index 08507f959..80259a486 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -23,7 +23,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/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/native-library.test.js dist-tests/tests/unit/binding-request.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.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/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/native-library.test.js dist-tests/tests/unit/binding-request.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/policy-store.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": "cd tests/integration && npx tsc --noEmit -p tsconfig.json", diff --git a/sdk/node/src/bindings/policy-store.ts b/sdk/node/src/bindings/policy-store.ts new file mode 100644 index 000000000..e08f8c18c --- /dev/null +++ b/sdk/node/src/bindings/policy-store.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Koffi wrappers for the prototype policy store entry points in mxc_ffi. + +import koffi from 'koffi'; +import { MxcError } from '../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_sandbox_policy_json' + | 'mxc_resolve_sandbox_policy_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 withResult( + call: ( + native: ReturnType, + result: AbiPolicyStoreResult, + ) => number, +): unknown { + const native = loadMxcFfi(); + try { + const free = bindNativeFunction(native.handle, { + symbol: 'mxc_policy_store_result_free', + result: 'void', + parameters: [koffi.pointer(AbiPolicyStoreResultType)], + }); + const result = {} as AbiPolicyStoreResult; + let filled = false; + try { + const status = call(native, result); + filled = true; + return decode(status, result); + } finally { + if (filled) free(result); + } + } finally { + native.handle.unload(); + } +} + +/** Calls a request-taking policy store entry point and parses its JSON. */ +export function callPolicyStore( + symbol: PolicyStoreRequestSymbol, + request: unknown, +): unknown { + return withResult((native, result) => + bindNativeFunction(native.handle, { + symbol, + result: 'int32_t', + parameters: ['const char *', koffi.out(koffi.pointer(AbiPolicyStoreResultType))], + })(JSON.stringify(request), result), + ); +} + +/** Calls an input-free policy store entry point and parses its JSON. */ +export function inspectPolicyStore(symbol: PolicyStoreInspectSymbol): unknown { + return withResult((native, result) => + bindNativeFunction(native.handle, { + symbol, + result: 'int32_t', + parameters: [koffi.out(koffi.pointer(AbiPolicyStoreResultType))], + })(result), + ); +} diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index 1c1f6b64e..622dcce04 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -162,6 +162,25 @@ export { export { MxcSandboxProcess } from './sandbox-process.js'; +// PROTOTYPE, pending API review: the policy store (bundled catalog lookup). +export { + type CatalogPlatform, + type CatalogArchitecture, + type ToolCandidate, + type ToolInput, + type ResolveContext, + type IdentityStrength, + type PolicyResolutionDiagnostics, + type SandboxConfigResolution, + type CatalogIdentityMetadata, + type CatalogEntryMetadata, + type CatalogInfo, + resolveSandboxPolicy, + resolveSandboxPolicyWithDiagnostics, + getCatalogInfo, + listCatalogEntries, +} from './policy-store.js'; + // Export telemetry consent functions and types export { TelemetryConfig, diff --git a/sdk/node/src/policy-store.ts b/sdk/node/src/policy-store.ts new file mode 100644 index 000000000..ddb0e0eb6 --- /dev/null +++ b/sdk/node/src/policy-store.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * **PROTOTYPE, pending API review.** The MXC policy store: resolve known tools + * to a candidate floor {@link SandboxPolicy} from the policy 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 composes with its own policy. It is + * complementary to Learning Mode, not a replacement. Resolution never grants + * access, launches a sandbox, contacts a network service, or writes state; the + * V1 catalog is compiled into `mxc_ffi` and nothing is downloaded. + * + * Names and shapes are proposed and may change before sign-off (for example, + * the names may drop "Sandbox", and lookup may gain an intent such as + * `git pull` versus `git push`). This API is not part of MXC 1.0. + * + * All four functions run in-process through `mxc_ffi`, so they require the + * native library that ships with this package. + * + * @module + */ + +import { callPolicyStore, inspectPolicyStore } from './bindings/policy-store.js'; +import type { SandboxPolicy } from './types.js'; + +/** Catalog platform selector values. */ +export type CatalogPlatform = 'windows' | 'linux' | 'macos'; + +/** Catalog architecture selector values. */ +export type CatalogArchitecture = 'x64' | 'arm64'; + +/** A tool to look up, identified by invocation name and optional identity. */ +export interface ToolCandidate { + /** The command name, for example `git`. */ + invocationName: string; + /** A Package URL, for example `pkg:npm/npm`. A strong identity. */ + packageUrl?: string; + /** The detected tool version, used to filter `packageUrl` version ranges. */ + detectedVersion?: 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; + /** Caller-supplied catalog symbol values, for example `git_prefix`. */ + 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; +} + +/** Strength of a matched identity predicate. */ +export type IdentityStrength = 'strong' | 'weak'; + +/** Match attribution and warnings from one resolution pass. */ +export interface PolicyResolutionDiagnostics { + catalogRevision: string; + tools: Array<{ + inputIndex: number; + matches: Array<{ + entryId: string; + entryRevision: number; + matchedIdentities: Array<{ kind: string; strength: IdentityStrength }>; + }>; + }>; + resolvedDependencies: Array<{ + entryId: string; + entryRevision: number; + requiredVersionRange?: string; + }>; + warnings: string[]; +} + +/** Result of {@link resolveSandboxPolicyWithDiagnostics}. */ +export interface SandboxConfigResolution { + /** The composed policy, or `undefined` when nothing could be resolved. */ + policy: SandboxPolicy | undefined; + diagnostics: PolicyResolutionDiagnostics; +} + +/** One identity predicate of a catalog entry. */ +export type CatalogIdentityMetadata = + | { kind: 'purl'; value: string; versionRange?: string } + | { kind: 'invocation-name'; names: string[] }; + +/** Inspection metadata for one catalog entry. It never exposes a policy body. */ +export interface CatalogEntryMetadata { + catalogRevision: string; + entryId: string; + entryRevision: number; + displayName: string; + identity: CatalogIdentityMetadata[]; + platformVariants: Array<{ + platform: CatalogPlatform; + architecture?: CatalogArchitecture; + dependencyEntryIds: string[]; + sandboxPolicyVersion: string; + }>; + provenance: { method: string; sourceRevision: string }; +} + +/** The bundled catalog's schema version and default revision. */ +export interface CatalogInfo { + catalogSchemaVersion: string; + catalogRevision: string; +} + +/** + * **PROTOTYPE, pending API review.** Resolves one tool or a list of tools to a + * single composed floor policy from the bundled catalog. + * + * @returns The policy, or `undefined` when no policy can be resolved. + * @throws {MxcError} with the stable failure reason in `details.reason`: + * `malformed_request` (`invalid_context`) for invalid input or context, + * `unsupported_containment` (`unsupported_host`), `policy_validation` + * (`invalid_catalog`, `composition_conflict`), or `backend_error` (`integrity`, + * `revision_unavailable`). + */ +export function resolveSandboxPolicy( + tools: ToolInput | ToolInput[], + context: ResolveContext = {}, +): SandboxPolicy | undefined { + const result = callPolicyStore('mxc_resolve_sandbox_policy_json', { + tools, + context, + }) as { policy?: SandboxPolicy }; + return result.policy; +} + +/** + * **PROTOTYPE, pending API review.** Like {@link resolveSandboxPolicy}, and + * also reports which catalog entries matched each input, the dependencies + * they pulled in, and warnings. + */ +export function resolveSandboxPolicyWithDiagnostics( + tools: ToolInput | ToolInput[], + context: ResolveContext = {}, +): SandboxConfigResolution { + const result = callPolicyStore('mxc_resolve_sandbox_policy_with_diagnostics_json', { + tools, + context, + }) as { policy?: SandboxPolicy; diagnostics: PolicyResolutionDiagnostics }; + return { policy: result.policy, diagnostics: result.diagnostics }; +} + +/** **PROTOTYPE, pending API review.** The bundled catalog's schema version and default revision. */ +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 policy 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..763e181b1 --- /dev/null +++ b/sdk/node/tests/unit/policy-store.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { MxcError } from '../../src/errors.js'; +import { findMxcFfiLibrary } from '../../src/native-library.js'; +import { + getCatalogInfo, + listCatalogEntries, + resolveSandboxPolicy, + resolveSandboxPolicyWithDiagnostics, +} from '../../src/policy-store.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; + +const context = { + platform: 'linux' as const, + architecture: 'x64' as const, + projectRoot: '/work/repo', + symbols: { + node_prefix: '/opt/node', + npm_prefix: '/opt/npm', + npm_cache: '/home/u/.npm', + }, +}; + +describe('policy store (prototype)', { skip }, () => { + it('reports the bundled catalog', () => { + const info = getCatalogInfo(); + assert.strictEqual(info.catalogSchemaVersion, '1'); + assert.match(info.catalogRevision, /\S/); + }); + + it('lists entry metadata without policy 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(!('policy' in entry)); + assert.ok(entry.platformVariants.length > 0); + } + }); + + it('resolves a strong identity to an SDK SandboxPolicy', () => { + const policy = resolveSandboxPolicy( + { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }, + context, + ); + assert.ok(policy !== undefined); + assert.strictEqual(policy.version, '0.9.0-alpha'); + assert.ok((policy.filesystem?.readonlyPaths?.length ?? 0) > 0); + }); + + it('returns undefined for an unknown tool', () => { + assert.strictEqual(resolveSandboxPolicy('no-such-tool', context), undefined); + }); + + it('requires the weak-identity opt-in for name-only matches', () => { + const withoutOptIn = resolveSandboxPolicyWithDiagnostics('git', { + ...context, + symbols: { git_prefix: '/usr' }, + }); + assert.strictEqual(withoutOptIn.policy, undefined); + + const withOptIn = resolveSandboxPolicyWithDiagnostics('git', { + ...context, + symbols: { git_prefix: '/usr' }, + allowWeakIdentityFallback: true, + }); + assert.ok(withOptIn.policy !== undefined); + assert.strictEqual(withOptIn.diagnostics.tools[0].inputIndex, 0); + assert.strictEqual(withOptIn.diagnostics.tools[0].matches[0].entryId, 'tool:git'); + assert.strictEqual( + withOptIn.diagnostics.tools[0].matches[0].matchedIdentities[0].strength, + 'weak', + ); + }); + + it('reports dependencies pulled in by a match', () => { + const resolution = resolveSandboxPolicyWithDiagnostics( + { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }, + context, + ); + assert.ok( + resolution.diagnostics.resolvedDependencies.some((dep) => dep.entryId === 'tool:node'), + ); + }); + + it('throws MxcError with a stable reason for an invalid context', () => { + assert.throws( + () => + resolveSandboxPolicy('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; + }, + ); + }); +}); From 4abf8fd39273329dd21a4a65cedc57ba1f9dda1d Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 17:15:58 -0700 Subject: [PATCH 23/50] feat(dotnet-sdk): add the prototype MxcPolicyStore APIs PROTOTYPE, pending API review. Adds MxcPolicyStore.ResolveSandboxPolicy, ResolveSandboxPolicyWithDiagnostics (single-tool and list overloads), GetCatalogInfo, and ListCatalogEntries to Microsoft.Mxc.Sdk over the mxc_ffi policy store entry points. Results deserialize strictly into the SDK's own SandboxPolicy, so a field the model cannot hold fails as policy_validation instead of narrowing the floor. Failures throw PolicyStoreException carrying the ErrorCode, the stable store reason, and the underlying MxcException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MxcPolicyStoreTests.cs | 149 +++++++ .../Microsoft.Mxc.Sdk/MxcPolicyStore.cs | 406 ++++++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs new file mode 100644 index 000000000..10e88621f --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +public class MxcPolicyStoreTests +{ + private static readonly ResolveContext LinuxContext = new() + { + Platform = CatalogPlatforms.Linux, + Architecture = CatalogArchitectures.X64, + ProjectRoot = "/work/repo", + Symbols = new Dictionary + { + ["git_prefix"] = "/usr", + ["node_prefix"] = "/opt/node", + ["npm_prefix"] = "/opt/npm", + ["npm_cache"] = "/home/u/.npm", + }, + }; + + private static readonly ToolInput Npm = new("npm") { PackageUrl = "pkg:npm/npm" }; + + [Fact] + public void GetCatalogInfo_ReportsTheBundledCatalog() + { + var info = MxcPolicyStore.GetCatalogInfo(); + + Assert.Equal("1", info.CatalogSchemaVersion); + Assert.False(string.IsNullOrWhiteSpace(info.CatalogRevision)); + } + + [Fact] + public void ListCatalogEntries_ReturnsMetadata() + { + var entries = MxcPolicyStore.ListCatalogEntries(); + + var npm = Assert.Single(entries, entry => entry.EntryId == "tool:npm"); + Assert.Contains(npm.Identity, identity => identity.Kind == "purl" && identity.Value == "pkg:npm/npm"); + Assert.Contains(npm.PlatformVariants, variant => + variant.Platform == CatalogPlatforms.Linux + && variant.DependencyEntryIds.Contains("tool:node")); + } + + [Fact] + public void ResolveSandboxPolicy_ReturnsTheSdkPolicyType() + { + SandboxPolicy? policy = MxcPolicyStore.ResolveSandboxPolicy(Npm, LinuxContext); + + Assert.NotNull(policy); + Assert.Equal("0.9.0-alpha", policy.Version); + Assert.NotNull(policy.Filesystem); + Assert.Contains("/opt/npm", policy.Filesystem.ReadonlyPaths); + Assert.Contains("/opt/node", policy.Filesystem.ReadonlyPaths); + Assert.Contains("/work/repo", policy.Filesystem.ReadwritePaths); + } + + [Fact] + public void ResolveSandboxPolicy_ReturnsNullForAnUnknownTool() + { + Assert.Null(MxcPolicyStore.ResolveSandboxPolicy("no-such-tool", LinuxContext)); + } + + [Fact] + public void ResolveSandboxPolicy_ComposesAListOfTools() + { + var policy = MxcPolicyStore.ResolveSandboxPolicy( + new ToolInput[] { "git", Npm }, + LinuxContext with { AllowWeakIdentityFallback = true }); + + Assert.NotNull(policy); + Assert.Contains("/usr", policy.Filesystem!.ReadonlyPaths); + Assert.Contains("/opt/npm", policy.Filesystem.ReadonlyPaths); + } + + [Fact] + public void ResolveSandboxPolicyWithDiagnostics_ReportsWeakMatchesOnlyWhenAllowed() + { + var withoutOptIn = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics("git", LinuxContext); + Assert.Null(withoutOptIn.Policy); + + var withOptIn = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics( + "git", + LinuxContext with { AllowWeakIdentityFallback = true }); + + Assert.NotNull(withOptIn.Policy); + var tool = Assert.Single(withOptIn.Diagnostics.Tools); + Assert.Equal(0, tool.InputIndex); + var match = Assert.Single(tool.Matches); + Assert.Equal("tool:git", match.EntryId); + Assert.Equal("weak", Assert.Single(match.MatchedIdentities).Strength); + } + + [Fact] + public void ResolveSandboxPolicyWithDiagnostics_ReportsDependencies() + { + var resolution = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics(Npm, LinuxContext); + + Assert.Contains(resolution.Diagnostics.ResolvedDependencies, dep => dep.EntryId == "tool:node"); + } + + [Fact] + public void ResolveSandboxPolicy_InvalidContextThrowsWithStableReason() + { + var error = Assert.Throws(() => + MxcPolicyStore.ResolveSandboxPolicy("npm", LinuxContext with { Platform = "plan9" })); + + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + Assert.Equal("invalid_context", error.Reason); + Assert.IsType(error.InnerException); + } + + [Fact] + public void ParseResolution_MapsNetworkUiAndTimeout() + { + var resolution = MxcPolicyStore.ParseResolution(""" + {"policy":{"version":"0.9.0-alpha", + "network":{"egress":{"default":"deny","allow":[{"to":[{"cidr":"10.0.0.0/8","except":["10.1.0.0/16"]}],"ports":[{"protocol":"tcp","port":443}]}]}, + "ingress":{"default":"deny","hostLoopback":"allow"}}, + "ui":{"clipboard":"read"}, + "timeoutMs":5000}} + """); + + var policy = Assert.IsType(resolution.Policy); + Assert.Equal(NetworkAction.Deny, policy.Network!.Egress!.Default); + var rule = Assert.Single(policy.Network.Egress.Allow!); + Assert.Equal("10.0.0.0/8", Assert.Single(rule.To!).Cidr); + Assert.Equal(NetworkProtocol.Tcp, Assert.Single(rule.Ports!).Protocol); + Assert.Equal(NetworkAction.Allow, policy.Network.Ingress!.HostLoopback); + Assert.Equal(ClipboardPolicy.Read, policy.Ui!.Clipboard); + Assert.False(policy.Ui.AllowWindows); + Assert.Equal(5000u, policy.TimeoutMs); + } + + [Fact] + public void ParseResolution_RejectsFieldsTheSdkModelCannotHold() + { + var error = Assert.Throws(() => + MxcPolicyStore.ParseResolution("""{"policy":{"version":"0.9.0-alpha","timeoutMs":4294967296}}""")); + Assert.Equal(ErrorCode.PolicyValidation, error.Code); + Assert.Equal("invalid_catalog", error.Reason); + + Assert.Throws(() => + MxcPolicyStore.ParseResolution("""{"policy":{"version":"0.9.0-alpha","unknownField":true}}""")); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs new file mode 100644 index 000000000..c5c04d747 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Mxc.Sdk.Native; + +namespace Microsoft.Mxc.Sdk; + +/// +/// PROTOTYPE, pending API review. The MXC policy store: resolve known +/// tools to a candidate floor from the policy +/// catalog bundled statically in the native mxc_ffi library. +/// +/// +/// +/// The result is a best-effort floor, not a guarantee: the access a known tool +/// typically needs, which a caller composes with its own policy. It is +/// complementary to Learning Mode, not a replacement. Resolution never grants +/// access, launches a sandbox, contacts a network service, or writes state; the +/// V1 catalog is compiled in and nothing is downloaded. +/// +/// +/// Names and shapes are proposed and may change before sign-off (for example, +/// the names may drop "Sandbox", and lookup may gain an intent such as +/// git pull versus git push). This API is not part of MXC 1.0. +/// +/// +/// Failures throw , which carries the typed +/// , the store's stable failure reason (for example +/// invalid_context), and the underlying . +/// +/// +public static class MxcPolicyStore +{ + static MxcPolicyStore() + { + NativeLibraryResolver.Initialize(); + } + + private static readonly JsonSerializerOptions RequestJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + // Strict on the way back in: a policy field the SDK model cannot hold must + // fail loudly rather than be silently dropped from the floor. + private static readonly JsonSerializerOptions ResultJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + /// + /// PROTOTYPE, pending API review. Resolve one tool to a floor policy. + /// + /// The policy, or when none can be resolved. + /// The input, context, or catalog is invalid. + public static SandboxPolicy? ResolveSandboxPolicy(ToolInput tool, ResolveContext? context = null) + { + ArgumentNullException.ThrowIfNull(tool); + return ResolveSandboxPolicy(new[] { tool }, context); + } + + /// + /// PROTOTYPE, pending API review. Resolve a list of tools to a single + /// composed floor policy. + /// + /// The policy, or when none can be resolved. + /// The input, context, or catalog is invalid. + public static SandboxPolicy? ResolveSandboxPolicy( + IReadOnlyList tools, + ResolveContext? context = null) + { + var json = CallResolve(Request(tools, context), withDiagnostics: false); + return ParseResolution(json).Policy; + } + + /// + /// PROTOTYPE, pending API review. Like + /// , and also + /// reports which catalog entries matched, the dependencies they pulled in, + /// and warnings. + /// + /// The input, context, or catalog is invalid. + public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics( + ToolInput tool, + ResolveContext? context = null) + { + ArgumentNullException.ThrowIfNull(tool); + return ResolveSandboxPolicyWithDiagnostics(new[] { tool }, context); + } + + /// + /// PROTOTYPE, pending API review. Like + /// , + /// and also reports which catalog entries matched each input, the + /// dependencies they pulled in, and warnings. + /// + /// The input, context, or catalog is invalid. + public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics( + IReadOnlyList tools, + ResolveContext? context = null) + { + var json = CallResolve(Request(tools, context), withDiagnostics: true); + var resolution = ParseResolution(json); + if (ReferenceEquals(resolution.Diagnostics, EmptyDiagnostics)) + { + throw new MxcException( + ErrorCode.BackendError, + "The policy store returned no diagnostics."); + } + + return resolution; + } + + /// + /// PROTOTYPE, pending API review. The bundled catalog's schema + /// version and default revision. + /// + public static CatalogInfo GetCatalogInfo() + { + var json = CallInspect(listEntries: false); + return Deserialize(json); + } + + /// + /// PROTOTYPE, pending API review. Metadata for every entry in the + /// bundled catalog's default revision. It never exposes a policy body. + /// + public static IReadOnlyList ListCatalogEntries() + { + var json = CallInspect(listEntries: true); + return Deserialize(json); + } + + private static byte[] Request(IReadOnlyList tools, ResolveContext? context) + { + ArgumentNullException.ThrowIfNull(tools); + var request = new NativeRequest( + tools.Select(tool => tool ?? throw new ArgumentException( + "A tool input is null.", nameof(tools))).ToArray(), + context); + var json = JsonSerializer.Serialize(request, RequestJsonOptions); + var bytes = new byte[Encoding.UTF8.GetByteCount(json) + 1]; + Encoding.UTF8.GetBytes(json, 0, json.Length, bytes, 0); + return bytes; + } + + private static unsafe string CallResolve(byte[] request, bool withDiagnostics) + { + fixed (byte* requestPtr = request) + { + MxcPolicyStoreResult result = default; + var status = withDiagnostics + ? NativeMethods.mxc_resolve_sandbox_policy_with_diagnostics_json(requestPtr, &result) + : NativeMethods.mxc_resolve_sandbox_policy_json(requestPtr, &result); + return TakeResult(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 TakeResult(status, &result); + } + + private static unsafe string TakeResult(int status, MxcPolicyStoreResult* result) + { + try + { + if (status != (int)ErrorCode.Success) + { + var error = NativeError.ToException(status, result->error, "unknown error"); + throw new PolicyStoreException( + error, + NativeError.ToStringOrNull(result->reason_utf8)); + } + + 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); + } + } + + private static T Deserialize(string json) => + JsonSerializer.Deserialize(json, ResultJsonOptions) + ?? throw new JsonException("The policy store returned null JSON."); + + /// + /// Maps the store's policy JSON onto the SDK . + /// A field the SDK model cannot hold is a catalog-contract violation, as in + /// the Rust SDK, not a silently narrowed floor. + /// + internal static SandboxConfigResolution ParseResolution(string json) + { + try + { + var resolution = Deserialize(json); + return new SandboxConfigResolution( + resolution.Policy, + resolution.Diagnostics ?? EmptyDiagnostics); + } + catch (JsonException error) + { + throw new PolicyStoreException( + new MxcException( + ErrorCode.PolicyValidation, + $"The resolved policy is not a valid SDK SandboxPolicy: {error.Message}", + error), + "invalid_catalog"); + } + } + + private static readonly ResolutionDiagnostics EmptyDiagnostics = + new(string.Empty, Array.Empty(), Array.Empty(), Array.Empty()); + + private sealed record NativeRequest( + [property: JsonPropertyName("tools")] IReadOnlyList Tools, + [property: JsonPropertyName("context")] ResolveContext? Context); + + private sealed record NativeResolution( + [property: JsonPropertyName("policy")] SandboxPolicy? Policy, + [property: JsonPropertyName("diagnostics")] ResolutionDiagnostics? Diagnostics); +} + +/// +/// PROTOTYPE, pending API review. A policy store failure. It is an +/// whose carries the store's +/// stable failure reason. +/// +public sealed class PolicyStoreException : Exception +{ + internal PolicyStoreException(MxcException inner, string? reason) + : base(inner.Message, inner) + { + Code = inner.Code; + Reason = reason; + } + + /// The typed error code. + public ErrorCode Code { get; } + + /// + /// The stable failure reason: invalid_catalog, + /// composition_conflict, invalid_context, + /// unsupported_host, integrity, or + /// revision_unavailable. when the failure did + /// not come from the store itself. + /// + public string? Reason { get; } +} + +/// 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 an input with only . +/// +/// The bare invocation name, for example git. +public sealed record ToolInput( + [property: JsonPropertyName("invocationName")] string InvocationName) +{ + /// A verified Package URL, for example pkg:npm/npm. A strong identity. + [JsonPropertyName("packageUrl")] + public string? PackageUrl { get; init; } + + /// The detected tool version, compared with reviewed version ranges. + [JsonPropertyName("detectedVersion")] + public string? DetectedVersion { get; init; } + + /// String shorthand for new ToolInput(name). + public static implicit operator ToolInput(string invocationName) => new(invocationName); +} + +/// PROTOTYPE, pending API review. Lookup context shared by every input in one lookup. +public sealed record ResolveContext +{ + /// Substituted for the project_root catalog symbol. + [JsonPropertyName("projectRoot")] + public string? ProjectRoot { get; init; } + + /// Caller-supplied catalog symbol values, for example git_prefix. + [JsonPropertyName("symbols")] + public IReadOnlyDictionary? Symbols { get; init; } + + /// A value; defaults to the host platform. + [JsonPropertyName("platform")] + public string? Platform { get; init; } + + /// A value; defaults to the host architecture. + [JsonPropertyName("architecture")] + public string? Architecture { get; init; } + + /// Defaults to the bundled catalog's default revision. + [JsonPropertyName("catalogRevision")] + public string? CatalogRevision { get; init; } + + /// Allow entries matched only by invocation name. Off by default. + [JsonPropertyName("allowWeakIdentityFallback")] + public bool AllowWeakIdentityFallback { get; init; } +} + +/// A satisfied identity predicate. +/// purl or invocation-name. +/// strong or weak. +public sealed record MatchedIdentity(string Kind, string Strength); + +/// One entry matched by one input. +public sealed record ToolMatch( + string EntryId, + int EntryRevision, + IReadOnlyList MatchedIdentities); + +/// Per-input attribution, in input order. +public sealed record ToolDiagnostics(int InputIndex, IReadOnlyList Matches); + +/// A dependency pulled in by a match. +public sealed record ResolvedDependency( + string EntryId, + int EntryRevision, + string? RequiredVersionRange = null); + +/// Match attribution and warnings from one resolution pass. +public sealed record ResolutionDiagnostics( + string CatalogRevision, + IReadOnlyList Tools, + IReadOnlyList ResolvedDependencies, + IReadOnlyList Warnings); + +/// +/// PROTOTYPE, pending API review. Result of +/// . +/// +/// The composed policy, or when none can be resolved. +/// Attribution and warnings. +public sealed record SandboxConfigResolution(SandboxPolicy? Policy, ResolutionDiagnostics Diagnostics); + +/// The bundled catalog's schema version and default revision. +public sealed record CatalogInfo(string CatalogSchemaVersion, string CatalogRevision); + +/// 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 reviewed version range, for purl, when declared. + public string? VersionRange { get; init; } + + /// The names, for invocation-name. + public IReadOnlyList? Names { get; init; } +} + +/// Platform variant metadata. +public sealed record CatalogVariantMetadata( + string Platform, + string? Architecture, + IReadOnlyList DependencyEntryIds, + string SandboxPolicyVersion); + +/// Entry provenance. +public sealed record CatalogProvenance(string Method, string SourceRevision); + +/// Inspection metadata for one catalog entry. It never exposes a policy body. +public sealed record CatalogEntryMetadata( + string CatalogRevision, + string EntryId, + int EntryRevision, + string DisplayName, + IReadOnlyList Identity, + IReadOnlyList PlatformVariants, + CatalogProvenance Provenance); From adfb0aae737da38481f4e58213a073c62210bbd5 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 17:17:22 -0700 Subject: [PATCH 24/50] chore(policy-store): remove the standalone policy-catalog packages and CLI The prototype now ships only as APIs inside the existing MXC SDKs, so this drops the standalone TypeScript, Rust, and .NET packages, the policy-catalog CLIs, the npm contribution tooling, and the PolicyCatalog.Prototype workflow. Catalog validation runs as mxc_policy_store tests, and the conformance vectors are now frozen fixtures. The design docs move in the next commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/PolicyCatalog.Prototype.yml | 110 -- policy-catalog/.github/workflows/ci.yml | 103 -- policy-catalog/.gitignore | 14 - policy-catalog/LICENSE.md | 21 - policy-catalog/README.md | 204 ---- policy-catalog/dotnet/.gitignore | 5 - .../Microsoft.Mxc.PolicyCatalog.Cli.csproj | 21 - .../Program.cs | 281 ----- .../ConformanceTests.cs | 107 -- .../Microsoft.Mxc.PolicyCatalog.Tests.csproj | 33 - .../PrimitivesTests.cs | 172 --- .../ResolverTests.cs | 303 ----- .../StoreTests.cs | 157 --- .../TestData.cs | 202 ---- .../ValidationTests.cs | 182 --- .../VectorTests.cs | 73 -- .../dotnet/Microsoft.Mxc.PolicyCatalog.slnx | 5 - .../CatalogStore.cs | 246 ---- .../CatalogValidation.cs | 452 -------- .../Microsoft.Mxc.PolicyCatalog/Inputs.cs | 93 -- .../Internal/CanonicalJson.cs | 72 -- .../Internal/Catalog.cs | 1015 ----------------- .../Internal/JsNumber.cs | 113 -- .../Internal/JsString.cs | 213 ---- .../Internal/Json.cs | 437 ------- .../Internal/Paths.cs | 478 -------- .../Internal/Purl.cs | 171 --- .../Internal/VersionRange.cs | 149 --- .../Microsoft.Mxc.PolicyCatalog.csproj | 46 - .../PolicyCatalog.cs | 579 ---------- .../PolicyCatalogException.cs | 115 -- .../PolicyCatalogJson.cs | 286 ----- .../Microsoft.Mxc.PolicyCatalog/Results.cs | 224 ---- .../SystemHostEnvironment.cs | 231 ---- policy-catalog/dotnet/README.md | 48 - .../BaseRefTests.cs | 95 -- .../FunctionalTests.cs | 390 ------- .../Helpers.cs | 239 ---- ...t.Mxc.PolicyCatalog.FunctionalTests.csproj | 24 - .../policy-catalog-cli-consumer.csproj | 26 - policy-catalog/dotnet/global.json | 9 - policy-catalog/dotnet/tools/compare-cli.mjs | 136 --- policy-catalog/package-lock.json | 110 -- policy-catalog/package.json | 55 - policy-catalog/rust/.gitignore | 7 - policy-catalog/rust/Cargo.lock | 93 -- policy-catalog/rust/Cargo.toml | 43 - policy-catalog/rust/functional/Cargo.toml.in | 17 - policy-catalog/rust/functional/src/lib.rs | 191 ---- .../rust/functional/tests/functional.rs | 441 ------- policy-catalog/rust/rust-toolchain.toml | 6 - policy-catalog/rust/rustfmt.toml | 4 - policy-catalog/rust/scripts/sync-catalog.mjs | 27 - policy-catalog/scripts/catalog-digest.mjs | 17 - policy-catalog/scripts/check-extractable.mjs | 120 -- policy-catalog/scripts/check-pack.mjs | 50 - .../scripts/check-tests-present.mjs | 24 - policy-catalog/scripts/ci-ensure-rustup.mjs | 44 - policy-catalog/scripts/clean.mjs | 11 - .../scripts/cross-language-check.mjs | 319 ------ policy-catalog/scripts/dotnet-functional.mjs | 145 --- policy-catalog/scripts/generate-vectors.mjs | 55 - policy-catalog/scripts/package-smoke.mjs | 62 - policy-catalog/scripts/rust-functional.mjs | 126 -- policy-catalog/scripts/validate-catalog.mjs | 220 ---- policy-catalog/src/canonical-json.ts | 31 - policy-catalog/src/catalog.ts | 756 ------------ policy-catalog/src/cli.ts | 203 ---- policy-catalog/src/errors.ts | 67 -- policy-catalog/src/history.ts | 106 -- policy-catalog/src/host.ts | 121 -- policy-catalog/src/index.ts | 46 - policy-catalog/src/purl.ts | 46 - policy-catalog/src/resolver.ts | 490 -------- policy-catalog/src/store.ts | 201 ---- policy-catalog/src/tooling.ts | 40 - policy-catalog/src/types.ts | 125 -- policy-catalog/src/validate.ts | 154 --- policy-catalog/src/version-range.ts | 119 -- policy-catalog/tests/functional/cli.test.ts | 126 -- policy-catalog/tests/functional/helpers.ts | 150 --- .../tests/functional/integrity.test.ts | 199 ---- .../tests/functional/resolution.test.ts | 288 ----- policy-catalog/tests/functional/run-tests.js | 69 -- policy-catalog/tests/functional/tsconfig.json | 18 - policy-catalog/tests/unit/conformance.test.ts | 50 - policy-catalog/tests/unit/helpers.ts | 108 -- policy-catalog/tests/unit/resolver.test.ts | 268 ----- policy-catalog/tests/unit/run-tests.js | 31 - policy-catalog/tests/unit/store.test.ts | 109 -- policy-catalog/tests/unit/tsconfig.json | 18 - policy-catalog/tests/unit/validation.test.ts | 140 --- policy-catalog/tests/unit/vectors.test.ts | 47 - .../tests/unit/version-range.test.ts | 37 - policy-catalog/tsconfig.json | 19 - src/core/mxc_policy_store/README.md | 2 +- src/core/mxc_policy_store/src/catalog.rs | 4 +- src/core/mxc_policy_store/src/errors.rs | 5 +- src/core/mxc_policy_store/src/history.rs | 1 - src/core/mxc_policy_store/src/host.rs | 1 - src/core/mxc_policy_store/src/model.rs | 8 +- src/core/mxc_policy_store/src/paths.rs | 4 +- src/core/mxc_policy_store/src/purl.rs | 2 +- src/core/mxc_policy_store/src/resolver.rs | 2 +- src/core/mxc_policy_store/src/store.rs | 4 +- src/core/mxc_policy_store/src/validate.rs | 4 +- .../mxc_policy_store/src/version_range.rs | 4 +- .../mxc_policy_store/tests/conformance.rs | 4 +- src/core/mxc_policy_store/tests/library.rs | 8 +- src/core/mxc_policy_store/tests/vectors.rs | 4 +- 110 files changed, 27 insertions(+), 14279 deletions(-) delete mode 100644 .github/workflows/PolicyCatalog.Prototype.yml delete mode 100644 policy-catalog/.github/workflows/ci.yml delete mode 100644 policy-catalog/.gitignore delete mode 100644 policy-catalog/LICENSE.md delete mode 100644 policy-catalog/README.md delete mode 100644 policy-catalog/dotnet/.gitignore delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs delete mode 100644 policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs delete mode 100644 policy-catalog/dotnet/README.md delete mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs delete mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs delete mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs delete mode 100644 policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj delete mode 100644 policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj delete mode 100644 policy-catalog/dotnet/global.json delete mode 100644 policy-catalog/dotnet/tools/compare-cli.mjs delete mode 100644 policy-catalog/package-lock.json delete mode 100644 policy-catalog/package.json delete mode 100644 policy-catalog/rust/.gitignore delete mode 100644 policy-catalog/rust/Cargo.lock delete mode 100644 policy-catalog/rust/Cargo.toml delete mode 100644 policy-catalog/rust/functional/Cargo.toml.in delete mode 100644 policy-catalog/rust/functional/src/lib.rs delete mode 100644 policy-catalog/rust/functional/tests/functional.rs delete mode 100644 policy-catalog/rust/rust-toolchain.toml delete mode 100644 policy-catalog/rust/rustfmt.toml delete mode 100644 policy-catalog/rust/scripts/sync-catalog.mjs delete mode 100644 policy-catalog/scripts/catalog-digest.mjs delete mode 100644 policy-catalog/scripts/check-extractable.mjs delete mode 100644 policy-catalog/scripts/check-pack.mjs delete mode 100644 policy-catalog/scripts/check-tests-present.mjs delete mode 100644 policy-catalog/scripts/ci-ensure-rustup.mjs delete mode 100644 policy-catalog/scripts/clean.mjs delete mode 100644 policy-catalog/scripts/cross-language-check.mjs delete mode 100644 policy-catalog/scripts/dotnet-functional.mjs delete mode 100644 policy-catalog/scripts/generate-vectors.mjs delete mode 100644 policy-catalog/scripts/package-smoke.mjs delete mode 100644 policy-catalog/scripts/rust-functional.mjs delete mode 100644 policy-catalog/scripts/validate-catalog.mjs delete mode 100644 policy-catalog/src/canonical-json.ts delete mode 100644 policy-catalog/src/catalog.ts delete mode 100644 policy-catalog/src/cli.ts delete mode 100644 policy-catalog/src/errors.ts delete mode 100644 policy-catalog/src/history.ts delete mode 100644 policy-catalog/src/host.ts delete mode 100644 policy-catalog/src/index.ts delete mode 100644 policy-catalog/src/purl.ts delete mode 100644 policy-catalog/src/resolver.ts delete mode 100644 policy-catalog/src/store.ts delete mode 100644 policy-catalog/src/tooling.ts delete mode 100644 policy-catalog/src/types.ts delete mode 100644 policy-catalog/src/validate.ts delete mode 100644 policy-catalog/src/version-range.ts delete mode 100644 policy-catalog/tests/functional/cli.test.ts delete mode 100644 policy-catalog/tests/functional/helpers.ts delete mode 100644 policy-catalog/tests/functional/integrity.test.ts delete mode 100644 policy-catalog/tests/functional/resolution.test.ts delete mode 100644 policy-catalog/tests/functional/run-tests.js delete mode 100644 policy-catalog/tests/functional/tsconfig.json delete mode 100644 policy-catalog/tests/unit/conformance.test.ts delete mode 100644 policy-catalog/tests/unit/helpers.ts delete mode 100644 policy-catalog/tests/unit/resolver.test.ts delete mode 100644 policy-catalog/tests/unit/run-tests.js delete mode 100644 policy-catalog/tests/unit/store.test.ts delete mode 100644 policy-catalog/tests/unit/tsconfig.json delete mode 100644 policy-catalog/tests/unit/validation.test.ts delete mode 100644 policy-catalog/tests/unit/vectors.test.ts delete mode 100644 policy-catalog/tests/unit/version-range.test.ts delete mode 100644 policy-catalog/tsconfig.json diff --git a/.github/workflows/PolicyCatalog.Prototype.yml b/.github/workflows/PolicyCatalog.Prototype.yml deleted file mode 100644 index fbcf4edef..000000000 --- a/.github/workflows/PolicyCatalog.Prototype.yml +++ /dev/null @@ -1,110 +0,0 @@ -# Runs the self-contained policy-catalog prototype checks while it is nested -# in this repository. After extraction, the directory's own workflow -# (policy-catalog/.github/workflows/ci.yml) takes over. The two workflows run -# the same steps; only the working directory differs. -name: Policy Catalog Prototype - -on: - pull_request: - paths: - - 'policy-catalog/**' - - '.github/workflows/PolicyCatalog.Prototype.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - check: - # One job per OS x architecture runs every language and the cross-language - # check on that host, so the comparison uses binaries built on it. - name: Policy catalog (${{ matrix.label }}) - strategy: - fail-fast: false - matrix: - include: - - label: linux-x64 - runner: ubuntu-latest - - label: linux-arm64 - runner: ubuntu-24.04-arm - - label: macos-arm64 - runner: macos-latest - - label: macos-x64 - runner: macos-15-intel - - label: windows-x64 - runner: windows-latest - - label: windows-arm64 - runner: windows-11-arm - runs-on: ${{ matrix.runner }} - defaults: - run: - working-directory: policy-catalog - shell: bash - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - cache: npm - cache-dependency-path: policy-catalog/package-lock.json - - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: policy-catalog/dotnet/global.json - - # rust/rust-toolchain.toml pins the channel; rustup installs it on first - # use. Some images (windows-11-arm) ship without rustup. - - name: Rust toolchain - run: node scripts/ci-ensure-rustup.mjs - - - name: Host facts - run: | - echo "runner.arch=${{ runner.arch }} uname=$(uname -m)" - node -p "process.arch + ' ' + require('os').machine()" - (cd rust && rustc -vV) - dotnet --info | head -n 20 - - # --- TypeScript ------------------------------------------------------- - - run: npm ci - - name: TypeScript check (typecheck, unit, functional from the tarball, validate, pack, smoke) - run: npm run check - - name: Published-revision immutability - if: github.event_name == 'pull_request' - # Passed through env, never interpolated into the script. - env: - POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} - run: npm run validate -- "--base-ref=$POLICY_CATALOG_BASE_REF" - - # --- Rust ------------------------------------------------------------- - - name: Rust fmt - working-directory: policy-catalog/rust - run: cargo fmt --check - - name: Rust clippy - working-directory: policy-catalog/rust - run: cargo clippy --locked --all-targets --all-features -- -D warnings - - name: Rust test - working-directory: policy-catalog/rust - run: cargo test --locked - - name: Rust functional (packaged .crate in a consumer outside the repo) - run: node scripts/rust-functional.mjs - - # --- .NET ------------------------------------------------------------- - - name: .NET build - working-directory: policy-catalog/dotnet - run: dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release - - name: .NET test - working-directory: policy-catalog/dotnet - run: dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release --no-build - - name: .NET functional (packed .nupkg from a local feed, consumer outside the repo) - run: node scripts/dotnet-functional.mjs - - # --- Cross-language --------------------------------------------------- - - name: Cross-language check (TS, Rust, C# CLIs byte-identical) - run: node scripts/cross-language-check.mjs - - - name: Extractability (policy-catalog/ as a standalone repository) - if: matrix.label == 'linux-x64' - run: npm run check:extract diff --git a/policy-catalog/.github/workflows/ci.yml b/policy-catalog/.github/workflows/ci.yml deleted file mode 100644 index e13e7220a..000000000 --- a/policy-catalog/.github/workflows/ci.yml +++ /dev/null @@ -1,103 +0,0 @@ -# CI for the standalone policy catalog repository. Mirrors the in-repo -# prototype workflow (.github/workflows/PolicyCatalog.Prototype.yml in MXC): -# one job per OS x architecture runs TypeScript, Rust, and .NET, then the -# cross-language check with the binaries built on that host. -name: Policy Catalog CI - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -jobs: - check: - name: Check (${{ matrix.label }}) - strategy: - fail-fast: false - matrix: - include: - - label: linux-x64 - runner: ubuntu-latest - - label: linux-arm64 - runner: ubuntu-24.04-arm - - label: macos-arm64 - runner: macos-latest - - label: macos-x64 - runner: macos-15-intel - - label: windows-x64 - runner: windows-latest - - label: windows-arm64 - runner: windows-11-arm - runs-on: ${{ matrix.runner }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - cache: npm - cache-dependency-path: package-lock.json - - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: dotnet/global.json - - - name: Rust toolchain - run: node scripts/ci-ensure-rustup.mjs - - - name: Host facts - run: | - echo "runner.arch=${{ runner.arch }} uname=$(uname -m)" - node -p "process.arch + ' ' + require('os').machine()" - (cd rust && rustc -vV) - dotnet --info | head -n 20 - - # --- TypeScript ------------------------------------------------------- - - run: npm ci - - name: TypeScript check (typecheck, unit, functional from the tarball, validate, pack, smoke) - run: npm run check - - name: Published-revision immutability - if: github.event_name == 'pull_request' - # Passed through env, never interpolated into the script. - env: - POLICY_CATALOG_BASE_REF: origin/${{ github.base_ref }} - run: npm run validate -- "--base-ref=$POLICY_CATALOG_BASE_REF" - - # --- Rust ------------------------------------------------------------- - - name: Rust fmt - working-directory: rust - run: cargo fmt --check - - name: Rust clippy - working-directory: rust - run: cargo clippy --locked --all-targets --all-features -- -D warnings - - name: Rust test - working-directory: rust - run: cargo test --locked - - name: Rust functional (packaged .crate in a consumer outside the repo) - run: node scripts/rust-functional.mjs - - # --- .NET ------------------------------------------------------------- - - name: .NET build - working-directory: dotnet - run: dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release - - name: .NET test - working-directory: dotnet - run: dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release --no-build - - name: .NET functional (packed .nupkg from a local feed, consumer outside the repo) - run: node scripts/dotnet-functional.mjs - - # --- Cross-language --------------------------------------------------- - - name: Cross-language check (TS, Rust, C# CLIs byte-identical) - run: node scripts/cross-language-check.mjs - - - name: Extractability (HEAD tree as a standalone repository) - if: matrix.label == 'linux-x64' - run: npm run check:extract diff --git a/policy-catalog/.gitignore b/policy-catalog/.gitignore deleted file mode 100644 index 40a10b247..000000000 --- a/policy-catalog/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -node_modules/ -dist/ -*.tgz -# Rust build output and packages (see also rust/.gitignore) -/rust/target/ -*.crate -# .NET build output and packages (see also dotnet/.gitignore; bin/ and obj/ -# are scoped there so rust/src/bin/ stays tracked) -*.nupkg -*.snupkg -TestResults/ -# The enclosing MXC repository ignores every `[Bb]in/`; Cargo binary targets -# live in rust/src/bin/, so re-include it (no effect once extracted). -!/rust/src/bin/ diff --git a/policy-catalog/LICENSE.md b/policy-catalog/LICENSE.md deleted file mode 100644 index 79656060d..000000000 --- a/policy-catalog/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE \ No newline at end of file diff --git a/policy-catalog/README.md b/policy-catalog/README.md deleted file mode 100644 index b6fe9317e..000000000 --- a/policy-catalog/README.md +++ /dev/null @@ -1,204 +0,0 @@ -# Known-tool policy catalog (prototype) - -> **Prototype.** This directory prototypes the standalone catalog and library -> described in the [Known-tool Policy Floors design proposal](docs/design.md), -> which is proposed in MXC as `docs/mxc-policy-store.md` -> ([microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309)). It is not -> an approved, shipped, or supported catalog. The design calls the catalog a -> time-limited bridge that is expected to be retired when Learning Mode -> provides the replacement workflow. - -This directory is laid out as its own repository. It has no imports from, and -no build or test dependency on, the enclosing MXC tree. You can extract it -with: - -```text -git filter-repo --subdirectory-filter policy-catalog -``` - -`npm run check:extract` proves this by building and testing a fresh copy. - -## Layout - -| Path | Contents | -|---|---| -| `docs/design.md` | Verbatim copy of the design proposal (authoritative). Its relative links point into the MXC repository; the header lists absolute equivalents. | -| `docs/architecture.md` | How this prototype implements the design. | -| `docs/contributing.md` | Catalog and library contribution flow, validation rules, and test layout. | -| `catalog/contract.v1.json` | Catalog contract v1: registered `SandboxPolicy` versions and the symbol vocabulary. | -| `catalog/manifest.json` | Append-only list of published revisions, each with its canonical SHA-256, plus the default revision. | -| `catalog/revisions/.json` | Immutable published catalog revisions. | -| `schema/` | JSON Schemas for revisions and the manifest. | -| `conformance/fixtures/` | Language-neutral conformance cases that every language binding must pass. | -| `conformance/vectors/` | Generated path-normalization and canonical-JSON vectors (`scripts/generate-vectors.mjs`) shared by every language. | -| `src/` | TypeScript/JavaScript library and the `policy-catalog` CLI. | -| `rust/` | Rust crate `mxc-policy-catalog` (library and `policy-catalog` binary). Its own Cargo `[workspace]`. | -| `dotnet/` | .NET library `Microsoft.Mxc.PolicyCatalog` (net8.0), its CLI, and tests. | -| `tests/unit/` | Unit and conformance tests (`node:test`), with their own `tsconfig.json` and `run-tests.js`. | -| `tests/functional/` | End-to-end tests against the packed and installed tarball: the installed CLI and library, the real catalog, and tampered or synthetic catalogs. Own `tsconfig.json` and `run-tests.js`. | -| `scripts/` | Validation, digest, package, extractability, per-language functional, and cross-language checks. | -| `.github/workflows/ci.yml` | CI for the standalone repository. | - -## Library API - -Runtime lookup is kept separate from inspection -([design §5](docs/design.md#5-api-surface)): - -```ts -import { - resolveSandboxPolicy, - resolveSandboxPolicyWithDiagnostics, - getCatalogInfo, - listCatalogEntries, -} from '@mxc-prototype/policy-catalog'; - -const ctx = { - allowWeakIdentityFallback: true, - projectRoot: '/work/app', - symbols: { git_prefix: '/usr/bin', npm_prefix: '/usr/local/bin', npm_cache: '/home/me/.npm', node_prefix: '/usr/local/bin' }, -}; - -resolveSandboxPolicy('npm', ctx); // one tool -> SandboxPolicy | undefined -resolveSandboxPolicy(['git', 'npm'], ctx); // several tools -> one composed policy -resolveSandboxPolicyWithDiagnostics(['git', 'npm'], ctx); // same policy plus attribution and warnings -resolveSandboxPolicy({ invocationName: 'npx', packageUrl: 'pkg:npm/npm@10.9.0' }, ctx); - -getCatalogInfo(); // { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' } -listCatalogEntries(); // metadata only, never policy bodies -``` - -The same operations are available from the command line. The CLI has -exactly three commands: - -| Command | Purpose | -|---|---| -| `policy-catalog resolve [options] ...` | `resolveSandboxPolicy`, or `resolveSandboxPolicyWithDiagnostics` with `--diagnostics`. Options: `--platform`, `--architecture`, `--revision`, `--project-root`, `--symbol name=value`, `--allow-weak`, and `--purl` / `--detected-version` for the next tool. | -| `policy-catalog inspect` | `getCatalogInfo()` and `listCatalogEntries()`: metadata only, never a policy body. | -| `policy-catalog validate [--base-ref REF]` | Integrity, the catalog contract (including dependency cycles), and entry-revision history. With `--base-ref`, also checks that every revision published at `REF` is unchanged. | - -Every command accepts `--catalog DIR` to use a catalog directory instead of the -bundled one. Output is JSON on stdout. Exit codes: `0` success (including "no -policy"), `1` library failure or failed validation, `2` usage error. - -```text -npx policy-catalog resolve --diagnostics --allow-weak --project-root /work/app \ - --symbol git_prefix=/usr/bin git -npx policy-catalog validate --catalog ./catalog --base-ref origin/main -``` - -### Results - -- The result is a candidate **lower bound**, not authorization. Consumers - decide whether lookup is enabled, and own authorization, elevation, - persistence, composition with their own layers and ceilings, approval, - audit, and sandbox creation - ([design §2](docs/design.md#2-ownership-boundary), - [design §5.3](docs/design.md#53-consumer-obligations)). -- Absence is not an empty policy. `resolveSandboxPolicy` returns `undefined` for - an empty input, when no input matched, or when a selected entry has an - unresolved required symbol. `resolveSandboxPolicyWithDiagnostics` returns - `policy: undefined` with the diagnostics. -- Library failures throw `PolicyCatalogError` with an MXC error `code` - (`policy_validation`, `malformed_request`, `unsupported_containment`, - `backend_error`) and a stable `details.reason`. Warnings are plain strings. - See [architecture](docs/architecture.md#failure-codes). - -### Matching and selection - -- Matching is additive: every eligible matching entry contributes, and each - contributes once. -- `purl` identity is strong. Invocation-name-only identity is weak and is - **off by default**; a caller opts in per call with - `allowWeakIdentityFallback: true` (CLI `--allow-weak`). -- The catalog records strong evidence (package URL, signer). **Verifying that - the binary about to run actually has that strong identity is the caller's - job**; the library cannot see the binary. -- Casing follows the target OS: Windows and macOS compare invocation names and - paths case-insensitively; Linux compares them exactly (`gh` is not `GH`). - Catalog validation always treats names case-insensitively when it checks for - duplicates. -- An omitted `platform` uses the host. An omitted `architecture` uses the - device's native system architecture, not the process architecture, and - diagnostics warn that the tool's architecture was not verified. An omitted - `catalogRevision` uses the installed default. An unavailable explicit - revision is an error. -- Lookup is local and synchronous. It makes no network calls and does not run - the candidate tool. - -## Development - -Requires Node.js 24 or later. - -```text -npm ci -npm run check # typecheck, unit, functional (installed tarball), catalog validation, pack contents, install smoke -npm run test:unit -npm run test:functional # packs, installs the tarball in a temp project, and tests the installed CLI and library -npm run validate # add -- --base-ref= (or POLICY_CATALOG_BASE_REF=) to enforce published-revision immutability -npm run check:pack -npm run check:extract # build and test HEAD's copy of this directory as a standalone repository -``` - -The Rust and .NET libraries are developed with their own toolchains; see -[docs/contributing.md](docs/contributing.md) for their commands. The -cross-language check builds all three CLIs and requires identical normalized -JSON output from each: - -```text -npm run build -node scripts/cross-language-check.mjs -``` - -See [docs/contributing.md](docs/contributing.md) before changing catalog data. - -## Status: implemented and deferred - -**Implemented (TypeScript, Rust, and C#/.NET):** - -- the four version dimensions -- canonical-digest integrity -- immutable, deep-frozen revisions with CI immutability and monotonicity - checks -- additive strong/weak identity matching with opt-in weak fallback -- platform and architecture variants with native-architecture defaults and - diagnostics -- deterministic, cycle-rejecting dependency resolution -- v1 filesystem-only composition across inputs and dependencies -- the policy-only and diagnostics APIs (single tool or array) -- metadata inspection -- the CLI harness (`resolve | inspect | validate`) -- the contribution validation pipeline -- package inclusion and offline install checks -- the extractability check -- native Rust (crate `mxc-policy-catalog`, `rust/`) and C#/.NET (NuGet - `Microsoft.Mxc.PolicyCatalog`, net8.0, `dotnet/`) libraries and CLIs, each - with unit, conformance, and packaged-artifact functional tests -- a cross-language check that requires byte-identical normalized CLI output - from all three languages, run on Linux, macOS, and Windows (x64 and arm64) - in CI - -**Deferred:** - -- Publishing to crates.io or NuGet, signing, and versioning policy for the - Rust and .NET packages - ([design §6.1](docs/design.md#61-library-distribution-and-consumption)). - Both are packaged and consumed only from local artifacts here. -- Validation of embedded policies against MXC's real released `SandboxPolicy` - schemas ([design §4.2](docs/design.md#42-entry-shape)). The prototype - validates a catalog-supported subset and pins `0.8.0-alpha` and - `0.9.0-alpha` in `catalog/contract.v1.json`. -- Integration tests that run a real tool under an MXC sandbox - ([design §12](docs/design.md#12-test-plan), "Integration"). -- Verification on emulated hosts (x64 code on ARM64). CI runs natively on - ARM64 runners; emulation is covered only with an injected host. -- Private or enterprise overlays - ([design §13](docs/design.md#13-open-questions)). -- Named-role review enforcement. That belongs to repository governance, not - CI. -- Reviewed catalog data. `tool:git`, `tool:node`, and `tool:npm` were migrated - from an earlier prototype and have not been re-observed - (`provenance.method: prototype-migration`). - -## License - -MIT. See [LICENSE.md](LICENSE.md). diff --git a/policy-catalog/dotnet/.gitignore b/policy-catalog/dotnet/.gitignore deleted file mode 100644 index 8b8f996ac..000000000 --- a/policy-catalog/dotnet/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -bin/ -obj/ -*.nupkg -*.snupkg -TestResults/ diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj deleted file mode 100644 index b4567ab0a..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Microsoft.Mxc.PolicyCatalog.Cli.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net8.0 - Exe - enable - enable - latest - true - Microsoft.Mxc.PolicyCatalog.Cli - policy-catalog - false - - false - - - - - - - diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs deleted file mode 100644 index c1e66d08c..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Cli/Program.cs +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Command-line harness over the public library API, output-equivalent to the -// TypeScript `policy-catalog` CLI (src/cli.ts): -// -// policy-catalog resolve [--catalog DIR] [--diagnostics] [--platform P] -// [--architecture A] [--revision R] [--project-root PATH] -// [--symbol name=value]... [--allow-weak] -// [--purl URL] [--detected-version V] ... -// policy-catalog inspect [--catalog DIR] -// policy-catalog validate [--catalog DIR] [--base-ref REF] -// -// Exit codes: 0 success (including "no policy"), 1 library failure or failed -// validation, 2 usage error. Output is JSON (2-space indent) on stdout. -using System.Text; -using Microsoft.Mxc.PolicyCatalog; - -namespace Microsoft.Mxc.PolicyCatalog.Cli; - -internal sealed class UsageException : Exception -{ - public UsageException(string message) - : base(message) - { - } -} - -internal static class Program -{ - private const string Usage = "usage: policy-catalog [options]"; - - private static readonly Encoding Utf8 = new UTF8Encoding(false); - - public static int Main(string[] argv) - { - try - { - return Run(argv); - } - catch (Exception error) - { - Write(Console.OpenStandardError(), $"{error}\n"); - return 1; - } - } - - private static void Write(Stream stream, string text) - { - var bytes = Utf8.GetBytes(text); - stream.Write(bytes, 0, bytes.Length); - stream.Flush(); - } - - private static void Print(string json) - { - using var stdout = Console.OpenStandardOutput(); - Write(stdout, json + "\n"); - } - - private static string TakeValue(IReadOnlyList args, int index, string flag) - { - if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal)) - { - throw new UsageException($"{flag} requires a value"); - } - - return args[index + 1]; - } - - private static CatalogStore StoreFrom(string? catalogDir) => - catalogDir is null ? CatalogStore.Bundled() : CatalogStore.FromDirectory(Path.GetFullPath(catalogDir)); - - private static void RejectRest(List args, string command) - { - if (args.Count > 0) - { - throw new UsageException($"{command}: unexpected argument '{args[0]}'"); - } - } - - internal static int Run(string[] argv) - { - var command = argv.Length > 0 ? argv[0] : null; - var rest = argv.Skip(1).ToList(); - try - { - string? catalogDir = null; - string? baseRef = null; - var args = new List(); - for (var i = 0; i < rest.Count; i++) - { - if (rest[i] == "--catalog") - { - catalogDir = TakeValue(rest, i, "--catalog"); - i++; - } - else if (rest[i] == "--base-ref" && command == "validate") - { - baseRef = TakeValue(rest, i, "--base-ref"); - i++; - } - else - { - args.Add(rest[i]); - } - } - - switch (command) - { - case "inspect": - { - RejectRest(args, command); - var catalog = new PolicyCatalog(StoreFrom(catalogDir)); - var info = catalog.GetCatalogInfo(); - var entries = catalog.ListCatalogEntries(); - Print(PolicyCatalogJson.SerializeInspect(info, entries, 2)); - return 0; - } - - case "validate": - { - RejectRest(args, command); - var report = CatalogValidation.ValidateCatalogDirectory(catalogDir ?? CatalogValidation.BundledCatalogDirectory, baseRef); - Print(PolicyCatalogJson.Serialize(report, 2)); - return report.Ok ? 0 : 1; - } - - case "resolve": - return Resolve(args, catalogDir); - default: - throw new UsageException(command is null ? "missing command" : $"unknown command '{command}'"); - } - } - catch (UsageException error) - { - using var stderr = Console.OpenStandardError(); - Write(stderr, $"policy-catalog: {error.Message}\n{Usage}\n"); - return 2; - } - catch (PolicyCatalogException error) - { - Print(PolicyCatalogJson.Serialize(error, 2)); - return 1; - } - } - - private static int Resolve(List args, string? catalogDir) - { - string? platform = null; - string? architecture = null; - string? revision = null; - string? projectRoot = null; - var allowWeak = false; - // Insertion-ordered; a repeated name keeps its first position and takes the last value. - var symbolOrder = new List(); - var symbolValues = new Dictionary(StringComparer.Ordinal); - var tools = new List(); - var diagnostics = false; - string? purl = null; - string? detectedVersion = null; - for (var i = 0; i < args.Count; i++) - { - var arg = args[i]; - switch (arg) - { - case "--diagnostics": - diagnostics = true; - break; - case "--allow-weak": - allowWeak = true; - break; - case "--platform": - platform = TakeValue(args, i++, arg); - break; - case "--architecture": - architecture = TakeValue(args, i++, arg); - break; - case "--revision": - revision = TakeValue(args, i++, arg); - break; - case "--project-root": - projectRoot = TakeValue(args, i++, arg); - break; - case "--symbol": - { - var pair = TakeValue(args, i++, arg); - var eq = pair.IndexOf('=', StringComparison.Ordinal); - if (eq <= 0) - { - throw new UsageException("--symbol expects name=value"); - } - - var name = pair.Substring(0, eq); - if (!symbolValues.ContainsKey(name)) - { - symbolOrder.Add(name); - } - - symbolValues[name] = pair.Substring(eq + 1); - break; - } - - case "--purl": - purl = TakeValue(args, i++, arg); - break; - case "--detected-version": - detectedVersion = TakeValue(args, i++, arg); - break; - default: - if (arg.StartsWith("--", StringComparison.Ordinal)) - { - throw new UsageException($"unknown option '{arg}'"); - } - - // --purl / --detected-version apply to the next tool name only. - tools.Add(new ToolInput(arg) { PackageUrl = purl, DetectedVersion = detectedVersion }); - purl = null; - detectedVersion = null; - break; - } - } - - if (purl is not null || detectedVersion is not null) - { - throw new UsageException("--purl/--detected-version must precede a tool name"); - } - - var context = new ResolveContext - { - Platform = platform, - Architecture = architecture, - CatalogRevision = revision, - ProjectRoot = projectRoot, - AllowWeakIdentityFallback = allowWeak, - Symbols = symbolOrder.Count > 0 ? new OrderedSymbols(symbolOrder, symbolValues) : null, - }; - var catalog = new PolicyCatalog(StoreFrom(catalogDir)); - if (diagnostics) - { - Print(PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(tools, context), 2)); - } - else - { - // `null` is JSON's rendering of "no policy"; it is never an empty policy. - Print(PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicy(tools, context), 2)); - } - - return 0; - } - - /// A read-only dictionary that enumerates in insertion order. - private sealed class OrderedSymbols : IReadOnlyDictionary - { - private readonly List _order; - private readonly Dictionary _values; - - public OrderedSymbols(List order, Dictionary values) - { - _order = order; - _values = values; - } - - public string? this[string key] => _values[key]; - - public IEnumerable Keys => _order; - - public IEnumerable Values => _order.Select(key => _values[key]); - - public int Count => _order.Count; - - public bool ContainsKey(string key) => _values.ContainsKey(key); - - public bool TryGetValue(string key, out string? value) => _values.TryGetValue(key, out value); - - public IEnumerator> GetEnumerator() => - _order.Select(key => new KeyValuePair(key, _values[key])).GetEnumerator(); - - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs deleted file mode 100644 index 2a269cfbd..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ConformanceTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using System.Text.Json.Nodes; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// Runs every case in conformance/fixtures/*.json (the TypeScript conformance runner's rules). -public sealed class ConformanceTests -{ - public static TheoryData Cases() - { - var data = new TheoryData(); - foreach (var resource in TestData.Resources("conformance/fixtures/")) - { - using var doc = JsonDocument.Parse(TestData.Resource(resource)); - var index = 0; - foreach (var testCase in doc.RootElement.GetProperty("cases").EnumerateArray()) - { - data.Add(resource, index++, testCase.GetProperty("name").GetString()!); - } - } - - return data; - } - - [Fact] - public void EveryFixtureFileHasCases() - { - var files = TestData.Resources("conformance/fixtures/").ToList(); - Assert.Contains("conformance/fixtures/bundled-catalog.json", files); - Assert.Contains("conformance/fixtures/synthetic-catalog.json", files); - Assert.True(Cases().Count >= 20); - } - - [Theory] - [MemberData(nameof(Cases))] - public void Case(string file, int index, string name) - { - Assert.False(string.IsNullOrEmpty(name)); - using var doc = JsonDocument.Parse(TestData.Resource(file)); - var root = doc.RootElement; - var testCase = root.GetProperty("cases")[index]; - var hostPlatform = "linux"; - var hostArchitecture = "x64"; - if (testCase.TryGetProperty("host", out var host)) - { - if (host.TryGetProperty("platform", out var p)) - { - hostPlatform = p.GetString()!; - } - - if (host.TryGetProperty("nativeArchitecture", out var a)) - { - hostArchitecture = a.GetString()!; - } - } - - var fixedHost = new FixedHost(hostPlatform, hostArchitecture); - var catalogElement = root.GetProperty("catalog"); - var catalog = catalogElement.ValueKind == JsonValueKind.String && catalogElement.GetString() == "bundled" - ? TestData.Bundled(fixedHost) - : TestData.CatalogFor(JsonNode.Parse(catalogElement.GetRawText())!, fixedHost); - var toolsElement = testCase.GetProperty("tools"); - var tools = TestData.Tools(toolsElement); - JsonElement? contextElement = testCase.TryGetProperty("context", out var c) ? c : null; - var context = TestData.Context(contextElement); - - if (testCase.TryGetProperty("expectError", out var expectError)) - { - var code = expectError.GetProperty("code").GetString(); - var reason = expectError.GetProperty("reason").GetString(); - var withDiagnostics = TestData.Failure(() => catalog.ResolveSandboxPolicyWithDiagnostics(tools, context)); - Assert.Equal((code, reason), (withDiagnostics.Code, withDiagnostics.Reason)); - var policyOnly = TestData.Failure(() => catalog.ResolveSandboxPolicy(tools, context)); - Assert.Equal((code, reason), (policyOnly.Code, policyOnly.Reason)); - return; - } - - var expect = JsonNode.Parse(testCase.GetProperty("expect").GetRawText())!.AsObject(); - var expectedPolicy = expect["policy"]; - if (expectedPolicy is null) - { - // JSON.stringify omits an undefined policy. - expect.Remove("policy"); - } - - var expected = TestData.Canonical(expect.ToJsonString()); - var result = catalog.ResolveSandboxPolicyWithDiagnostics(tools, context); - Assert.Equal(expected, TestData.Canonical(PolicyCatalogJson.Serialize(result))); - - var policy = catalog.ResolveSandboxPolicy(tools, context); - Assert.Equal(TestData.Canonical(expectedPolicy?.ToJsonString() ?? "null"), TestData.Canonical(PolicyCatalogJson.Serialize(policy))); - Assert.Equal(result.Policy is null, policy is null); - - if (TestData.IsSingle(toolsElement)) - { - var single = catalog.ResolveSandboxPolicyWithDiagnostics(tools[0], context); - Assert.Equal(expected, TestData.Canonical(PolicyCatalogJson.Serialize(single))); - Assert.Equal( - TestData.Canonical(PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicy(tools[0], context))), - TestData.Canonical(PolicyCatalogJson.Serialize(policy))); - } - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj deleted file mode 100644 index a56bde5d7..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/Microsoft.Mxc.PolicyCatalog.Tests.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - net8.0 - - Exe - enable - enable - latest - false - true - - - - - - - - - - - - - - - - - diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs deleted file mode 100644 index b66ddcc3e..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/PrimitivesTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Globalization; -using Microsoft.Mxc.PolicyCatalog.Internal; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// Version ranges, package URLs, and the JavaScript semantics the port reproduces. -public sealed class PrimitivesTests -{ - [Theory] - [InlineData(">=10 <12", true)] - [InlineData("22", true)] - [InlineData(">=1.2.3", true)] - [InlineData("=1.0.0", true)] - [InlineData("<2 || >=4", true)] - [InlineData("", false)] - [InlineData("^1.2.3", false)] - [InlineData("~1", false)] - [InlineData("1.x", false)] - [InlineData(">= 1", false)] - [InlineData("1 ||", false)] - [InlineData("latest", false)] - public void VersionRangeGrammar(string range, bool valid) => Assert.Equal(valid, VersionRange.IsValid(range)); - - [Theory] - [InlineData("10.9.0", ">=10 <12", true)] - [InlineData("v12.0.0", ">=10 <12", false)] - [InlineData("22.3.1", "22", true)] - [InlineData("23.0.0", "22", false)] - [InlineData("3.0.0", "<2 || >=3", true)] - [InlineData("10.0.0-rc.1", ">=10", true)] - [InlineData(" 10.1 ", ">=10.1", true)] - [InlineData("nightly", ">=10", null)] - [InlineData("10.0.0", "bogus", null)] - public void VersionRangeEvaluation(string version, string range, bool? expected) => Assert.Equal(expected, VersionRange.Satisfies(version, range)); - - [Fact] - public void PackageUrls() - { - Assert.Equal(new ParsedPurl("npm/npm", "10.9.0"), Purl.Parse("pkg:npm/npm@10.9.0")); - Assert.Equal(new ParsedPurl("npm/%40scope/pkg", "1.0.0"), Purl.Parse("pkg:NPM/%40scope/pkg@1.0.0?x=y#sub")); - Assert.Equal(new ParsedPurl("npm/npm", null), Purl.Parse("pkg:npm/npm")); - Assert.Equal(new ParsedPurl("npm/npm", null), Purl.Parse("pkg:npm/npm@")); - Assert.Equal(new ParsedPurl("npm/npm", "1 2\u00e9"), Purl.Parse("pkg:npm/npm@1%202%C3%A9")); - Assert.Null(Purl.Parse("npm/npm")); - Assert.Null(Purl.Parse("pkg:npm")); - Assert.Null(Purl.Parse("pkg:1npm/x")); - Assert.Null(Purl.Parse("pkg:npm//x")); - Assert.Null(Purl.Parse("pkg:npm/npm@%E0%A4%A")); - Assert.Null(Purl.Parse("pkg:npm/npm@%ED%A0%80")); - Assert.Null(Purl.Parse("pkg:npm/npm@%zz")); - } - - [Theory] - [InlineData(0.0, "0")] - [InlineData(1e21, "1e+21")] - [InlineData(1e20, "100000000000000000000")] - [InlineData(1e-7, "1e-7")] - [InlineData(0.000001, "0.000001")] - [InlineData(12345678901234567890.0, "12345678901234567000")] - [InlineData(5e-324, "5e-324")] - [InlineData(1.7976931348623157e308, "1.7976931348623157e+308")] - [InlineData(-1234.5678, "-1234.5678")] - [InlineData(0.30000000000000004, "0.30000000000000004")] - [InlineData(123e-20, "1.23e-18")] - [InlineData(1.5, "1.5")] - public void EcmaScriptNumberToString(double value, string expected) - { - Assert.Equal(expected, JsNumber.ToString(value)); - Assert.Equal("0", JsNumber.ToString(-0.0)); - } - - [Fact] - public void NumberFormattingMatchesRoundTripForManyValues() - { - var random = new Random(1234); - for (var i = 0; i < 20000; i++) - { - var value = BitConverter.Int64BitsToDouble(random.NextInt64()); - if (!double.IsFinite(value)) - { - continue; - } - - var text = JsNumber.ToString(value); - Assert.Equal(value == 0 ? 0 : value, double.Parse(text, CultureInfo.InvariantCulture)); - } - } - - [Fact] - public void StringEscapingMatchesJsonStringify() - { - Assert.Equal("\"\\u0000\\b\\t\\n\\f\\r\\u001f \\\" \\\\ / \u007f \u2028 \u00e9\"", JsonText.Quote("\0\b\t\n\f\r\u001f \" \\ / \u007f \u2028 \u00e9")); - Assert.Equal("\"\\ud800x\\udc00\ud83d\ude00\"", JsonText.Quote("\ud800x\udc00\ud83d\ude00")); - } - - [Fact] - public void ParsingKeepsJavaScriptKeyOrderAndDuplicateSemantics() - { - var parsed = (JsonObject)JsonText.Parse("""{"b":1,"2":0,"a":2,"1":0,"b":3}"""); - Assert.Equal(new[] { "1", "2", "b", "a" }, parsed.Keys); - Assert.Equal(3.0, ((JsonNumber)parsed.Get("b")!).Value); - Assert.Equal("""{"1":0,"2":0,"b":3,"a":2}""", JsonText.Stringify(parsed)); - Assert.ThrowsAny(() => JsonText.Parse("{\"a\":1,}")); - Assert.ThrowsAny(() => JsonText.Parse("{} x")); - } - - [Theory] - [InlineData("GIT.EXE", "git.exe")] - [InlineData("\u00c9T\u00c9", "\u00e9t\u00e9")] - [InlineData("\u0130", "i\u0307")] - [InlineData("\u0391\u03a3", "\u03b1\u03c2")] - [InlineData("\u0391\u03a3\u0391", "\u03b1\u03c3\u03b1")] - [InlineData("\u03a3", "\u03c3")] - [InlineData("\ua7cb", "\u0264")] - [InlineData("\u212a", "k")] - public void LowerCasingMatchesJavaScript(string input, string expected) => Assert.Equal(expected, JsString.ToLower(input)); - - [Theory] - [InlineData("AMD64", "x64")] - [InlineData("x86_64", "x64")] - [InlineData(" x64\n", "x64")] - [InlineData("ARM64", "arm64")] - [InlineData("aarch64", "arm64")] - [InlineData("riscv64", null)] - [InlineData("x86", null)] - public void MachineNamesMapToSelectors(string machine, string? expected) => Assert.Equal(expected, SystemHostEnvironment.ArchitectureFromMachine(machine)); - - [Fact] - public void SystemHostDetectsANativeArchitectureOrFailsWithUnsupportedHost() - { - var host = SystemHostEnvironment.Instance; - Assert.Contains(host.Platform(), CatalogPlatforms.All); - try - { - Assert.Contains(host.NativeArchitecture(), CatalogArchitectures.All); - } - catch (PolicyCatalogException error) - { - Assert.Equal("unsupported_host", error.Reason); - } - - Assert.Null(host.Symbol("git_prefix")); - Assert.NotNull(host.Symbol("temp_dir")); - } - - [Fact] - public void ErrorCodesMapLikeTypeScript() - { - var expected = new Dictionary - { - [PolicyCatalogErrorReason.InvalidCatalog] = ("invalid_catalog", "policy_validation"), - [PolicyCatalogErrorReason.CompositionConflict] = ("composition_conflict", "policy_validation"), - [PolicyCatalogErrorReason.InvalidContext] = ("invalid_context", "malformed_request"), - [PolicyCatalogErrorReason.UnsupportedHost] = ("unsupported_host", "unsupported_containment"), - [PolicyCatalogErrorReason.Integrity] = ("integrity", "backend_error"), - [PolicyCatalogErrorReason.RevisionUnavailable] = ("revision_unavailable", "backend_error"), - }; - foreach (var (reason, (wire, code)) in expected) - { - var error = new PolicyCatalogException(reason, "m"); - Assert.Equal((wire, code, $"[{code}] m"), (error.Reason, error.Code, error.Message)); - } - - Assert.Equal( - "{\"error\":{\"code\":\"backend_error\",\"message\":\"[backend_error] m\",\"details\":{\"reason\":\"integrity\"}}}", - PolicyCatalogJson.Serialize(new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, "m"))); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs deleted file mode 100644 index 3d5fd75ba..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ResolverTests.cs +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// Port of tests/unit/resolver.test.ts. -public sealed class ResolverTests -{ - private static readonly ResolveContext Weak = new() { AllowWeakIdentityFallback = true }; - - private static PolicyCatalog ArchCatalog(IHostEnvironment? host = null) => TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:a", """ - { "identity": [{ "kind": "purl", "value": "pkg:npm/a" }, { "kind": "invocation-name", "names": ["a"] }], - "platformVariants": [ - { "when": { "platform": "windows", "architecture": "x64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "timeoutMs": 1 } }, - { "when": { "platform": "windows", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "timeoutMs": 2 } } ] } - """), - }), host ?? new FixedHost("windows", "arm64")); - - private static string Warnings(SandboxConfigResolution result) => string.Join("\n", result.Diagnostics.Warnings); - - [Fact] - public void OmittedContextUsesHostDefaultsAndNoWeakFallback() - { - var catalog = ArchCatalog(); - Assert.Null(catalog.ResolveSandboxPolicy("a")); - var result = catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }); - Assert.Equal(2.0, result.Policy?.TimeoutMs); - Assert.Equal("2000-01-01.1", result.Diagnostics.CatalogRevision); - Assert.Contains("native system architecture 'arm64'; the tool's architecture was not verified", Warnings(result), StringComparison.Ordinal); - } - - [Fact] - public void ExplicitArchitectureWinsAndSuppressesTheWarning() - { - var result = ArchCatalog().ResolveSandboxPolicyWithDiagnostics(new ToolInput("a") { PackageUrl = "pkg:npm/a" }, new ResolveContext { Architecture = "x64" }); - Assert.Equal(1.0, result.Policy?.TimeoutMs); - Assert.Empty(result.Diagnostics.Warnings); - } - - [Fact] - public void AnotherArchitectureIsNeverAFallback() - { - var catalog = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), - })); - var result = catalog.ResolveSandboxPolicyWithDiagnostics("a", Weak with { Architecture = "x64" }); - Assert.Null(result.Policy); - Assert.Equal("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/x64", result.Diagnostics.Warnings[0]); - } - - [Fact] - public void ArchitectureDetectionFailureIsALibraryErrorAndIsLazy() - { - var host = new FixedHost("linux", () => throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, "unknown machine")); - var catalog = new PolicyCatalog(TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:t") }) }), host); - var error = TestData.Failure(() => catalog.ResolveSandboxPolicy("t", Weak with { ProjectRoot = "/p" })); - Assert.Equal(("unsupported_containment", "unsupported_host"), (error.Code, error.Reason)); - Assert.Null(catalog.ResolveSandboxPolicy("nothing", Weak)); - var explicitArch = catalog.ResolveSandboxPolicy("t", Weak with { Architecture = "x64", ProjectRoot = "/p" }); - Assert.Equal(new[] { "/p" }, explicitArch?.Filesystem?.ReadwritePaths); - } - - [Fact] - public void NeverFabricatesSymbols() - { - var result = TestData.Bundled().ResolveSandboxPolicyWithDiagnostics("git", Weak with { Architecture = "x64" }); - Assert.Null(result.Policy); - Assert.Matches("(?s)required symbol 'git_prefix'.*required symbol 'project_root'", Warnings(result)); - } - - [Fact] - public void HostSymbolsOnlyForTheCurrentHostPlatform() - { - var revision = TestData.Revision(new[] - { - TestData.Entry("tool:t", """ - { "platformVariants": [ - { "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${user_home}/.cfg"] } } }, - { "when": { "platform": "macos" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${user_home}/.cfg"] } } } ] } - """), - }); - var catalog = TestData.CatalogFor(revision, new FixedHost("linux", "x64", new Dictionary { ["user_home"] = "/home/me" })); - Assert.Equal(new[] { "/home/me/.cfg" }, catalog.ResolveSandboxPolicy("t", Weak)?.Filesystem?.ReadonlyPaths); - Assert.Null(catalog.ResolveSandboxPolicy("t", Weak with { Platform = "macos", Architecture = "arm64" })); - Assert.Equal(new[] { "/srv/u/.cfg" }, catalog.ResolveSandboxPolicy("t", Weak with { Symbols = new Dictionary { ["user_home"] = "/srv/u" } })?.Filesystem?.ReadonlyPaths); - } - - [Fact] - public void StringObjectAndOneElementListAreEquivalent() - { - var catalog = TestData.Bundled(); - var ctx = Weak with { Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["git_prefix"] = "/g" } }; - var a = PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics("git", ctx)); - Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("git"), ctx))); - Assert.Equal(a, PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput[] { "git" }, ctx))); - Assert.Null(catalog.ResolveSandboxPolicy("git", ctx with { AllowWeakIdentityFallback = false })); - } - - public static TheoryData InvalidInputs() => new() - { - { "empty name", "", new ResolveContext() }, - { "path", "/usr/bin/git", new ResolveContext() }, - { "bad purl", new ToolInput("npm") { PackageUrl = "npm" }, new ResolveContext() }, - { "empty version", new ToolInput("npm") { DetectedVersion = "" }, new ResolveContext() }, - { "null input", null, new ResolveContext() }, - { "platform", "git", new ResolveContext { Platform = "plan9" } }, - { "architecture", "git", new ResolveContext { Architecture = "x86" } }, - { "unknown symbol", "git", new ResolveContext { Symbols = new Dictionary { ["nope"] = "/x" } } }, - { "context symbol", "git", new ResolveContext { Symbols = new Dictionary { ["project_root"] = "/x" } } }, - { "null symbol", "git", new ResolveContext { Symbols = new Dictionary { ["git_prefix"] = null } } }, - { "proto symbol", "git", new ResolveContext { Symbols = new Dictionary { ["__proto__"] = "/x" } } }, - { "empty root", "git", new ResolveContext { ProjectRoot = "" } }, - }; - - [Theory] - [MemberData(nameof(InvalidInputs))] - public void InvalidInputsAreLibraryFailures(string label, ToolInput? tool, ResolveContext ctx) - { - Assert.NotEmpty(label); - Assert.Equal("invalid_context", TestData.ErrorReason(() => TestData.Bundled().ResolveSandboxPolicy(new[] { tool! }, ctx))); - } - - [Fact] - public void RelativeOrTemplatedSymbolValuesAreRejected() - { - var catalog = TestData.Bundled(); - var ctx = Weak with { Platform = "linux", Architecture = "x64" }; - var error = TestData.Failure(() => catalog.ResolveSandboxPolicy("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "bin" } })); - Assert.Equal("[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path", error.Message); - Assert.Equal("invalid_context", TestData.ErrorReason(() => catalog.ResolveSandboxPolicy("node", ctx with { Symbols = new Dictionary { ["node_prefix"] = "/x/${git_prefix}" } }))); - } - - [Fact] - public void ResultsAreDeterministicCopies() - { - var catalog = TestData.Bundled(); - var ctx = new ResolveContext { Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["npm_prefix"] = "/n", ["npm_cache"] = "/c", ["node_prefix"] = "/n" } }; - var tool = new ToolInput("npm") { PackageUrl = "pkg:npm/npm" }; - var first = PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(tool, ctx)); - Assert.Equal(first, PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicyWithDiagnostics(tool, ctx))); - var policy = catalog.ResolveSandboxPolicy(tool, ctx)!; - Assert.Equal(new[] { "/n" }, policy.Filesystem!.ReadonlyPaths); - Assert.Equal(new[] { "/p", "/c" }, policy.Filesystem!.ReadwritePaths); - } - - [Fact] - public void DeduplicatesWithPlatformPathRules() - { - var windows = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:w", """{ "platformVariants": [{ "when": { "platform": "windows" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}", "${node_prefix}\\"] } } }] }"""), - })); - var policy = windows.ResolveSandboxPolicy("w", Weak with { Platform = "windows", Architecture = "x64", Symbols = new Dictionary { ["git_prefix"] = "C:\\Tools", ["node_prefix"] = "c:\\tools" } }); - Assert.Equal(new[] { "C:\\Tools" }, policy?.Filesystem?.ReadonlyPaths); - var linux = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:l", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}", "${node_prefix}/"] } } }] }"""), - })); - Assert.Equal(new[] { "/Tools", "/tools" }, linux.ResolveSandboxPolicy("l", Weak with { Symbols = new Dictionary { ["git_prefix"] = "/Tools", ["node_prefix"] = "/tools" } })?.Filesystem?.ReadonlyPaths); - } - - [Fact] - public void MacOSFoldsPathCaseForOverlap() - { - string Entry(string platform) => $$"""{ "platformVariants": [{ "when": { "platform": "{{platform}}" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}"], "readwritePaths": ["${project_root}"] } } }] }"""; - var mac = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:m", Entry("macos")) })); - var ctx = Weak with { Platform = "macos", Architecture = "arm64", ProjectRoot = "/tools/work", Symbols = new Dictionary { ["git_prefix"] = "/Tools" } }; - var error = TestData.Failure(() => mac.ResolveSandboxPolicy("m", ctx)); - Assert.Equal("[policy_validation] resolved paths overlap across access classes: '/Tools' (readonlyPaths) overlaps '/tools/work' (readwritePaths)", error.Message); - var linux = TestData.CatalogFor(TestData.Revision(new[] { TestData.Entry("tool:m", Entry("linux")) })); - Assert.NotNull(linux.ResolveSandboxPolicy("m", ctx with { Platform = "linux", Architecture = "x64" })); - } - - [Fact] - public void InvocationNameCasingFollowsThePlatform() - { - var catalog = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:gh", """ - { "platformVariants": [ - { "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }, - { "when": { "platform": "macos" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }, - { "when": { "platform": "windows" }, "sandboxPolicy": { "version": "0.9.0-alpha" } } ] } - """), - })); - IEnumerable On(string platform, string name) => - catalog.ResolveSandboxPolicyWithDiagnostics(name, Weak with { Platform = platform, Architecture = "x64" }).Diagnostics.Tools[0].Matches.Select(m => m.EntryId); - Assert.Equal(new[] { "tool:gh" }, On("linux", "gh")); - Assert.Empty(On("linux", "GH")); - Assert.Equal(new[] { "tool:gh" }, On("macos", "GH")); - Assert.Equal(new[] { "tool:gh" }, On("windows", "Gh")); - } - - [Fact] - public void DependencyChainsComposeEachEntryOnceInOrder() - { - static string Variant(string[] deps, string path) => $$""" - { "platformVariants": [{ "when": { "platform": "linux" } - {{(deps.Length > 0 ? ", \"dependencies\": [" + string.Join(",", deps.Select(d => $"{{\"entryId\":\"{d}\"}}")) + "]" : string.Empty)}}, - "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/{{path}}"] } } }] } - """; - var catalog = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:top", Variant(new[] { "tool:left", "tool:right" }, "top")), - TestData.Entry("tool:left", Variant(new[] { "tool:leaf" }, "left")), - TestData.Entry("tool:right", Variant(new[] { "tool:leaf" }, "right")), - TestData.Entry("tool:leaf", Variant(Array.Empty(), "leaf")), - })); - var result = catalog.ResolveSandboxPolicyWithDiagnostics("top", Weak with { ProjectRoot = "/r" }); - Assert.Equal(new[] { "tool:leaf", "tool:left", "tool:right" }, result.Diagnostics.ResolvedDependencies.Select(d => d.EntryId)); - Assert.Equal(new[] { "/r/top", "/r/left", "/r/leaf", "/r/right" }, result.Policy?.Filesystem?.ReadonlyPaths); - } - - [Fact] - public void DependencyDiagnosticsKeepDistinctRangesSorted() - { - var catalog = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:c", "versionRange": ">=2" }], "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), - TestData.Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:c" }], "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), - TestData.Entry("tool:c", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), - })); - var result = catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput[] { "a", "b", "a" }, Weak); - Assert.Equal( - new[] { new ResolvedDependency("tool:c", 1, null), new ResolvedDependency("tool:c", 1, ">=2") }, - result.Diagnostics.ResolvedDependencies); - Assert.Equal("""{"version":"0.9.0-alpha"}""", PolicyCatalogJson.Serialize(result.Policy)); - } - - [Fact] - public void CatalogFileOrderDoesNotMatter() - { - var entries = new[] - { - TestData.Entry("tool:b", """{ "identity": [{ "kind": "invocation-name", "names": ["x"] }], "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/b"] } } }] }"""), - TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["x"] }], "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${project_root}/a"] } } }] }"""), - }; - var ctx = Weak with { ProjectRoot = "/r" }; - var forward = TestData.CatalogFor(TestData.Revision(entries.Select(e => e.DeepClone()))).ResolveSandboxPolicyWithDiagnostics("x", ctx); - var backward = TestData.CatalogFor(TestData.Revision(Enumerable.Reverse(entries).Select(e => e.DeepClone()))).ResolveSandboxPolicyWithDiagnostics("x", ctx); - Assert.Equal(PolicyCatalogJson.Serialize(forward), PolicyCatalogJson.Serialize(backward)); - Assert.Equal(new[] { "/r/a", "/r/b" }, forward.Policy?.Filesystem?.ReadonlyPaths); - Assert.Contains("input 0 ('x') matched 2 entries (tool:a, tool:b); all contribute", forward.Diagnostics.Warnings); - } - - [Fact] - public void MixedVersionsAcrossInputsAreACompositionConflict() - { - var catalog = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.8.0-alpha" } }] }"""), - TestData.Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }] }"""), - })); - var error = TestData.Failure(() => catalog.ResolveSandboxPolicy(new ToolInput[] { "a", "b" }, Weak)); - Assert.Equal("[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)", error.Message); - Assert.Equal("""{"version":"0.8.0-alpha"}""", PolicyCatalogJson.Serialize(catalog.ResolveSandboxPolicy("a", Weak))); - } - - [Fact] - public void VersionRangeWarnings() - { - var catalog = TestData.CatalogFor(TestData.Revision(new[] - { - TestData.Entry("tool:app", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/app", "versionRange": ">=2 <3" }] }"""), - })); - var ctx = new ResolveContext { ProjectRoot = "/p" }; - Assert.DoesNotContain(catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@2.1.0" }, ctx).Diagnostics.Warnings, w => w.Contains("range", StringComparison.Ordinal)); - Assert.Contains( - "input 0 ('app'): detected version 'nightly' could not be compared with the reviewed range '>=2 <3' for tool:app", - catalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput("app") { PackageUrl = "pkg:npm/app@3.0.0", DetectedVersion = "nightly" }, ctx).Diagnostics.Warnings); - } - - [Fact] - public void InspectionListsMetadataOnly() - { - Assert.Equal(new CatalogInfo("1", "2026-09-29.1"), BundledPolicyCatalog.GetCatalogInfo()); - var entries = BundledPolicyCatalog.ListCatalogEntries(); - Assert.Equal(new[] { "tool:git", "tool:node", "tool:npm" }, entries.Select(e => e.EntryId)); - var npm = entries.Single(e => e.EntryId == "tool:npm"); - Assert.Equal("windows", npm.PlatformVariants[0].Platform); - Assert.Null(npm.PlatformVariants[0].Architecture); - Assert.Equal(new[] { "tool:node" }, npm.PlatformVariants[0].DependencyEntryIds); - var text = PolicyCatalogJson.Serialize(entries); - Assert.DoesNotContain("Paths", text, StringComparison.Ordinal); - Assert.DoesNotContain("${", text, StringComparison.Ordinal); - } - - [Fact] - public void BundledConvenienceFunctions() - { - var ctx = Weak with { Platform = "linux", Architecture = "x64" }; - Assert.Null(BundledPolicyCatalog.ResolveSandboxPolicy("definitely-unknown-tool", ctx)); - var result = BundledPolicyCatalog.ResolveSandboxPolicyWithDiagnostics(new ToolInput[] { "definitely-unknown-tool" }, ctx); - Assert.Null(result.Policy); - Assert.Equal("""{"diagnostics":{"catalogRevision":"2026-09-29.1","tools":[{"inputIndex":0,"matches":[]}],"resolvedDependencies":[],"warnings":["input 0 ('definitely-unknown-tool') matched no eligible catalog entry"]}}""", PolicyCatalogJson.Serialize(result)); - Assert.Equal("revision_unavailable", TestData.ErrorReason(() => BundledPolicyCatalog.ResolveSandboxPolicy("git", ctx with { CatalogRevision = "1999-01-01.1" }))); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs deleted file mode 100644 index ff7ccdc55..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/StoreTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Nodes; -using Microsoft.Mxc.PolicyCatalog.Internal; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// Port of tests/unit/store.test.ts: integrity, revisions, history, immutability. -public sealed class StoreTests -{ - private static readonly CatalogContract Contract = Catalog.ValidateContract(JsonText.Parse(TestData.Contract)); - - private static CatalogRevisionData Validated(JsonNode revision) => Catalog.ValidateRevision(JsonText.Parse(revision.ToJsonString()), Contract); - - [Fact] - public void BundledCatalogVerifies() - { - var store = CatalogStore.Bundled(); - Assert.Empty(CatalogValidation.CheckStoreHistory(store)); - Assert.Equal("2026-09-29.1", store.DefaultRevision); - store.Verify(); - } - - [Fact] - public void CanonicalDigestIgnoresFormattingButNotContent() - { - Assert.Equal("""{"a":[2,{"c":4,"d":3}],"b":1}""", PolicyCatalogJson.Canonicalize("""{ "b": 1, "a": [2, { "d": 3, "c": 4 }] }""")); - Assert.Equal(PolicyCatalogJson.CanonicalSha256("""{"a":1,"b":2}"""), PolicyCatalogJson.CanonicalSha256("{\r\n \"b\": 2,\n\"a\": 1}")); - Assert.NotEqual(PolicyCatalogJson.CanonicalSha256("""{"a":1}"""), PolicyCatalogJson.CanonicalSha256("""{"a":2}""")); - } - - [Fact] - public void TamperedRevisionIsAnIntegrityError() - { - var store = TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:a") }) }, digests: new Dictionary { ["2000-01-01.1"] = new string('0', 64) }); - var error = TestData.Failure(() => store.Verify()); - Assert.Equal(("backend_error", "integrity"), (error.Code, error.Reason)); - Assert.Matches("^\\[backend_error\\] catalog revision '2000-01-01.1' digest [0-9a-f]{64} does not match the published digest 0{64}$", error.Message); - var catalog = new PolicyCatalog(store, new FixedHost()); - var weak = new ResolveContext { AllowWeakIdentityFallback = true }; - Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ResolveSandboxPolicy("a", weak))); - Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ResolveSandboxPolicyWithDiagnostics("a", weak))); - Assert.Equal("integrity", TestData.ErrorReason(() => catalog.ListCatalogEntries())); - Assert.Equal("integrity", TestData.ErrorReason(() => catalog.GetCatalogInfo())); - } - - [Fact] - public void RevisionIdMismatchIsAnIntegrityError() - { - var revision = TestData.Revision(new[] { TestData.Entry("tool:a") }, "2000-01-02.1"); - var relabelled = revision.DeepClone(); - relabelled["catalogRevision"] = "2000-01-01.1"; - // The digest was computed for other content, so this is caught as tampering first. - var store = TestData.StoreFor(new[] { relabelled }, digests: new Dictionary { ["2000-01-01.1"] = PolicyCatalogJson.CanonicalSha256(revision.ToJsonString()) }); - Assert.Equal("integrity", TestData.ErrorReason(() => store.Verify())); - - // Correct digest, but the file declares another revision. - var files = new Dictionary { ["revisions/2000-01-01.1.json"] = revision.ToJsonString() }; - var manifest = $$"""{"catalogSchemaVersion":"1","defaultRevision":"2000-01-01.1","revisions":[{"catalogRevision":"2000-01-01.1","file":"revisions/2000-01-01.1.json","sha256":"{{PolicyCatalogJson.CanonicalSha256(revision.ToJsonString())}}"}]}"""; - var mismatched = CatalogStore.FromJson(TestData.Contract, manifest, file => files[file]); - var error = TestData.Failure(() => mismatched.Verify()); - Assert.Equal("[backend_error] file 'revisions/2000-01-01.1.json' declares revision '2000-01-02.1', expected '2000-01-01.1'", error.Message); - } - - [Fact] - public void InvalidManifestIsAValidationError() - { - var revision = TestData.Revision(new[] { TestData.Entry("tool:a") }); - Assert.Equal("invalid_catalog", TestData.ErrorReason(() => TestData.StoreFor(new[] { revision }, defaultRevision: "2001-01-01.1"))); - var error = TestData.Failure(() => TestData.StoreFor(new[] { TestData.Revision(new[] { TestData.Entry("tool:a") }, "2000-01-02.1"), revision })); - Assert.Equal("[policy_validation] manifest.revisions must be strictly increasing ('2000-01-01.1')", error.Message); - } - - [Fact] - public void ExplicitRevisionIsNeverSubstituted() - { - var r1 = TestData.Revision(new[] { TestData.Entry("tool:a"), TestData.Entry("tool:b") }, "2000-01-01.1"); - var r2 = TestData.Revision(new[] { TestData.Entry("tool:a", """{ "entryRevision": 2, "displayName": "renamed" }"""), TestData.Entry("tool:b") }, "2000-01-02.1"); - var catalog = new PolicyCatalog(TestData.StoreFor(new[] { r1, r2 }), new FixedHost()); - var ctx = new ResolveContext { AllowWeakIdentityFallback = true, ProjectRoot = "/p" }; - var latest = catalog.ResolveSandboxPolicyWithDiagnostics("a", ctx); - Assert.Equal(("2000-01-02.1", 2.0), (latest.Diagnostics.CatalogRevision, latest.Diagnostics.Tools[0].Matches[0].EntryRevision)); - var older = catalog.ResolveSandboxPolicyWithDiagnostics("a", ctx with { CatalogRevision = "2000-01-01.1" }); - Assert.Equal(("2000-01-01.1", 1.0), (older.Diagnostics.CatalogRevision, older.Diagnostics.Tools[0].Matches[0].EntryRevision)); - var error = TestData.Failure(() => catalog.ResolveSandboxPolicy("a", ctx with { CatalogRevision = "2000-01-03.1" })); - Assert.Equal(("backend_error", "revision_unavailable", "[backend_error] catalog revision '2000-01-03.1' is not installed"), (error.Code, error.Reason, error.Message)); - } - - [Fact] - public void EntryRevisionMustIncreaseExactlyWhenAnEntryChanges() - { - var r1 = Validated(TestData.Revision(new[] { TestData.Entry("tool:a"), TestData.Entry("tool:b") }, "2000-01-01.1")); - var changedNoBump = Validated(TestData.Revision(new[] { TestData.Entry("tool:a", """{ "displayName": "x" }"""), TestData.Entry("tool:b") }, "2000-01-02.1")); - var bumpedNoChange = Validated(TestData.Revision(new[] { TestData.Entry("tool:a"), TestData.Entry("tool:b", """{ "entryRevision": 2 }""") }, "2000-01-02.1")); - var ok = Validated(TestData.Revision(new[] { TestData.Entry("tool:a", """{ "displayName": "x", "entryRevision": 2 }"""), TestData.Entry("tool:b") }, "2000-01-02.1")); - Assert.Equal(new[] { "2000-01-02.1: 'tool:a' changed but entryRevision did not increase (1 -> 1)" }, CatalogValidation.CheckEntryRevisions(r1, changedNoBump)); - Assert.Equal(new[] { "2000-01-02.1: 'tool:b' is unchanged but entryRevision moved (1 -> 2)" }, CatalogValidation.CheckEntryRevisions(r1, bumpedNoChange)); - Assert.Empty(CatalogValidation.CheckEntryRevisions(r1, ok)); - Assert.Contains("catalog revision '2000-01-01.1' must be newer than '2000-01-02.1'", CatalogValidation.CheckEntryRevisions(ok, r1)); - } - - private static CatalogValidation.PublishedRevision Rev(string id, string sha) => - new(new JsonString(id), new JsonString($"revisions/{id}.json"), new JsonString(sha)); - - [Fact] - public void PublishedRevisionsAreImmutable() - { - var published = Rev("2000-01-01.1", new string('a', 64)); - var files = new Dictionary { ["revisions/2000-01-01.1.json"] = "{\"x\":1}\n" }; - var @base = new CatalogValidation.PublishedState(new[] { published }, files); - Assert.Empty(CatalogValidation.CheckPublishedImmutability(@base, new(new[] { published, Rev("2000-01-02.1", new string('b', 64)) }, new Dictionary { ["revisions/2000-01-01.1.json"] = "{ \"x\" : 1 }\r\n" }))); - Assert.Equal( - new[] { "published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead" }, - CatalogValidation.CheckPublishedImmutability(@base, new(new[] { published }, new Dictionary { ["revisions/2000-01-01.1.json"] = "{\"x\":2}\n" }))); - Assert.Equal( - new[] { "published revision '2000-01-01.1' manifest entry was modified" }, - CatalogValidation.CheckPublishedImmutability(@base, new(new[] { Rev("2000-01-01.1", new string('c', 64)) }, files))); - Assert.Equal( - new[] { "published revision '2000-01-01.1' was removed or reordered in the manifest" }, - CatalogValidation.CheckPublishedImmutability(@base, new(Array.Empty(), new Dictionary()))); - Assert.Equal( - new[] { "published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead" }, - CatalogValidation.CheckPublishedImmutability(@base, new(new[] { published }, new Dictionary { ["revisions/2000-01-01.1.json"] = "not json" }))); - } - - [Fact] - public void DirectoryStoreReportsUnreadableData() - { - var dir = Path.Combine(Path.GetTempPath(), "pc-dotnet-unit-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - try - { - var error = TestData.Failure(() => CatalogStore.FromDirectory(dir)); - Assert.Equal("integrity", error.Reason); - Assert.StartsWith("[backend_error] catalog data could not be read: ", error.Message, StringComparison.Ordinal); - var report = CatalogValidation.ValidateCatalogDirectory(dir); - Assert.False(report.Ok); - Assert.Null(report.DefaultRevision); - Assert.Equal(Path.GetFullPath(dir), report.CatalogDir); - } - finally - { - Directory.Delete(dir, true); - } - } - - [Fact] - public void BaseRefRejectsOptionLikeRefsWithoutRunningGit() - { - var (compared, errors) = CatalogValidation.CheckAgainstBaseRef(Path.GetTempPath(), Array.Empty(), "-h"); - Assert.Equal(0, compared); - Assert.Equal(new[] { "[policy_validation] base-ref check: '-h' is not a valid git ref" }, errors); - Assert.Equal("[policy_validation] base-ref check: 'a b' is not a valid git ref", CatalogValidation.CheckAgainstBaseRef(Path.GetTempPath(), Array.Empty(), "a b").Errors.Single()); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs deleted file mode 100644 index 6500b79a2..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/TestData.cs +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using System.Text.Json.Nodes; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// Host with fixed facts, so tests never depend on the machine running them. -internal sealed class FixedHost : IHostEnvironment -{ - private readonly string _platform; - private readonly Func _architecture; - private readonly IReadOnlyDictionary _symbols; - - public FixedHost(string platform = "linux", string architecture = "x64", IReadOnlyDictionary? symbols = null) - : this(platform, () => architecture, symbols) - { - } - - public FixedHost(string platform, Func architecture, IReadOnlyDictionary? symbols = null) - { - _platform = platform; - _architecture = architecture; - _symbols = symbols ?? new Dictionary(); - } - - public int ArchitectureCalls { get; private set; } - - public string Platform() => _platform; - - public string NativeArchitecture() - { - ArchitectureCalls++; - return _architecture(); - } - - public string? Symbol(string name) => _symbols.TryGetValue(name, out var value) ? value : null; -} - -internal static class TestData -{ - public const string V = "0.9.0-alpha"; - - public static string Resource(string name) - { - var assembly = typeof(TestData).Assembly; - var actual = assembly.GetManifestResourceNames().Single(resource => resource.Replace('\\', '/') == name); - using var stream = assembly.GetManifestResourceStream(actual)!; - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } - - public static IEnumerable Resources(string prefix) => - typeof(TestData).Assembly.GetManifestResourceNames() - .Select(resource => resource.Replace('\\', '/')) - .Where(resource => resource.StartsWith(prefix, StringComparison.Ordinal)) - .OrderBy(resource => resource, StringComparer.Ordinal); - - public static string Contract => Resource("catalog/contract.v1.json"); - - /// An in-memory store whose manifest publishes the given revisions with correct (or overridden) digests. - public static CatalogStore StoreFor(IReadOnlyList revisions, string? defaultRevision = null, IReadOnlyDictionary? digests = null) - { - var files = new Dictionary(); - var manifestRevisions = new JsonArray(); - foreach (var revision in revisions) - { - var id = revision["catalogRevision"]!.GetValue(); - var file = $"revisions/{id}.json"; - var text = revision.ToJsonString(); - files[file] = text; - manifestRevisions.Add(new JsonObject - { - ["catalogRevision"] = id, - ["file"] = file, - ["sha256"] = digests is not null && digests.TryGetValue(id, out var digest) ? digest : PolicyCatalogJson.CanonicalSha256(text), - }); - } - - var manifest = new JsonObject - { - ["catalogSchemaVersion"] = "1", - ["defaultRevision"] = defaultRevision ?? revisions[^1]["catalogRevision"]!.GetValue(), - ["revisions"] = manifestRevisions, - }; - return CatalogStore.FromJson(Contract, manifest.ToJsonString(), file => files[file]); - } - - public static PolicyCatalog CatalogFor(JsonNode revision, IHostEnvironment? host = null) => - new(StoreFor(new[] { revision }), host ?? new FixedHost()); - - public static PolicyCatalog Bundled(IHostEnvironment? host = null) => new(CatalogStore.Bundled(), host ?? new FixedHost()); - - public static JsonNode Revision(IEnumerable entries, string catalogRevision = "2000-01-01.1") => new JsonObject - { - ["catalogSchemaVersion"] = "1", - ["catalogRevision"] = catalogRevision, - ["entries"] = new JsonArray(entries.ToArray()), - }; - - public static JsonNode Parse(string json) => JsonNode.Parse(json)!; - - /// Minimal valid entry; is a JSON object merged over it. - public static JsonNode Entry(string entryId, string? overrides = null) - { - var name = entryId.Split(':')[1]; - var entry = new JsonObject - { - ["entryId"] = entryId, - ["entryRevision"] = 1, - ["displayName"] = name, - ["identity"] = Parse($$"""[{ "kind": "invocation-name", "names": ["{{name}}"] }]"""), - ["platformVariants"] = Parse("""[{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"] } } }]"""), - ["provenance"] = Parse("""{ "method": "test", "sourceRevision": "test" }"""), - }; - if (overrides is not null) - { - foreach (var (key, value) in Parse(overrides).AsObject().ToList()) - { - entry[key] = value?.DeepClone(); - } - } - - return entry; - } - - /// The failure's reason, after checking that its code is the reason's code. - public static string? ErrorReason(Action action) - { - try - { - action(); - } - catch (PolicyCatalogException error) - { - Assert.Equal(PolicyCatalogException.CodeFor(error.ErrorReason), error.ErrorCode); - Assert.StartsWith($"[{error.Code}] ", error.Message, StringComparison.Ordinal); - return error.Reason; - } - - return null; - } - - public static PolicyCatalogException Failure(Action action) => Assert.Throws(action); - - // --- fixture conversion -------------------------------------------------- - - public static List Tools(JsonElement tools) - { - if (tools.ValueKind == JsonValueKind.Array) - { - return tools.EnumerateArray().Select(Tool).ToList(); - } - - return new List { Tool(tools) }; - } - - public static bool IsSingle(JsonElement tools) => tools.ValueKind != JsonValueKind.Array; - - public static ToolInput Tool(JsonElement tool) - { - if (tool.ValueKind == JsonValueKind.String) - { - return tool.GetString()!; - } - - return new ToolInput(tool.GetProperty("invocationName").GetString()!) - { - PackageUrl = tool.TryGetProperty("packageUrl", out var purl) ? purl.GetString() : null, - DetectedVersion = tool.TryGetProperty("detectedVersion", out var version) ? version.GetString() : null, - }; - } - - public static ResolveContext Context(JsonElement? context) - { - if (context is not { } ctx || ctx.ValueKind != JsonValueKind.Object) - { - return new ResolveContext(); - } - - string? Str(string name) => ctx.TryGetProperty(name, out var value) ? value.GetString() : null; - Dictionary? symbols = null; - if (ctx.TryGetProperty("symbols", out var s)) - { - symbols = s.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()); - } - - return new ResolveContext - { - Platform = Str("platform"), - Architecture = Str("architecture"), - ProjectRoot = Str("projectRoot"), - CatalogRevision = Str("catalogRevision"), - AllowWeakIdentityFallback = ctx.TryGetProperty("allowWeakIdentityFallback", out var weak) && weak.GetBoolean(), - Symbols = symbols, - }; - } - - public static string Canonical(string json) => PolicyCatalogJson.Canonicalize(json); -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs deleted file mode 100644 index e9cbcab26..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/ValidationTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Nodes; -using Microsoft.Mxc.PolicyCatalog.Internal; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// Port of tests/unit/validation.test.ts (contribution/CI rules). -public sealed class ValidationTests -{ - private static readonly CatalogContract Contract = Catalog.ValidateContract(JsonText.Parse(TestData.Contract)); - - private static string? Validate(params JsonNode[] entries) - { - try - { - Catalog.ValidateRevision(JsonText.Parse(TestData.Revision(entries).ToJsonString()), Contract); - return null; - } - catch (PolicyCatalogException error) - { - Assert.Equal("invalid_catalog", error.Reason); - return error.Message; - } - } - - private static void Rejects(string expected, params JsonNode[] entries) - { - var message = Validate(entries); - Assert.NotNull(message); - Assert.Contains(expected, message, StringComparison.Ordinal); - } - - private static string Linux(string policy, string extra = "") => - $$"""{ "when": { "platform": "linux" }{{extra}}, "sandboxPolicy": {{policy}} }"""; - - private static string Variants(params string[] variants) => $$"""{ "platformVariants": [{{string.Join(",", variants)}}] }"""; - - private static string Dep(string id) => Variants(Linux("""{ "version": "0.9.0-alpha" }""", $$""", "dependencies": [{ "entryId": "{{id}}" }]""")); - - [Fact] - public void AcceptsMinimalRevision() => Assert.Null(Validate(TestData.Entry("tool:a"))); - - [Fact] - public void RejectsDependencyCycles() - { - Rejects("cycle (tool:a -> tool:b -> tool:c -> tool:a)", TestData.Entry("tool:a", Dep("tool:b")), TestData.Entry("tool:b", Dep("tool:c")), TestData.Entry("tool:c", Dep("tool:a"))); - Rejects("depends on itself", TestData.Entry("tool:a", Dep("tool:a"))); - Rejects("[policy_validation] 'tool:a' on linux/x64: cycle", TestData.Entry("tool:a", Dep("tool:b")), TestData.Entry("tool:b", Dep("tool:a"))); - } - - [Fact] - public void RejectsUnknownDependencies() => Rejects("unknown entry 'tool:missing'", TestData.Entry("tool:a", Dep("tool:missing"))); - - [Fact] - public void RejectsInvalidDependencyRanges() => - Rejects("versionRange", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha" }""", """, "dependencies": [{ "entryId": "tool:b", "versionRange": "^1.x" }]"""))), TestData.Entry("tool:b")); - - [Fact] - public void RejectsDuplicateSelectors() - { - static string V(string? arch) => arch is null - ? """{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }""" - : $$"""{ "when": { "platform": "linux", "architecture": "{{arch}}" }, "sandboxPolicy": { "version": "0.9.0-alpha" } }"""; - Rejects("duplicates selector 'linux/x64'", TestData.Entry("tool:a", Variants(V("x64"), V("x64")))); - Rejects("second architecture-neutral", TestData.Entry("tool:a", Variants(V(null), V(null)))); - Assert.Null(Validate(TestData.Entry("tool:a", Variants(V(null), V("x64"), V("arm64"))))); - Rejects("'entries[0].platformVariants[0].when.architecture' must be one of x64, arm64", TestData.Entry("tool:a", Variants(V("riscv")))); - } - - [Fact] - public void RejectsBackendKeysAndUnregisteredVersions() - { - Rejects("names a containment backend", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "processContainer": {} }""")))); - Rejects("containment backend", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "containment": "lxc" }""")))); - Rejects("not a SandboxPolicy version registered", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0" }""")))); - } - - [Fact] - public void RejectsUnsupportedFields() - { - Rejects("unsupported field 'entries[0].extra'", TestData.Entry("tool:a", """{ "extra": 1 }""")); - Rejects("unsupported field", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "filesystem": { "clearPolicyOnExit": true } }""")))); - Rejects("unsupported field", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "network": { "allowOutbound": true } }""")))); - Rejects("unsupported field", TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "telemetry": {} }""")))); - } - - [Theory] - [InlineData("/home/alice/.npm", "literal paths are not allowed")] - [InlineData("C:\\\\Users\\\\alice", "literal paths are not allowed")] - [InlineData("${project_root}/*", "wildcard")] - [InlineData("${project_root}/../x", "'..'")] - [InlineData("${unknown_thing}", "unknown symbol 'unknown_thing'")] - [InlineData("${project_root", "malformed symbol")] - [InlineData("${project_root}/node_modules", null)] - public void PathRules(string path, string? expected) - { - var entry = TestData.Entry("tool:a", Variants(Linux($$"""{ "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["{{path}}"] } }"""))); - if (expected is null) - { - Assert.Null(Validate(entry)); - } - else - { - Rejects(expected, entry); - } - } - - [Theory] - [InlineData("""{ "egress": { "default": "allow" } }""", "default-allow")] - [InlineData("""{ "ingress": { "default": "allow" } }""", "default-allow")] - [InlineData("""{ "egress": { "allow": [{ "ports": [{ "port": 443 }] }] } }""", "wildcard network grant")] - [InlineData("""{ "egress": { "allow": [{ "to": [{ "cidr": "0.0.0.0/0" }] }] } }""", "wildcard network grant")] - [InlineData("""{ "egress": { "allow": [{ "to": [{ "cidr": "10.0.0.0/8" }], "ports": [{ "port": 70000 }] }] } }""", "must be an integer in 1..65535")] - [InlineData("""{ "egress": { "allow": [{ "to": [{ "cidr": "10.0.0.0/8" }], "ports": [{ "port": 5, "endPort": 4 }] }] } }""", "requires a lower or equal 'port'")] - [InlineData("""{ "egress": { "deny": [{ "ports": [{ "protocol": "tcp" }] }] } }""", null)] - [InlineData("""{ "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } }""", null)] - public void NetworkRules(string network, string? expected) - { - var entry = TestData.Entry("tool:a", Variants(Linux($$"""{ "version": "0.9.0-alpha", "network": {{network}} }"""))); - if (expected is null) - { - Assert.Null(Validate(entry)); - } - else - { - Rejects(expected, entry); - } - } - - [Fact] - public void IdentityRules() - { - Assert.Null(Validate(TestData.Entry("tool:a"), TestData.Entry("tool:b", """{ "identity": [{ "kind": "invocation-name", "names": ["A"] }] }"""))); - Assert.Null(Validate( - TestData.Entry("tool:a", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/x" }] }"""), - TestData.Entry("tool:b", """{ "identity": [{ "kind": "purl", "value": "pkg:NPM/x" }] }"""))); - Rejects("repeats identity 'invocation-name:a'", TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["a", "A"] }] }""")); - // Folding matches JavaScript toLowerCase beyond ASCII (Latin-1 and final sigma). - Rejects("repeats identity", TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["\u00c9t\u00e9", "\u00e9T\u00c9"] }] }""")); - Rejects("repeats identity 'purl:npm/a'", TestData.Entry("tool:a", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/a" }, { "kind": "purl", "value": "pkg:NPM/a" }] }""")); - Rejects("must not pin a version", TestData.Entry("tool:a", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/a@1.0.0" }] }""")); - Rejects("bare invocation name", TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["bin/a"] }] }""")); - Rejects("not a supported identity kind", TestData.Entry("tool:a", """{ "identity": [{ "kind": "sha256", "value": "x" }] }""")); - Rejects("duplicate entryId 'tool:a'", TestData.Entry("tool:a"), TestData.Entry("tool:a", """{ "identity": [{ "kind": "invocation-name", "names": ["z"] }] }""")); - Rejects("must be namespaced", TestData.Entry("tool:a", """{ "entryId": "noNamespace" }""")); - Rejects("'entries[0].entryRevision' must be a positive integer", TestData.Entry("tool:a", """{ "entryRevision": 1.5 }""")); - } - - private static JsonNode[] WithDep(string policy, string depPolicy) => new[] - { - TestData.Entry("tool:a", Variants(Linux(policy, """, "dependencies": [{ "entryId": "tool:b" }]"""))), - TestData.Entry("tool:b", Variants(Linux(depPolicy))), - }; - - [Fact] - public void CompositionVocabulary() - { - Assert.Null(Validate(WithDep( - """{ "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"], "readonlyPaths": ["${node_prefix}"] } }""", - """{ "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${node_prefix}", "${git_prefix}"], "deniedPaths": ["${user_home}/.ssh"] } }"""))); - Rejects("overlaps", WithDep( - """{ "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"] } }""", - """{ "version": "0.9.0-alpha", "filesystem": { "deniedPaths": ["${project_root}/secrets"] } }""")); - Rejects("mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)", WithDep("""{ "version": "0.9.0-alpha" }""", """{ "version": "0.8.0-alpha" }""")); - Rejects("'tool:a' uses 'network'", WithDep("""{ "version": "0.9.0-alpha", "network": { "egress": { "default": "deny" } } }""", """{ "version": "0.9.0-alpha" }""")); - Rejects("'timeoutMs'", WithDep("""{ "version": "0.9.0-alpha" }""", """{ "version": "0.9.0-alpha", "timeoutMs": 5 }""")); - Assert.Null(Validate(TestData.Entry("tool:a", Variants(Linux("""{ "version": "0.9.0-alpha", "network": { "egress": { "default": "deny" } }, "ui": { "clipboard": "read" }, "timeoutMs": 5 }"""))))); - } - - [Fact] - public void ContractValidation() - { - Assert.Equal(7, Contract.Symbols.Count); - var bad = TestData.Parse(TestData.Contract); - bad["symbols"]!["x"] = TestData.Parse("""{ "source": "elsewhere", "description": "d" }"""); - var error = Assert.Throws(() => Catalog.ValidateContract(JsonText.Parse(bad.ToJsonString()))); - Assert.Equal("[policy_validation] contract.symbols.x.source is unsupported", error.Message); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs deleted file mode 100644 index fee6dadf0..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.Tests/VectorTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Microsoft.Mxc.PolicyCatalog.Internal; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.Tests; - -/// conformance/vectors/*.json: canonical JSON and path rules shared by every binding. -public sealed class VectorTests -{ - public static TheoryData CanonicalCases() - { - using var doc = JsonDocument.Parse(TestData.Resource("conformance/vectors/canonical-json.json")); - var data = new TheoryData(); - for (var i = 0; i < doc.RootElement.GetProperty("cases").GetArrayLength(); i++) - { - data.Add(i); - } - - return data; - } - - [Theory] - [MemberData(nameof(CanonicalCases))] - public void CanonicalJsonVector(int index) - { - using var doc = JsonDocument.Parse(TestData.Resource("conformance/vectors/canonical-json.json")); - var c = doc.RootElement.GetProperty("cases")[index]; - var json = c.GetProperty("json").GetString()!; - Assert.Equal(c.GetProperty("canonical").GetString(), PolicyCatalogJson.Canonicalize(json)); - Assert.Equal(c.GetProperty("sha256").GetString(), PolicyCatalogJson.CanonicalSha256(json)); - } - - [Fact] - public void EveryVectorFileIsCovered() - { - Assert.Equal( - new[] { "conformance/vectors/canonical-json.json", "conformance/vectors/paths.json" }, - TestData.Resources("conformance/vectors/").ToArray()); - } - - [Theory] - [InlineData("windows")] - [InlineData("linux")] - [InlineData("macos")] - public void PathVectors(string platform) - { - using var doc = JsonDocument.Parse(TestData.Resource("conformance/vectors/paths.json")); - var rows = doc.RootElement.GetProperty(platform).EnumerateArray().ToList(); - Assert.True(rows.Count > 5); - foreach (var row in rows) - { - var input = row.GetProperty("input").GetString()!; - Assert.True(row.GetProperty("absolute").GetBoolean() == Paths.IsAbsolutePath(input, platform), input); - var normalized = row.GetProperty("normalized"); - if (normalized.ValueKind != JsonValueKind.Null) - { - Assert.Equal(normalized.GetString(), Paths.NormalizePath(input, platform)); - } - - Assert.Equal(row.GetProperty("keySegments").EnumerateArray().Select(s => s.GetString()!).ToList(), Paths.PathKeySegments(input, platform)); - } - } - - [Fact] - public void MalformedPercentEncodingIsAnInvalidPurl() - { - Assert.Null(Purl.Parse("pkg:npm/npm@%E0%A4%A")); - Assert.Equal(new ParsedPurl("npm/npm", "10.9.0"), Purl.Parse("pkg:npm/npm@10.9.0")); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx deleted file mode 100644 index 858fa1753..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog.slnx +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs deleted file mode 100644 index 59a94bb8b..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogStore.cs +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections.Concurrent; -using System.Reflection; -using System.Text.RegularExpressions; -using Microsoft.Mxc.PolicyCatalog.Internal; - -namespace Microsoft.Mxc.PolicyCatalog; - -/// One published revision as listed in manifest.json. -/// The revision ID (YYYY-MM-DD.N). -/// The revision file, revisions/<id>.json. -/// Lower-case hex SHA-256 of the revision's canonical JSON. -public sealed record ManifestRevision(string CatalogRevision, string File, string Sha256); - -/// -/// Read-only access to locally installed, integrity-validated catalog revisions. Revisions are loaded -/// lazily, verified against the manifest digest, validated against the contract, and cached. -/// -public sealed class CatalogStore -{ - private static readonly Regex Sha256Pattern = new("^[0-9a-f]{64}\\z", RegexOptions.CultureInvariant); - private static readonly Lazy BundledStore = new(LoadBundled, LazyThreadSafetyMode.ExecutionAndPublication); - - private readonly Func _readRevision; - private readonly ConcurrentDictionary _loaded = new(StringComparer.Ordinal); - - internal CatalogStore(JsonValue contract, JsonValue manifest, Func readRevision) - { - _readRevision = readRevision; - Contract = Catalog.ValidateContract(contract); - var (defaultRevision, revisions) = ValidateManifest(manifest); - DefaultRevision = defaultRevision; - Revisions = revisions; - } - - internal CatalogContract Contract { get; } - - /// The installed default revision. - public string DefaultRevision { get; } - - /// The published revisions in manifest order. - public IReadOnlyList Revisions { get; } - - /// Every installed revision ID, in manifest order. - public IReadOnlyList AvailableRevisions => Revisions.Select(revision => revision.CatalogRevision).ToList(); - - /// The catalog embedded in this assembly (the repository's catalog/ directory). - /// The shared bundled store. - /// The bundled data is invalid. - public static CatalogStore Bundled() => BundledStore.Value; - - /// Loads a store from a catalog directory (contract.v1.json, manifest.json, revisions/). - /// The catalog directory. - /// The store. - /// The data cannot be read (integrity) or is invalid (invalid_catalog). - public static CatalogStore FromDirectory(string directory) - { - var baseDir = Path.GetFullPath(directory); - JsonValue contract; - JsonValue manifest; - try - { - contract = ReadJsonFile(Path.Combine(baseDir, "contract.v1.json")); - manifest = ReadJsonFile(Path.Combine(baseDir, "manifest.json")); - } - catch (Exception error) when (error is not PolicyCatalogException) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"catalog data could not be read: {error.Message}"); - } - - return new CatalogStore(contract, manifest, file => ReadJsonFile(Path.Combine(baseDir, file))); - } - - /// Creates a store from in-memory JSON text (used by tests and fixtures). - /// Contract JSON text. - /// Manifest JSON text. - /// Returns the JSON text of a manifest file entry. - /// The store. - /// The contract or manifest is invalid. - public static CatalogStore FromJson(string contractJson, string manifestJson, Func readRevisionJson) - { - ArgumentNullException.ThrowIfNull(readRevisionJson); - return new CatalogStore(JsonText.Parse(contractJson), JsonText.Parse(manifestJson), file => JsonText.Parse(readRevisionJson(file))); - } - - internal static JsonValue ReadJsonFile(string path) => JsonText.Parse(JsonText.DecodeUtf8(File.ReadAllBytes(path))); - - private static CatalogStore LoadBundled() - { - var assembly = typeof(CatalogStore).Assembly; - JsonValue contract; - JsonValue manifest; - try - { - contract = ReadResource(assembly, "contract.v1.json"); - manifest = ReadResource(assembly, "manifest.json"); - } - catch (Exception error) when (error is not PolicyCatalogException) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"catalog data could not be read: {error.Message}"); - } - - return new CatalogStore(contract, manifest, file => ReadResource(assembly, file)); - } - - private static JsonValue ReadResource(Assembly assembly, string file) - { - var name = "catalog/" + file.Replace('\\', '/'); - // MSBuild's RecursiveDir uses the build host's separator, so compare - // resource names with separators normalized. - var actual = assembly.GetManifestResourceNames().FirstOrDefault(resource => resource.Replace('\\', '/') == name); - using var stream = (actual is null ? null : assembly.GetManifestResourceStream(actual)) - ?? throw new FileNotFoundException($"embedded catalog resource '{name}' does not exist"); - using var memory = new MemoryStream(); - stream.CopyTo(memory); - return JsonText.Parse(JsonText.DecodeUtf8(memory.ToArray())); - } - - private static PolicyCatalogException Invalid(string message) => new(PolicyCatalogErrorReason.InvalidCatalog, message); - - private static (string DefaultRevision, List Revisions) ValidateManifest(JsonValue raw) - { - if (raw is not JsonObject obj) - { - throw Invalid("manifest root must be an object"); - } - - foreach (var key in obj.Keys) - { - if (key is not ("catalogSchemaVersion" or "defaultRevision" or "revisions")) - { - throw Invalid($"unsupported field 'manifest.{key}'"); - } - } - - if (obj.Get("catalogSchemaVersion") is not JsonString { Value: Catalog.SchemaVersion }) - { - throw Invalid($"manifest.catalogSchemaVersion must be '{Catalog.SchemaVersion}'"); - } - - if (obj.Get("revisions") is not JsonArray items || items.Count == 0) - { - throw Invalid("manifest.revisions must be a non-empty array"); - } - - var revisions = new List(); - for (var index = 0; index < items.Count; index++) - { - var at = $"manifest.revisions[{index}]"; - if (items[index] is not JsonObject item) - { - throw Invalid($"'{at}' must be an object"); - } - - foreach (var key in item.Keys) - { - if (key is not ("catalogRevision" or "file" or "sha256")) - { - throw Invalid($"unsupported field '{at}.{key}'"); - } - } - - if (item.Get("catalogRevision") is not JsonString revisionValue || !Catalog.IsRevisionId(revisionValue.Value)) - { - throw Invalid($"'{at}.catalogRevision' must match YYYY-MM-DD.N"); - } - - var catalogRevision = revisionValue.Value; - var expectedFile = $"revisions/{catalogRevision}.json"; - if (item.Get("file") is not JsonString file || file.Value != expectedFile) - { - throw Invalid($"'{at}.file' must be '{expectedFile}'"); - } - - if (item.Get("sha256") is not JsonString sha256 || !Sha256Pattern.IsMatch(sha256.Value)) - { - throw Invalid($"'{at}.sha256' must be a lower-case hex SHA-256 digest"); - } - - revisions.Add(new ManifestRevision(catalogRevision, file.Value, sha256.Value)); - } - - for (var index = 1; index < revisions.Count; index++) - { - if (Catalog.CompareRevisions(revisions[index - 1].CatalogRevision, revisions[index].CatalogRevision) >= 0) - { - throw Invalid($"manifest.revisions must be strictly increasing ('{revisions[index].CatalogRevision}')"); - } - } - - if (obj.Get("defaultRevision") is not JsonString defaultRevision || !revisions.Any(r => r.CatalogRevision == defaultRevision.Value)) - { - throw Invalid("manifest.defaultRevision must name a listed revision"); - } - - return (defaultRevision.Value, revisions); - } - - /// - /// Returns the requested revision, or the default when is null. - /// An explicitly requested revision that is not installed is an error; it is never substituted. - /// - internal CatalogRevisionData Revision(string? catalogRevision = null) - { - var id = catalogRevision ?? DefaultRevision; - if (_loaded.TryGetValue(id, out var cached)) - { - return cached; - } - - var listed = Revisions.FirstOrDefault(revision => revision.CatalogRevision == id) - ?? throw new PolicyCatalogException(PolicyCatalogErrorReason.RevisionUnavailable, $"catalog revision '{id}' is not installed"); - JsonValue raw; - try - { - raw = _readRevision(listed.File); - } - catch (Exception error) when (error is not PolicyCatalogException) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"catalog revision '{id}' could not be read: {error.Message}"); - } - - var digest = CanonicalJson.Sha256(raw); - if (digest != listed.Sha256) - { - throw new PolicyCatalogException( - PolicyCatalogErrorReason.Integrity, - $"catalog revision '{id}' digest {digest} does not match the published digest {listed.Sha256}"); - } - - var revision = Catalog.ValidateRevision(raw, Contract); - if (revision.CatalogRevision != id) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.Integrity, $"file '{listed.File}' declares revision '{revision.CatalogRevision}', expected '{id}'"); - } - - return _loaded.GetOrAdd(id, revision); - } - - /// Loads and verifies a revision (integrity, contract, revision ID) without resolving anything. - /// The revision, or null for the default. - /// The revision is unavailable, tampered, or invalid. - public void Verify(string? catalogRevision = null) => Revision(catalogRevision); -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs deleted file mode 100644 index a641cc0b2..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/CatalogValidation.cs +++ /dev/null @@ -1,452 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using Microsoft.Mxc.PolicyCatalog.Internal; - -namespace Microsoft.Mxc.PolicyCatalog; - -/// -/// Contribution and CI tooling (TypeScript history.ts and validate.ts): catalog-directory validation, -/// entry-revision history, and the --base-ref published-revision immutability check. The runtime lookup -/// path never uses this class or runs git. -/// -public static class CatalogValidation -{ - /// - /// The on-disk copy of the bundled catalog shipped beside the consuming application - /// (<app>/catalog/, package content copied to output). The embedded copy is what - /// reads; both come from the same source directory. - /// - public static string BundledCatalogDirectory => Path.Combine(AppContext.BaseDirectory, "catalog"); - - /// - /// Validates a catalog directory: manifest and contract validity, per-revision integrity, the full catalog - /// contract, entry-revision history, and, when is given, immutability of every revision - /// published at that git ref. Never throws for catalog problems; they are reported in . - /// - /// The catalog directory (relative paths resolve against the current directory). - /// An optional git ref. - /// The report. - public static CatalogValidationReport ValidateCatalogDirectory(string catalogDir, string? baseRef = null) - { - var dir = Path.GetFullPath(catalogDir); - var errors = new List(); - CatalogStore store; - try - { - store = CatalogStore.FromDirectory(dir); - } - catch (Exception error) - { - errors.Add(DescribeError(error)); - return new CatalogValidationReport(false, dir, null, null, null, errors); - } - - errors.AddRange(CheckStoreHistory(store)); - BaseRefReport? baseRefReport = null; - if (baseRef is not null) - { - var (compared, baseErrors) = CheckAgainstBaseRef(dir, store.Revisions, baseRef); - baseRefReport = new BaseRefReport(baseRef, compared); - errors.AddRange(baseErrors); - } - - return new CatalogValidationReport(errors.Count == 0, dir, store.DefaultRevision, store.AvailableRevisions, baseRefReport, errors); - } - - /// Validates every installed revision (integrity + contract) and the entry-revision chain between consecutive revisions. - /// The store. - /// Error strings; empty when valid. - public static IReadOnlyList CheckStoreHistory(CatalogStore store) - { - ArgumentNullException.ThrowIfNull(store); - var errors = new List(); - CatalogRevisionData? previous = null; - foreach (var id in store.AvailableRevisions) - { - CatalogRevisionData current; - try - { - current = store.Revision(id); - } - catch (PolicyCatalogException error) - { - errors.Add(error.Message); - previous = null; - continue; - } - - if (previous is not null) - { - errors.AddRange(CheckEntryRevisions(previous, current)); - } - - previous = current; - } - - return errors; - } - - /// Entry-revision monotonicity between two consecutive published revisions (design §4.1, §10). - internal static List CheckEntryRevisions(CatalogRevisionData previous, CatalogRevisionData next) - { - var errors = new List(); - if (Catalog.CompareRevisions(previous.CatalogRevision, next.CatalogRevision) >= 0) - { - errors.Add($"catalog revision '{next.CatalogRevision}' must be newer than '{previous.CatalogRevision}'"); - } - - var before = new Dictionary(StringComparer.Ordinal); - foreach (var entry in previous.Entries) - { - before[entry.EntryId] = entry; - } - - foreach (var entry in next.Entries) - { - if (!before.TryGetValue(entry.EntryId, out var old)) - { - continue; - } - - var changed = SemanticKey(old) != SemanticKey(entry); - var oldRevision = JsNumber.ToString(old.EntryRevision); - var newRevision = JsNumber.ToString(entry.EntryRevision); - if (changed && entry.EntryRevision <= old.EntryRevision) - { - errors.Add($"{next.CatalogRevision}: '{entry.EntryId}' changed but entryRevision did not increase ({oldRevision} -> {newRevision})"); - } - else if (!changed && entry.EntryRevision != old.EntryRevision) - { - errors.Add($"{next.CatalogRevision}: '{entry.EntryId}' is unchanged but entryRevision moved ({oldRevision} -> {newRevision})"); - } - } - - return errors; - } - - // The validated entry holds exactly the raw entry's fields (unsupported fields - // are rejected), so the raw object minus entryRevision is its semantic key. - private static string SemanticKey(Entry entry) - { - var copy = new JsonObject(); - foreach (var key in entry.Raw.Keys) - { - if (key != "entryRevision") - { - copy.Set(key, entry.Raw.Get(key)!); - } - } - - return CanonicalJson.Serialize(copy); - } - - // ----------------------------------------------------------------------- - // Published-revision immutability - // ----------------------------------------------------------------------- - - /// A raw manifest revision record from a published state (fields may be absent or of any JSON type). - internal sealed record PublishedRevision(JsonValue? CatalogRevision, JsonValue? File, JsonValue? Sha256); - - /// Raw published state used to compare a proposed change against its base. - internal sealed record PublishedState(IReadOnlyList? Revisions, IReadOnlyDictionary Files); - - private static bool StrictEquals(JsonValue? left, JsonValue? right) => (left, right) switch - { - (null, null) => true, - (JsonString a, JsonString b) => a.Value == b.Value, - (JsonNumber a, JsonNumber b) => a.Value == b.Value, - (JsonBool a, JsonBool b) => a.Value == b.Value, - (JsonNull, JsonNull) => true, - _ => ReferenceEquals(left, right), - }; - - /// Published revisions keep their manifest entry, digest, and content; new revisions are only appended. - internal static List CheckPublishedImmutability(PublishedState @base, PublishedState proposed) - { - if (@base.Revisions is null) - { - throw new InvalidOperationException("Cannot read properties of undefined (reading 'forEach')"); - } - - var errors = new List(); - for (var index = 0; index < @base.Revisions.Count; index++) - { - var published = @base.Revisions[index]; - var name = Catalog.Js(published.CatalogRevision); - var now = proposed.Revisions is not null && index < proposed.Revisions.Count ? proposed.Revisions[index] : null; - if (now is null || !StrictEquals(now.CatalogRevision, published.CatalogRevision)) - { - errors.Add($"published revision '{name}' was removed or reordered in the manifest"); - continue; - } - - if (!StrictEquals(now.File, published.File) || !StrictEquals(now.Sha256, published.Sha256)) - { - errors.Add($"published revision '{name}' manifest entry was modified"); - } - - var file = Catalog.Js(published.File); - var baseContent = Canonical(@base.Files.TryGetValue(file, out var baseText) ? baseText : null); - var proposedContent = Canonical(proposed.Files.TryGetValue(file, out var proposedText) ? proposedText : null); - if (baseContent is not null && proposedContent != baseContent) - { - errors.Add($"published revision file '{file}' was modified; publish a new revision instead"); - } - } - - return errors; - } - - // Canonical content, not raw bytes, so line-ending conversion cannot produce a false positive. - private static string? Canonical(string? text) - { - if (text is null) - { - return null; - } - - try - { - return CanonicalJson.Serialize(JsonText.Parse(text)); - } - catch (System.Text.Json.JsonException) - { - return $"invalid:{text}"; - } - } - - private static string DescribeError(Exception error) => - error is PolicyCatalogException ? error.Message : $"[policy_validation] {error.Message}"; - - /// Compares a directory's manifest and revision files with the revisions published at . Returns errors, never throws. - internal static (int ComparedRevisions, List Errors) CheckAgainstBaseRef(string catalogDir, IReadOnlyList manifest, string baseRef) - { - var dir = Path.GetFullPath(catalogDir); - try - { - var @base = ReadPublishedStateAtRef(dir, baseRef); - if (@base is null) - { - return (0, new List()); - } - - var files = new Dictionary(StringComparer.Ordinal); - foreach (var revision in manifest) - { - string text; - try - { - text = JsonText.DecodeUtf8(File.ReadAllBytes(Path.GetFullPath(Path.Combine(dir, revision.File)))); - } - catch (Exception) - { - // A published file may not disappear; reported as a modification. - text = string.Empty; - } - - files[revision.File] = text; - } - - var proposed = new PublishedState( - manifest.Select(r => new PublishedRevision(new JsonString(r.CatalogRevision), new JsonString(r.File), new JsonString(r.Sha256))).ToList(), - files); - return (@base.Revisions!.Count, CheckPublishedImmutability(@base, proposed).Select(message => $"[immutability] {message}").ToList()); - } - catch (Exception error) - { - return (0, new List { DescribeError(error) }); - } - } - - /// - /// Reads the published catalog state at for the git repository containing - /// ; null when the ref has no catalog at that path. Throws when the check - /// cannot run, so a typo never passes silently. - /// - internal static PublishedState? ReadPublishedStateAtRef(string catalogDir, string baseRef) - { - // A ref starting with '-' would be parsed by git as an option. - if (baseRef.Length == 0 || baseRef.StartsWith('-') || baseRef.Any(c => JsString.IsWhitespace(c) || c == '\0')) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{baseRef}' is not a valid git ref"); - } - - var dir = RealPath(catalogDir); - string top; - try - { - top = RealPath(Git(new[] { "rev-parse", "--show-toplevel" }, dir).Trim()); - } - catch (Exception) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{catalogDir}' is not inside a git work tree"); - } - - if (!GitSucceeds(new[] { "rev-parse", "--verify", "--quiet", $"{baseRef}^{{commit}}" }, top)) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{baseRef}' does not name a commit"); - } - - var prefix = Relative(top, dir); - if (prefix is null) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"base-ref check: '{catalogDir}' is outside its git work tree"); - } - - string At(string file) => $"{baseRef}:{(prefix.Length == 0 ? string.Empty : prefix + "/")}{file}"; - if (!GitSucceeds(new[] { "cat-file", "-e", At("manifest.json") }, top)) - { - return null; - } - - var manifest = JsonText.Parse(Git(new[] { "show", At("manifest.json") }, top)); - List? revisions = null; - var files = new Dictionary(StringComparer.Ordinal); - var revisionsValue = manifest switch - { - JsonObject obj => obj.Get("revisions"), - JsonNull => throw new InvalidOperationException("Cannot read properties of null (reading 'revisions')"), - _ => null, - }; - if (revisionsValue is not null and not JsonNull) - { - if (revisionsValue is not JsonArray list) - { - throw new InvalidOperationException("manifest.revisions is not iterable"); - } - - revisions = new List(); - foreach (var item in list.Items) - { - if (item is not JsonObject record) - { - throw new InvalidOperationException("Cannot read properties of a non-object manifest revision"); - } - - var revision = new PublishedRevision(record.Get("catalogRevision"), record.Get("file"), record.Get("sha256")); - revisions.Add(revision); - var file = Catalog.Js(revision.File); - if (GitSucceeds(new[] { "cat-file", "-e", At(file) }, top)) - { - files[file] = Git(new[] { "show", At(file) }, top); - } - } - } - - return new PublishedState(revisions, files); - } - - /// Node path.relative(top, dir) with forward slashes, or null when is outside . - private static string? Relative(string top, string dir) - { - var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var t = Path.TrimEndingDirectorySeparator(top); - var d = Path.TrimEndingDirectorySeparator(dir); - if (string.Equals(t, d, comparison)) - { - return string.Empty; - } - - var withSeparator = t.EndsWith(Path.DirectorySeparatorChar) ? t : t + Path.DirectorySeparatorChar; - if (!d.StartsWith(withSeparator, comparison)) - { - return null; - } - - return d.Substring(withSeparator.Length).Replace('\\', '/'); - } - - /// Node realpathSync.native: absolute, symlinks resolved, Windows 8.3 names expanded. Throws when missing. - private static string RealPath(string path) - { - var full = Path.GetFullPath(path); - if (!Directory.Exists(full) && !File.Exists(full)) - { - throw new DirectoryNotFoundException($"ENOENT: no such file or directory, realpath '{path}'"); - } - - var info = new DirectoryInfo(full); - var resolved = info.LinkTarget is not null ? info.ResolveLinkTarget(true)?.FullName ?? full : full; - // Resolve links in ancestors as well. - var parts = new List(); - var current = new DirectoryInfo(resolved); - while (current.Parent is not null) - { - if (current.LinkTarget is not null && current.ResolveLinkTarget(true) is { } target) - { - current = new DirectoryInfo(target.FullName); - continue; - } - - parts.Insert(0, current.Name); - current = current.Parent; - } - - var result = parts.Count == 0 ? current.FullName : Path.Combine(new[] { current.FullName }.Concat(parts).ToArray()); - return OperatingSystem.IsWindows() ? LongPath(result) : result; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "GetLongPathNameW")] - private static extern uint GetLongPathName(string shortPath, StringBuilder longPath, uint bufferLength); - - private static string LongPath(string path) - { - var buffer = new StringBuilder(1024); - var length = GetLongPathName(path, buffer, (uint)buffer.Capacity); - if (length > buffer.Capacity) - { - buffer = new StringBuilder((int)length); - length = GetLongPathName(path, buffer, (uint)buffer.Capacity); - } - - return length == 0 ? path : buffer.ToString(); - } - - private static string Git(IReadOnlyList args, string cwd) - { - var start = new ProcessStartInfo("git") - { - WorkingDirectory = cwd, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true, - StandardOutputEncoding = new UTF8Encoding(false), - }; - foreach (var arg in args) - { - start.ArgumentList.Add(arg); - } - - using var process = Process.Start(start) ?? throw new InvalidOperationException("git could not be started"); - process.StandardInput.Close(); - var stderr = process.StandardError.ReadToEndAsync(); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - var errorText = stderr.Result; - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"Command failed: git {string.Join(" ", args)}\n{errorText}"); - } - - return output; - } - - private static bool GitSucceeds(IReadOnlyList args, string cwd) - { - try - { - Git(args, cwd); - return true; - } - catch (Exception) - { - return false; - } - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs deleted file mode 100644 index bf8299334..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Inputs.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.Mxc.PolicyCatalog; - -/// Catalog platform selector values (design §4.4). -public static class CatalogPlatforms -{ - /// Windows. - public const string Windows = "windows"; - - /// Linux. - public const string Linux = "linux"; - - /// macOS. - public const string MacOS = "macos"; - - /// Every selector, in catalog order. - public static IReadOnlyList All { get; } = new[] { Windows, Linux, MacOS }; -} - -/// Catalog architecture selector values (design §4.4). -public static class CatalogArchitectures -{ - /// x64 (AMD64 / x86_64). - public const string X64 = "x64"; - - /// ARM64 (aarch64). - public const string Arm64 = "arm64"; - - /// Every selector, in catalog order. - public static IReadOnlyList All { get; } = new[] { X64, Arm64 }; -} - -/// -/// Runtime lookup input (design §5.1). A plain string converts implicitly to an input with only -/// . -/// -/// The bare invocation name (never a path). -public sealed record ToolInput(string InvocationName) -{ - /// Package URL evidence (strong identity). The caller must have verified it. - public string? PackageUrl { get; init; } - - /// Detected tool version evidence, compared with reviewed version ranges. - public string? DetectedVersion { get; init; } - - /// String shorthand for new ToolInput(name). - /// The invocation name. - public static implicit operator ToolInput(string invocationName) => new(invocationName); -} - -/// Runtime lookup context, shared by every input in one lookup (design §5.1). -public sealed record ResolveContext -{ - /// The consumer's project root; the only source of the project_root symbol. - public string? ProjectRoot { get; init; } - - /// Caller-supplied symbol values (caller and host symbols; host values override detection). - public IReadOnlyDictionary? Symbols { get; init; } - - /// Target platform (); defaults to the host platform. - public string? Platform { get; init; } - - /// Target architecture (); defaults to the native system architecture. - public string? Architecture { get; init; } - - /// Explicit catalog revision; defaults to the installed default revision. Never substituted. - public string? CatalogRevision { get; init; } - - /// Allows invocation-name-only (weak) identity matches. Off by default. - public bool AllowWeakIdentityFallback { get; init; } -} - -/// Host facts the resolver uses only when the caller omits them. -public interface IHostEnvironment -{ - /// The host platform selector. Throw (unsupported_host) when it has none. - /// A value of . - string Platform(); - - /// - /// The device's native system architecture (not the process architecture). Throw when it cannot be - /// determined; never guess. - /// - /// A value of . - string NativeArchitecture(); - - /// An approved host-known symbol (source: "host") for the current host. - /// The symbol name, e.g. user_home. - /// The value, or null when unknown. - string? Symbol(string name); -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs deleted file mode 100644 index bc941ce80..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/CanonicalJson.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Security.Cryptography; -using System.Text; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// -/// Canonical JSON used for revision integrity digests (TypeScript canonicalJson): -/// object keys sorted by UTF-16 code units at every level, arrays in order, no -/// whitespace, JSON.stringify string escaping, ECMAScript number formatting. -/// -internal static class CanonicalJson -{ - public static string Serialize(JsonValue value) - { - var builder = new StringBuilder(); - Write(builder, value); - return builder.ToString(); - } - - private static void Write(StringBuilder builder, JsonValue value) - { - switch (value) - { - case JsonArray array: - builder.Append('['); - for (var i = 0; i < array.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - Write(builder, array[i]); - } - - builder.Append(']'); - break; - case JsonObject obj: - var keys = obj.Keys.ToList(); - keys.Sort(string.CompareOrdinal); - builder.Append('{'); - for (var i = 0; i < keys.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - JsonText.Quote(builder, keys[i]); - builder.Append(':'); - Write(builder, obj.Get(keys[i])!); - } - - builder.Append('}'); - break; - default: - builder.Append(JsonText.Stringify(value)); - break; - } - } - - /// Lower-case hex SHA-256 of the UTF-8 canonical form. - public static string Sha256(JsonValue value) - { - // Lone surrogates become U+FFFD in UTF-8, exactly as Node's hash update does. - var bytes = Encoding.UTF8.GetBytes(Serialize(value)); - return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs deleted file mode 100644 index 17d2203f2..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Catalog.cs +++ /dev/null @@ -1,1015 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Globalization; -using System.Text.RegularExpressions; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -internal sealed record SymbolDefinition(string Source, string Description); - -internal sealed class CatalogContract -{ - public CatalogContract(string schemaVersion, IReadOnlyList policyVersions, IReadOnlyDictionary symbols) - { - SchemaVersion = schemaVersion; - PolicyVersions = policyVersions; - Symbols = symbols; - } - - public string SchemaVersion { get; } - - public IReadOnlyList PolicyVersions { get; } - - /// Own-key symbol table; lookups never see inherited names. - public IReadOnlyDictionary Symbols { get; } -} - -internal abstract record IdentityPredicate(string Kind) -{ - public string Strength => Kind == "purl" ? "strong" : "weak"; -} - -internal sealed record PurlPredicate(string Value, string? VersionRange) : IdentityPredicate("purl"); - -internal sealed record NamePredicate(IReadOnlyList Names) : IdentityPredicate("invocation-name"); - -internal sealed record Dependency(string EntryId, string? VersionRange); - -internal sealed class Variant -{ - public Variant(string platform, string? architecture, IReadOnlyList? dependencies, JsonObject policy) - { - Platform = platform; - Architecture = architecture; - Dependencies = dependencies; - Policy = policy; - } - - public string Platform { get; } - - public string? Architecture { get; } - - public IReadOnlyList? Dependencies { get; } - - /// The validated raw policy object (only contract fields). - public JsonObject Policy { get; } - - public string PolicyVersion => ((JsonString)Policy.Get("version")!).Value; - - public IReadOnlyList DependencyList => Dependencies ?? Array.Empty(); - - public List Paths(string field) - { - if (Policy.Get("filesystem") is JsonObject filesystem && filesystem.Get(field) is JsonArray array) - { - return array.Items.Select(item => ((JsonString)item).Value).ToList(); - } - - return new List(); - } -} - -internal sealed class Entry -{ - public Entry(string entryId, double entryRevision, string displayName, IReadOnlyList identity, IReadOnlyList variants, string method, string sourceRevision, JsonObject raw) - { - EntryId = entryId; - EntryRevision = entryRevision; - DisplayName = displayName; - Identity = identity; - Variants = variants; - Method = method; - SourceRevision = sourceRevision; - Raw = raw; - } - - public string EntryId { get; } - - public double EntryRevision { get; } - - public string DisplayName { get; } - - public IReadOnlyList Identity { get; } - - public IReadOnlyList Variants { get; } - - public string Method { get; } - - public string SourceRevision { get; } - - public JsonObject Raw { get; } -} - -internal sealed class CatalogRevisionData -{ - public CatalogRevisionData(string schemaVersion, string catalogRevision, IReadOnlyList entries) - { - SchemaVersion = schemaVersion; - CatalogRevision = catalogRevision; - Entries = entries; - } - - public string SchemaVersion { get; } - - public string CatalogRevision { get; } - - public IReadOnlyList Entries { get; } -} - -internal sealed record VariantSelection(Variant Variant, bool Exact); - -internal sealed record ClosureNode(Entry Entry, Variant Variant, bool Exact); - -internal sealed record ClosureResult(List? Nodes, string? Reason, string? Detail) -{ - public bool Ok => Nodes is not null; -} - -/// Port of TypeScript catalog.ts. -internal static class Catalog -{ - public const string SchemaVersion = "1"; - - public static readonly string[] ComposableFields = { "deniedPaths", "readonlyPaths", "readwritePaths" }; - - private static readonly string[] BackendKeys = { "containment", "processContainer", "appContainer", "lxc", "seatbelt", "wslc", "hyperlight", "bwrap" }; - - private static readonly Regex RevisionPattern = new("^([0-9]{4}-[0-9]{2}-[0-9]{2})\\.([1-9][0-9]*)\\z", RegexOptions.CultureInvariant); - private static readonly Regex EntryIdPattern = new("^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*\\z", RegexOptions.CultureInvariant); - private static readonly Regex SymbolNamePattern = new("^[a-z][a-z0-9_]*\\z", RegexOptions.CultureInvariant); - public static readonly Regex SymbolPattern = new("\\$\\{([a-z][a-z0-9_]*)\\}", RegexOptions.CultureInvariant); - private static readonly Regex AnchoredSymbol = new("^\\$\\{([a-z][a-z0-9_]*)\\}(?:[\\\\/]|\\z)", RegexOptions.CultureInvariant); - private static readonly Regex PathSplit = new("[\\\\/]", RegexOptions.CultureInvariant); - private static readonly Regex CidrWildcard = new("/0\\z", RegexOptions.CultureInvariant); - - public static PolicyCatalogException Fail(string message) => new(PolicyCatalogErrorReason.InvalidCatalog, message); - - private static bool IsRecord(JsonValue? value) => value is JsonObject; - - private static void OnlyFields(JsonObject value, IReadOnlyCollection allowed, string at) - { - foreach (var key in value.Keys) - { - if (!allowed.Contains(key)) - { - throw Fail($"unsupported field '{at}.{key}'"); - } - } - } - - private static string NonEmptyString(JsonValue? value, string at) - { - if (value is not JsonString s || s.Value.Length == 0) - { - throw Fail($"'{at}' must be a non-empty string"); - } - - return s.Value; - } - - private static List StringArray(JsonValue? value, string at, int minItems = 0) - { - if (value is not JsonArray array || array.Count < minItems) - { - throw Fail($"'{at}' must be an array with at least {minItems} item(s)"); - } - - return array.Items.Select((item, index) => NonEmptyString(item, $"{at}[{index}]")).ToList(); - } - - private static bool IsString(JsonValue? value, string expected) => value is JsonString s && s.Value == expected; - - private static bool IsInteger(JsonValue? value, out double number) - { - number = value is JsonNumber n ? n.Value : double.NaN; - return value is JsonNumber && double.IsFinite(number) && Math.Floor(number) == number; - } - - /// Formats a raw value the way a JavaScript template literal would for the values that reach messages. - internal static string Js(JsonValue? value) => value switch - { - null => "undefined", - JsonString s => s.Value, - JsonNumber n => JsNumber.ToString(n.Value), - JsonBool b => b.Value ? "true" : "false", - JsonNull => "null", - JsonArray a => string.Join(",", a.Items.Select(item => item is JsonNull ? string.Empty : Js(item))), - _ => "[object Object]", - }; - - public static int CompareRevisions(string left, string right) - { - var a = RevisionPattern.Match(left); - var b = RevisionPattern.Match(right); - if (!a.Success || !b.Success) - { - throw Fail($"cannot compare malformed catalog revisions '{left}' and '{right}'"); - } - - if (a.Groups[1].Value != b.Groups[1].Value) - { - return string.CompareOrdinal(a.Groups[1].Value, b.Groups[1].Value) < 0 ? -1 : 1; - } - - var x = double.Parse(a.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture); - var y = double.Parse(b.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture); - return Math.Sign(x - y); - } - - public static bool IsRevisionId(string value) => RevisionPattern.IsMatch(value); - - // ----------------------------------------------------------------------- - // Contract - // ----------------------------------------------------------------------- - - public static CatalogContract ValidateContract(JsonValue raw) - { - if (raw is not JsonObject obj) - { - throw Fail("contract root must be an object"); - } - - OnlyFields(obj, new[] { "$comment", "catalogSchemaVersion", "sandboxPolicyVersions", "symbols" }, "contract"); - if (!IsString(obj.Get("catalogSchemaVersion"), SchemaVersion)) - { - throw Fail($"contract.catalogSchemaVersion must be '{SchemaVersion}'"); - } - - var versions = StringArray(obj.Get("sandboxPolicyVersions"), "contract.sandboxPolicyVersions", 1); - if (obj.Get("symbols") is not JsonObject symbolsRaw) - { - throw Fail("contract.symbols must be an object"); - } - - var symbols = new Dictionary(StringComparer.Ordinal); - foreach (var name in symbolsRaw.Keys) - { - var definition = symbolsRaw.Get(name); - if (!SymbolNamePattern.IsMatch(name) || definition is not JsonObject def) - { - throw Fail($"contract.symbols.{name} is malformed"); - } - - OnlyFields(def, new[] { "source", "description" }, $"contract.symbols.{name}"); - var source = def.Get("source"); - if (!IsString(source, "context") && !IsString(source, "caller") && !IsString(source, "host")) - { - throw Fail($"contract.symbols.{name}.source is unsupported"); - } - - symbols[name] = new SymbolDefinition(((JsonString)source!).Value, NonEmptyString(def.Get("description"), $"contract.symbols.{name}.description")); - } - - return new CatalogContract(SchemaVersion, versions, symbols); - } - - // ----------------------------------------------------------------------- - // Paths and symbols - // ----------------------------------------------------------------------- - - private static void ValidateTemplatePath(string value, string at, CatalogContract contract) - { - if (value.IndexOfAny(new[] { '*', '?' }) >= 0) - { - throw Fail($"'{at}' contains a wildcard"); - } - - if (SymbolPattern.Replace(value, string.Empty).Contains("${", StringComparison.Ordinal)) - { - throw Fail($"'{at}' contains malformed symbol syntax"); - } - - foreach (Match match in SymbolPattern.Matches(value)) - { - if (!contract.Symbols.ContainsKey(match.Groups[1].Value)) - { - throw Fail($"'{at}' references unknown symbol '{match.Groups[1].Value}'"); - } - } - - if (!AnchoredSymbol.IsMatch(value)) - { - throw Fail($"'{at}' must start with a declared symbol; literal paths are not allowed"); - } - - if (PathSplit.Split(value).Any(segment => segment == "..")) - { - throw Fail($"'{at}' must not contain '..' segments"); - } - } - - /// Symbols a policy references, in first-seen order. - public static List PolicySymbols(Variant variant) - { - var seen = new List(); - foreach (var field in ComposableFields) - { - foreach (var value in variant.Paths(field)) - { - foreach (Match match in SymbolPattern.Matches(value)) - { - if (!seen.Contains(match.Groups[1].Value)) - { - seen.Add(match.Groups[1].Value); - } - } - } - } - - return seen; - } - - private static bool IsSameOrNested(List left, List right) - { - var shorter = left.Count <= right.Count ? left : right; - var longer = ReferenceEquals(shorter, left) ? right : left; - for (var i = 0; i < shorter.Count; i++) - { - if (longer[i] != shorter[i]) - { - return false; - } - } - - return true; - } - - /// First equal or ancestor/descendant pair across access classes, or null. - public static string? FindCrossClassOverlap(IReadOnlyDictionary> classes, string platform) - { - var fields = ComposableFields.Where(field => classes.TryGetValue(field, out var list) && list.Count > 0).ToList(); - for (var i = 0; i < fields.Count; i++) - { - for (var j = i + 1; j < fields.Count; j++) - { - foreach (var left in classes[fields[i]]) - { - foreach (var right in classes[fields[j]]) - { - if (IsSameOrNested(Paths.PathKeySegments(left, platform), Paths.PathKeySegments(right, platform))) - { - return $"'{left}' ({fields[i]}) overlaps '{right}' ({fields[j]})"; - } - } - } - } - } - - return null; - } - - // ----------------------------------------------------------------------- - // Embedded SandboxPolicy - // ----------------------------------------------------------------------- - - private static void ValidateNetworkRules(JsonValue value, string at, bool denyList) - { - if (value is not JsonArray rules) - { - throw Fail($"'{at}' must be an array"); - } - - for (var index = 0; index < rules.Count; index++) - { - var ruleAt = $"{at}[{index}]"; - if (rules[index] is not JsonObject rule) - { - throw Fail($"'{ruleAt}' must be an object"); - } - - OnlyFields(rule, new[] { "to", "ports" }, ruleAt); - var to = rule.Get("to"); - if (to is null && !denyList) - { - throw Fail($"'{ruleAt}' has no 'to'; wildcard network grants are not allowed"); - } - - if (to is not null) - { - if (to is not JsonArray peers || peers.Count == 0) - { - throw Fail($"'{ruleAt}.to' must be a non-empty array"); - } - - for (var peerIndex = 0; peerIndex < peers.Count; peerIndex++) - { - var peerAt = $"{ruleAt}.to[{peerIndex}]"; - if (peers[peerIndex] is not JsonObject peer) - { - throw Fail($"'{peerAt}' must be an object"); - } - - OnlyFields(peer, new[] { "cidr", "except" }, peerAt); - var cidr = NonEmptyString(peer.Get("cidr"), $"{peerAt}.cidr"); - if (!denyList && CidrWildcard.IsMatch(cidr)) - { - throw Fail($"'{peerAt}.cidr' is a wildcard network grant"); - } - - if (peer.Get("except") is { } except) - { - StringArray(except, $"{peerAt}.except"); - } - } - } - - var portsValue = rule.Get("ports"); - if (portsValue is not null) - { - if (portsValue is not JsonArray ports || ports.Count == 0) - { - throw Fail($"'{ruleAt}.ports' must be a non-empty array"); - } - - for (var portIndex = 0; portIndex < ports.Count; portIndex++) - { - var portAt = $"{ruleAt}.ports[{portIndex}]"; - if (ports[portIndex] is not JsonObject port) - { - throw Fail($"'{portAt}' must be an object"); - } - - OnlyFields(port, new[] { "protocol", "port", "endPort" }, portAt); - var protocol = port.Get("protocol"); - if (protocol is not null && !(protocol is JsonString p && (p.Value is "tcp" or "udp" or "icmp" or "any"))) - { - throw Fail($"'{portAt}.protocol' is unsupported"); - } - - foreach (var key in new[] { "port", "endPort" }) - { - var n = port.Get(key); - if (n is not null && (!IsInteger(n, out var number) || number < 1 || number > 65535)) - { - throw Fail($"'{portAt}.{key}' must be an integer in 1..65535"); - } - } - - if (port.Get("endPort") is JsonNumber endPort - && (port.Get("port") is not JsonNumber start || endPort.Value < start.Value)) - { - throw Fail($"'{portAt}.endPort' requires a lower or equal 'port'"); - } - } - } - } - } - - private static JsonObject ValidateSandboxPolicy(JsonValue? raw, string at, CatalogContract contract) - { - if (raw is not JsonObject policy) - { - throw Fail($"'{at}' must be an object"); - } - - foreach (var key in BackendKeys) - { - if (policy.Has(key)) - { - throw Fail($"'{at}.{key}' names a containment backend; platform variants must stay backend-neutral"); - } - } - - OnlyFields(policy, new[] { "version", "filesystem", "network", "ui", "timeoutMs" }, at); - var version = NonEmptyString(policy.Get("version"), $"{at}.version"); - if (!contract.PolicyVersions.Contains(version)) - { - throw Fail($"'{at}.version' '{version}' is not a SandboxPolicy version registered in the catalog contract"); - } - - var filesystemValue = policy.Get("filesystem"); - if (filesystemValue is not null) - { - if (filesystemValue is not JsonObject filesystem) - { - throw Fail($"'{at}.filesystem' must be an object"); - } - - OnlyFields(filesystem, ComposableFields, $"{at}.filesystem"); - foreach (var field in ComposableFields) - { - var list = filesystem.Get(field); - if (list is not null) - { - var values = StringArray(list, $"{at}.filesystem.{field}"); - for (var index = 0; index < values.Count; index++) - { - ValidateTemplatePath(values[index], $"{at}.filesystem.{field}[{index}]", contract); - } - } - } - } - - var networkValue = policy.Get("network"); - if (networkValue is not null) - { - if (networkValue is not JsonObject network) - { - throw Fail($"'{at}.network' must be an object"); - } - - OnlyFields(network, new[] { "egress", "ingress" }, $"{at}.network"); - var egressValue = network.Get("egress"); - if (egressValue is not null) - { - if (egressValue is not JsonObject egress) - { - throw Fail($"'{at}.network.egress' must be an object"); - } - - OnlyFields(egress, new[] { "default", "allow", "deny" }, $"{at}.network.egress"); - if (egress.Get("default") is { } egressDefault && !IsString(egressDefault, "deny")) - { - throw Fail($"'{at}.network.egress.default' must be 'deny'; a default-allow grant is a wildcard"); - } - - if (egress.Get("allow") is { } allow) - { - ValidateNetworkRules(allow, $"{at}.network.egress.allow", false); - } - - if (egress.Get("deny") is { } deny) - { - ValidateNetworkRules(deny, $"{at}.network.egress.deny", true); - } - } - - var ingressValue = network.Get("ingress"); - if (ingressValue is not null) - { - if (ingressValue is not JsonObject ingress) - { - throw Fail($"'{at}.network.ingress' must be an object"); - } - - OnlyFields(ingress, new[] { "default", "hostLoopback" }, $"{at}.network.ingress"); - if (ingress.Get("default") is { } ingressDefault && !IsString(ingressDefault, "deny")) - { - throw Fail($"'{at}.network.ingress.default' must be 'deny'; a default-allow grant is a wildcard"); - } - - if (ingress.Get("hostLoopback") is { } loopback && !IsString(loopback, "allow") && !IsString(loopback, "deny")) - { - throw Fail($"'{at}.network.ingress.hostLoopback' is unsupported"); - } - } - } - - var uiValue = policy.Get("ui"); - if (uiValue is not null) - { - if (uiValue is not JsonObject ui) - { - throw Fail($"'{at}.ui' must be an object"); - } - - OnlyFields(ui, new[] { "allowWindows", "clipboard", "allowInputInjection" }, $"{at}.ui"); - foreach (var key in new[] { "allowWindows", "allowInputInjection" }) - { - if (ui.Get(key) is { } flag && flag is not JsonBool) - { - throw Fail($"'{at}.ui.{key}' must be a boolean"); - } - } - - if (ui.Get("clipboard") is { } clipboard && !(clipboard is JsonString c && (c.Value is "none" or "read" or "write" or "all"))) - { - throw Fail($"'{at}.ui.clipboard' is unsupported"); - } - } - - var timeout = policy.Get("timeoutMs"); - if (timeout is not null && (!IsInteger(timeout, out var ms) || ms < 1)) - { - throw Fail($"'{at}.timeoutMs' must be a positive integer"); - } - - return policy; - } - - // ----------------------------------------------------------------------- - // Entries - // ----------------------------------------------------------------------- - - private static List ValidateIdentity(JsonValue? raw, string at) - { - if (raw is not JsonArray items || items.Count == 0) - { - throw Fail($"'{at}' must be a non-empty array"); - } - - var predicates = new List(); - for (var index = 0; index < items.Count; index++) - { - var itemAt = $"{at}[{index}]"; - if (items[index] is not JsonObject item) - { - throw Fail($"'{itemAt}' must be an object"); - } - - var kind = item.Get("kind"); - if (IsString(kind, "purl")) - { - OnlyFields(item, new[] { "kind", "value", "versionRange" }, itemAt); - var value = NonEmptyString(item.Get("value"), $"{itemAt}.value"); - var parsed = Purl.Parse(value) ?? throw Fail($"'{itemAt}.value' is not a valid package URL"); - if (parsed.Version is not null) - { - throw Fail($"'{itemAt}.value' must not pin a version; use 'versionRange'"); - } - - var rangeValue = item.Get("versionRange"); - string? range = null; - if (rangeValue is not null) - { - range = NonEmptyString(rangeValue, $"{itemAt}.versionRange"); - if (!VersionRange.IsValid(range)) - { - throw Fail($"'{itemAt}.versionRange' is not a valid version range"); - } - } - - predicates.Add(new PurlPredicate(value, range)); - continue; - } - - if (IsString(kind, "invocation-name")) - { - OnlyFields(item, new[] { "kind", "names" }, itemAt); - var names = StringArray(item.Get("names"), $"{itemAt}.names", 1); - foreach (var name in names) - { - if (name.IndexOfAny(new[] { '\\', '/' }) >= 0) - { - throw Fail($"'{itemAt}.names' entry '{name}' must be a bare invocation name, not a path"); - } - } - - predicates.Add(new NamePredicate(names)); - continue; - } - - throw Fail($"'{itemAt}.kind' is not a supported identity kind"); - } - - var seen = new HashSet(StringComparer.Ordinal); - foreach (var key in predicates.SelectMany(IdentityKeys)) - { - if (!seen.Add(key)) - { - throw Fail($"'{at}' repeats identity '{key}'"); - } - } - - return predicates; - } - - /// Stable comparison keys for one predicate; invocation names always fold case. - private static IEnumerable IdentityKeys(IdentityPredicate predicate) => predicate switch - { - PurlPredicate purl => new[] { $"purl:{Purl.Parse(purl.Value)!.Key}" }, - NamePredicate names => names.Names.Select(name => $"invocation-name:{JsString.ToLower(name)}"), - _ => Array.Empty(), - }; - - private static List ValidateVariants(JsonValue? raw, string at, CatalogContract contract) - { - if (raw is not JsonArray items || items.Count == 0) - { - throw Fail($"'{at}' must be a non-empty array"); - } - - var selectors = new HashSet(StringComparer.Ordinal); - var variants = new List(); - for (var index = 0; index < items.Count; index++) - { - var itemAt = $"{at}[{index}]"; - if (items[index] is not JsonObject item) - { - throw Fail($"'{itemAt}' must be an object"); - } - - OnlyFields(item, new[] { "when", "dependencies", "sandboxPolicy" }, itemAt); - if (item.Get("when") is not JsonObject when) - { - throw Fail($"'{itemAt}.when' must be an object"); - } - - OnlyFields(when, new[] { "platform", "architecture" }, $"{itemAt}.when"); - if (when.Get("platform") is not JsonString platformValue || !CatalogPlatforms.All.Contains(platformValue.Value)) - { - throw Fail($"'{itemAt}.when.platform' must be one of {string.Join(", ", CatalogPlatforms.All)}"); - } - - var platform = platformValue.Value; - var architectureValue = when.Get("architecture"); - string? architecture = null; - if (architectureValue is not null) - { - if (architectureValue is not JsonString a || !CatalogArchitectures.All.Contains(a.Value)) - { - throw Fail($"'{itemAt}.when.architecture' must be one of {string.Join(", ", CatalogArchitectures.All)}"); - } - - architecture = a.Value; - } - - var selector = $"{platform}/{architecture ?? "*"}"; - if (!selectors.Add(selector)) - { - throw Fail(architecture is null - ? $"'{itemAt}' is a second architecture-neutral variant for '{platform}'" - : $"'{itemAt}' duplicates selector '{selector}'"); - } - - List? dependencies = null; - var dependenciesValue = item.Get("dependencies"); - if (dependenciesValue is not null) - { - if (dependenciesValue is not JsonArray list) - { - throw Fail($"'{itemAt}.dependencies' must be an array"); - } - - var seen = new HashSet(StringComparer.Ordinal); - dependencies = new List(); - for (var depIndex = 0; depIndex < list.Count; depIndex++) - { - var depAt = $"{itemAt}.dependencies[{depIndex}]"; - if (list[depIndex] is not JsonObject dependency) - { - throw Fail($"'{depAt}' must be an object"); - } - - OnlyFields(dependency, new[] { "entryId", "versionRange" }, depAt); - var entryId = NonEmptyString(dependency.Get("entryId"), $"{depAt}.entryId"); - if (!seen.Add(entryId)) - { - throw Fail($"'{depAt}.entryId' '{entryId}' is listed twice"); - } - - string? range = null; - if (dependency.Get("versionRange") is { } rangeValue) - { - range = NonEmptyString(rangeValue, $"{depAt}.versionRange"); - if (!VersionRange.IsValid(range)) - { - throw Fail($"'{depAt}.versionRange' is not a valid version range"); - } - } - - dependencies.Add(new Dependency(entryId, range)); - } - } - - var policy = ValidateSandboxPolicy(item.Get("sandboxPolicy"), $"{itemAt}.sandboxPolicy", contract); - variants.Add(new Variant(platform, architecture, dependencies, policy)); - } - - return variants; - } - - private static Entry ValidateEntry(JsonValue raw, string at, CatalogContract contract) - { - if (raw is not JsonObject entry) - { - throw Fail($"'{at}' must be an object"); - } - - OnlyFields(entry, new[] { "entryId", "entryRevision", "displayName", "identity", "platformVariants", "provenance" }, at); - var entryId = NonEmptyString(entry.Get("entryId"), $"{at}.entryId"); - if (!EntryIdPattern.IsMatch(entryId)) - { - throw Fail($"'{at}.entryId' '{entryId}' must be namespaced, e.g. 'tool:name'"); - } - - if (!IsInteger(entry.Get("entryRevision"), out var entryRevision) || entryRevision < 1) - { - throw Fail($"'{at}.entryRevision' must be a positive integer"); - } - - if (entry.Get("provenance") is not JsonObject provenance) - { - throw Fail($"'{at}.provenance' must be an object"); - } - - OnlyFields(provenance, new[] { "method", "sourceRevision" }, $"{at}.provenance"); - var displayName = NonEmptyString(entry.Get("displayName"), $"{at}.displayName"); - var identity = ValidateIdentity(entry.Get("identity"), $"{at}.identity"); - var variants = ValidateVariants(entry.Get("platformVariants"), $"{at}.platformVariants", contract); - var method = NonEmptyString(provenance.Get("method"), $"{at}.provenance.method"); - var sourceRevision = NonEmptyString(provenance.Get("sourceRevision"), $"{at}.provenance.sourceRevision"); - return new Entry(entryId, entryRevision, displayName, identity, variants, method, sourceRevision, entry); - } - - // ----------------------------------------------------------------------- - // Variant selection and dependency closure - // ----------------------------------------------------------------------- - - public static VariantSelection? SelectVariant(Entry entry, string platform, string architecture) - { - var forPlatform = entry.Variants.Where(variant => variant.Platform == platform).ToList(); - var exact = forPlatform.FirstOrDefault(variant => variant.Architecture == architecture); - if (exact is not null) - { - return new VariantSelection(exact, true); - } - - var neutral = forPlatform.FirstOrDefault(variant => variant.Architecture is null); - return neutral is null ? null : new VariantSelection(neutral, false); - } - - /// Depth-first closure: root first, dependencies in declaration order, each once; cycles reported. - public static ClosureResult DependencyClosure(Entry root, VariantSelection rootSelection, IReadOnlyDictionary byId, string platform, string architecture) - { - var nodes = new List(); - var done = new HashSet(StringComparer.Ordinal); - var stack = new List(); - ClosureResult? failure = null; - - void Visit(Entry entry, VariantSelection selection) - { - if (failure is not null) - { - return; - } - - if (stack.Contains(entry.EntryId)) - { - failure = new ClosureResult(null, "cycle", string.Join(" -> ", stack.Append(entry.EntryId))); - return; - } - - if (done.Contains(entry.EntryId)) - { - return; - } - - stack.Add(entry.EntryId); - nodes.Add(new ClosureNode(entry, selection.Variant, selection.Exact)); - foreach (var dependency in selection.Variant.DependencyList) - { - if (!byId.TryGetValue(dependency.EntryId, out var target)) - { - failure = new ClosureResult(null, "missing-entry", $"{entry.EntryId} -> {dependency.EntryId}"); - return; - } - - var selected = SelectVariant(target, platform, architecture); - if (selected is null) - { - failure = new ClosureResult(null, "unsupported-dependency", $"{entry.EntryId} -> {dependency.EntryId} has no {platform}/{architecture} variant"); - return; - } - - Visit(target, selected); - if (failure is not null) - { - return; - } - } - - stack.RemoveAt(stack.Count - 1); - done.Add(entry.EntryId); - } - - Visit(root, rootSelection); - return failure ?? new ClosureResult(nodes, null, null); - } - - /// v1 composition limits (design §4.5); a violation description or null. - public static string? CompositionViolation(IReadOnlyList nodes) - { - var versions = new List(); - foreach (var node in nodes) - { - if (!versions.Contains(node.Variant.PolicyVersion)) - { - versions.Add(node.Variant.PolicyVersion); - } - } - - if (versions.Count > 1) - { - versions.Sort(string.CompareOrdinal); - return $"mixed sandboxPolicy.version values ({string.Join(", ", versions)})"; - } - - if (nodes.Count < 2) - { - return null; - } - - foreach (var node in nodes) - { - foreach (var key in node.Variant.Policy.Keys) - { - if (key != "version" && key != "filesystem") - { - return $"'{node.Entry.EntryId}' uses '{key}', which has no v1 cross-entry composition rule"; - } - } - } - - return null; - } - - // ----------------------------------------------------------------------- - // Revision-level validation - // ----------------------------------------------------------------------- - - public static CatalogRevisionData ValidateRevision(JsonValue raw, CatalogContract contract) - { - if (raw is not JsonObject obj) - { - throw Fail("catalog root must be an object"); - } - - OnlyFields(obj, new[] { "catalogSchemaVersion", "catalogRevision", "entries" }, "catalog"); - if (!IsString(obj.Get("catalogSchemaVersion"), contract.SchemaVersion)) - { - throw Fail($"catalog.catalogSchemaVersion must be '{contract.SchemaVersion}'"); - } - - var catalogRevision = NonEmptyString(obj.Get("catalogRevision"), "catalog.catalogRevision"); - if (!IsRevisionId(catalogRevision)) - { - throw Fail($"catalog.catalogRevision '{catalogRevision}' must match YYYY-MM-DD.N"); - } - - if (obj.Get("entries") is not JsonArray rawEntries) - { - throw Fail("catalog.entries must be an array"); - } - - var entries = rawEntries.Items.Select((entry, index) => ValidateEntry(entry, $"entries[{index}]", contract)).ToList(); - var byId = new Dictionary(StringComparer.Ordinal); - foreach (var entry in entries) - { - if (byId.ContainsKey(entry.EntryId)) - { - throw Fail($"duplicate entryId '{entry.EntryId}'"); - } - - byId[entry.EntryId] = entry; - } - - foreach (var entry in entries) - { - foreach (var variant in entry.Variants) - { - foreach (var dependency in variant.DependencyList) - { - if (!byId.ContainsKey(dependency.EntryId)) - { - throw Fail($"'{entry.EntryId}' depends on unknown entry '{dependency.EntryId}' in this revision"); - } - - if (dependency.EntryId == entry.EntryId) - { - throw Fail($"'{entry.EntryId}' depends on itself"); - } - } - } - - foreach (var platform in CatalogPlatforms.All) - { - foreach (var architecture in CatalogArchitectures.All) - { - var selected = SelectVariant(entry, platform, architecture); - if (selected is null) - { - continue; - } - - var closure = DependencyClosure(entry, selected, byId, platform, architecture); - if (!closure.Ok) - { - throw Fail($"'{entry.EntryId}' on {platform}/{architecture}: {closure.Reason} ({closure.Detail})"); - } - - var violation = CompositionViolation(closure.Nodes!); - if (violation is not null) - { - throw Fail($"'{entry.EntryId}' on {platform}/{architecture}: {violation}"); - } - - var classes = new Dictionary>(StringComparer.Ordinal); - foreach (var field in ComposableFields) - { - classes[field] = closure.Nodes!.SelectMany(node => node.Variant.Paths(field)).ToList(); - } - - var overlap = FindCrossClassOverlap(classes, platform); - if (overlap is not null) - { - throw Fail($"'{entry.EntryId}' on {platform}/{architecture}: {overlap}"); - } - } - } - } - - return new CatalogRevisionData(contract.SchemaVersion, catalogRevision, entries); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs deleted file mode 100644 index 76cf39977..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsNumber.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Globalization; -using System.Text; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// ECMAScript Number::toString for IEEE doubles. -internal static class JsNumber -{ - /// String(value) in JavaScript. - public static string ToString(double value) - { - if (double.IsNaN(value)) - { - return "NaN"; - } - - if (double.IsPositiveInfinity(value)) - { - return "Infinity"; - } - - if (double.IsNegativeInfinity(value)) - { - return "-Infinity"; - } - - if (value == 0) - { - return "0"; - } - - var negative = value < 0; - // "R" yields the shortest round-trip digits on .NET Core 3.0 and later. - var text = Math.Abs(value).ToString("R", CultureInfo.InvariantCulture); - var (digits, n) = Decompose(text); - var k = digits.Length; - var builder = new StringBuilder(); - if (negative) - { - builder.Append('-'); - } - - if (k <= n && n <= 21) - { - builder.Append(digits).Append('0', n - k); - } - else if (0 < n && n <= 21) - { - builder.Append(digits, 0, n).Append('.').Append(digits, n, k - n); - } - else if (-6 < n && n <= 0) - { - builder.Append("0.").Append('0', -n).Append(digits); - } - else - { - var exponent = n - 1; - builder.Append(digits[0]); - if (k > 1) - { - builder.Append('.').Append(digits, 1, k - 1); - } - - builder.Append('e').Append(exponent >= 0 ? '+' : '-').Append(Math.Abs(exponent).ToString(CultureInfo.InvariantCulture)); - } - - return builder.ToString(); - } - - /// JSON.stringify(value) for a number: non-finite values are null. - public static string ToJson(double value) => double.IsFinite(value) ? ToString(value) : "null"; - - /// - /// Splits a positive .NET round-trip string into significant digits (no - /// leading/trailing zeros) and the decimal exponent n such that the - /// value is 0.digits × 10^n. - /// - private static (string Digits, int N) Decompose(string text) - { - var exponent = 0; - var e = text.IndexOfAny(new[] { 'E', 'e' }); - var mantissa = text; - if (e >= 0) - { - exponent = int.Parse(text.AsSpan(e + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); - mantissa = text.Substring(0, e); - } - - var dot = mantissa.IndexOf('.'); - var integerPart = dot >= 0 ? mantissa.Substring(0, dot) : mantissa; - var fractionPart = dot >= 0 ? mantissa.Substring(dot + 1) : string.Empty; - var all = integerPart + fractionPart; - var pointPosition = integerPart.Length + exponent; - var leading = 0; - while (leading < all.Length - 1 && all[leading] == '0') - { - leading++; - } - - all = all.Substring(leading); - pointPosition -= leading; - all = all.TrimEnd('0'); - if (all.Length == 0) - { - all = "0"; - } - - return (all, pointPosition); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs deleted file mode 100644 index 258cefa04..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/JsString.cs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Globalization; -using System.Text; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// JavaScript string semantics the TypeScript reference relies on. -internal static class JsString -{ - /// JavaScript \s / String.prototype.trim whitespace (WhiteSpace + LineTerminator). - public const string WhitespaceClass = "[\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]"; - - /// JavaScript . without the s flag: anything except a line terminator. - public const string AnyButLineTerminator = "[^\\n\\r\\u2028\\u2029]"; - - public static bool IsWhitespace(char c) => c switch - { - '\t' or '\n' or '\v' or '\f' or '\r' or ' ' or '\u00a0' or '\u1680' or '\u2028' or '\u2029' or '\u202f' or '\u205f' or '\u3000' or '\ufeff' => true, - _ => c >= '\u2000' && c <= '\u200a', - }; - - /// String.prototype.trim. - public static string Trim(string value) - { - var start = 0; - var end = value.Length; - while (start < end && IsWhitespace(value[start])) - { - start++; - } - - while (end > start && IsWhitespace(value[end - 1])) - { - end--; - } - - return value.Substring(start, end - start); - } - - // Code points whose lower-case mapping is newer than the Unicode data in - // .NET 8's invariant casing (Unicode 16 additions), taken from V8/ICU 78. - private static readonly Dictionary NewerLowerCase = BuildNewerLowerCase(); - - private static Dictionary BuildNewerLowerCase() - { - var map = new Dictionary - { - [0x1C89] = 0x1C8A, - [0xA7CB] = 0x0264, - [0xA7CC] = 0xA7CD, - [0xA7CE] = 0xA7CF, - [0xA7D2] = 0xA7D3, - [0xA7D4] = 0xA7D5, - [0xA7DA] = 0xA7DB, - [0xA7DC] = 0x019B, - }; - for (var cp = 0x10D50; cp <= 0x10D65; cp++) - { - map[cp] = cp + 0x20; - } - - for (var cp = 0x16EA0; cp <= 0x16EB8; cp++) - { - map[cp] = cp + 0x1B; - } - - return map; - } - - /// - /// String.prototype.toLowerCase: Unicode default (locale-independent) - /// lower-casing, including the unconditional U+0130 expansion and the - /// context-sensitive Greek final sigma, which invariant lower-casing omits. - /// - public static string ToLower(string value) - { - var simple = value.ToLowerInvariant(); - var needsFixup = false; - foreach (var c in value) - { - if (c == '\u0130' || c == '\u03a3' || char.IsSurrogate(c) || c == '\u1c89' || (c >= '\ua7cb' && c <= '\ua7dc')) - { - needsFixup = true; - break; - } - } - - if (!needsFixup) - { - return simple; - } - - var builder = new StringBuilder(value.Length + 4); - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c == '\u0130') - { - builder.Append("i\u0307"); - continue; - } - - if (c == '\u03a3') - { - builder.Append(IsFinalSigma(value, i) ? '\u03c2' : '\u03c3'); - continue; - } - - int cp = c; - var width = 1; - if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - cp = char.ConvertToUtf32(c, value[i + 1]); - width = 2; - } - - if (NewerLowerCase.TryGetValue(cp, out var lower)) - { - builder.Append(char.ConvertFromUtf32(lower)); - } - else - { - builder.Append(value.Substring(i, width).ToLowerInvariant()); - } - - i += width - 1; - } - - return builder.ToString(); - } - - // Unicode Final_Sigma: preceded by a cased letter (skipping case-ignorables) - // and not followed by one. - private static bool IsFinalSigma(string value, int index) - { - var before = false; - for (var i = index - 1; i >= 0; i--) - { - var (cp, start) = CodePointBefore(value, i); - i = start; - if (IsCaseIgnorable(cp)) - { - continue; - } - - before = IsCased(cp); - break; - } - - if (!before) - { - return false; - } - - for (var i = index + 1; i < value.Length; i++) - { - var cp = CodePointAt(value, i, out var width); - i += width - 1; - if (IsCaseIgnorable(cp)) - { - continue; - } - - return !IsCased(cp); - } - - return true; - } - - private static (int CodePoint, int Start) CodePointBefore(string value, int index) - { - if (char.IsLowSurrogate(value[index]) && index > 0 && char.IsHighSurrogate(value[index - 1])) - { - return (char.ConvertToUtf32(value[index - 1], value[index]), index - 1); - } - - return (value[index], index); - } - - private static int CodePointAt(string value, int index, out int width) - { - if (char.IsHighSurrogate(value[index]) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1])) - { - width = 2; - return char.ConvertToUtf32(value[index], value[index + 1]); - } - - width = 1; - return value[index]; - } - - private static bool IsCased(int cp) - { - var category = CharUnicodeInfo.GetUnicodeCategory(cp); - return category is UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter - || (cp >= 0x02B0 && cp <= 0x02B8) || (cp >= 0x02C0 && cp <= 0x02C1) || (cp >= 0x02E0 && cp <= 0x02E4) - || cp == 0x0345 || cp == 0x037A || (cp >= 0x2160 && cp <= 0x217F) || (cp >= 0x24B6 && cp <= 0x24E9); - } - - private static bool IsCaseIgnorable(int cp) - { - var category = CharUnicodeInfo.GetUnicodeCategory(cp); - return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format - or UnicodeCategory.ModifierLetter or UnicodeCategory.ModifierSymbol - || cp is 0x0027 or 0x002E or 0x003A or 0x005E or 0x0060 or 0x00A8 or 0x00AD or 0x00AF or 0x00B4 or 0x00B7 or 0x00B8 - or 0x2018 or 0x2019 or 0x2024 or 0x2027 or 0xFE13 or 0xFE52 or 0xFE55 or 0xFF07 or 0xFF0E or 0xFF1A; - } - - /// JavaScript < on strings: UTF-16 code unit order. - public static int Compare(string left, string right) => Math.Sign(string.CompareOrdinal(left, right)); -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs deleted file mode 100644 index 0e3ef52b8..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Json.cs +++ /dev/null @@ -1,437 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Globalization; -using System.Text; -using System.Text.Json; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// -/// A JSON value with JavaScript semantics: every number is an IEEE double, and -/// object keys follow ECMAScript own-property order (array-index keys ascending, -/// then the remaining keys in insertion order; a repeated key keeps its first -/// position and takes the last value, as JSON.parse does). -/// -internal abstract class JsonValue -{ -} - -internal sealed class JsonNull : JsonValue -{ - public static readonly JsonNull Instance = new(); - - private JsonNull() - { - } -} - -internal sealed class JsonBool : JsonValue -{ - public static readonly JsonBool True = new(true); - public static readonly JsonBool False = new(false); - - private JsonBool(bool value) => Value = value; - - public bool Value { get; } - - public static JsonBool Of(bool value) => value ? True : False; -} - -internal sealed class JsonNumber : JsonValue -{ - public JsonNumber(double value) => Value = value; - - public double Value { get; } -} - -internal sealed class JsonString : JsonValue -{ - public JsonString(string value) => Value = value; - - public string Value { get; } -} - -internal sealed class JsonArray : JsonValue -{ - public JsonArray() - { - Items = new List(); - } - - public JsonArray(IEnumerable items) - { - Items = new List(items); - } - - public List Items { get; } - - public int Count => Items.Count; - - public JsonValue this[int index] => Items[index]; -} - -internal sealed class JsonObject : JsonValue -{ - private readonly List _insertion = new(); - private readonly Dictionary _values = new(StringComparer.Ordinal); - - /// Own keys in ECMAScript OrdinaryOwnPropertyKeys order. - public IReadOnlyList Keys - { - get - { - var indices = new List<(uint Index, string Key)>(); - var others = new List(); - foreach (var key in _insertion) - { - if (TryArrayIndex(key, out var index)) - { - indices.Add((index, key)); - } - else - { - others.Add(key); - } - } - - if (indices.Count == 0) - { - return others; - } - - indices.Sort((a, b) => a.Index.CompareTo(b.Index)); - var ordered = new List(_insertion.Count); - ordered.AddRange(indices.Select(i => i.Key)); - ordered.AddRange(others); - return ordered; - } - } - - public int Count => _insertion.Count; - - public bool Has(string key) => _values.ContainsKey(key); - - /// The own property value, or null when absent (JavaScript undefined). - public JsonValue? Get(string key) => _values.TryGetValue(key, out var value) ? value : null; - - public void Set(string key, JsonValue value) - { - if (!_values.ContainsKey(key)) - { - _insertion.Add(key); - } - - _values[key] = value; - } - - public JsonObject With(string key, JsonValue? value) - { - if (value is not null) - { - Set(key, value); - } - - return this; - } - - internal static bool TryArrayIndex(string key, out uint index) - { - index = 0; - if (key.Length == 0 || key.Length > 10) - { - return false; - } - - if (key.Length > 1 && key[0] == '0') - { - return false; - } - - foreach (var c in key) - { - if (c < '0' || c > '9') - { - return false; - } - } - - if (!ulong.TryParse(key, NumberStyles.None, CultureInfo.InvariantCulture, out var value) || value > 4294967294UL) - { - return false; - } - - index = (uint)value; - return true; - } -} - -/// Parsing and JSON.stringify-compatible serialization. -internal static class JsonText -{ - private static readonly JsonReaderOptions ReaderOptions = new() - { - AllowTrailingCommas = false, - CommentHandling = JsonCommentHandling.Disallow, - MaxDepth = 4096, - }; - - /// Decodes UTF-8 bytes the way Node's readFileSync(..., 'utf8') does (a BOM is kept; invalid bytes become U+FFFD). - public static string DecodeUtf8(byte[] bytes) => Encoding.UTF8.GetString(bytes); - - /// Parses JSON text like JSON.parse. Throws on invalid input. - public static JsonValue Parse(string text) - { - // Re-encoding a .NET string can only produce well-formed UTF-8; lone - // surrogates in the text itself are replaced, which is also invalid JSON - // outside a string and rare inside one. - var bytes = Encoding.UTF8.GetBytes(text); - var reader = new Utf8JsonReader(bytes, ReaderOptions); - if (!reader.Read()) - { - throw new JsonException("Unexpected end of JSON input"); - } - - var value = ReadValue(ref reader); - if (reader.Read()) - { - throw new JsonException($"Unexpected non-whitespace character after JSON at byte {reader.TokenStartIndex}"); - } - - return value; - } - - private static JsonValue ReadValue(ref Utf8JsonReader reader) - { - switch (reader.TokenType) - { - case JsonTokenType.Null: - return JsonNull.Instance; - case JsonTokenType.True: - return JsonBool.True; - case JsonTokenType.False: - return JsonBool.False; - case JsonTokenType.Number: - return new JsonNumber(double.Parse(Encoding.UTF8.GetString(reader.ValueSpan), NumberStyles.Float, CultureInfo.InvariantCulture)); - case JsonTokenType.String: - return new JsonString(ReadString(ref reader)); - case JsonTokenType.StartArray: - { - var array = new JsonArray(); - while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) - { - array.Items.Add(ReadValue(ref reader)); - } - - return array; - } - - case JsonTokenType.StartObject: - { - var obj = new JsonObject(); - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - var key = ReadString(ref reader); - reader.Read(); - obj.Set(key, ReadValue(ref reader)); - } - - return obj; - } - - default: - throw new JsonException($"Unexpected token {reader.TokenType}"); - } - } - - /// Unescapes a string token without rejecting lone surrogate escapes (JavaScript strings may hold them). - private static string ReadString(ref Utf8JsonReader reader) - { - var span = reader.ValueSpan; - if (!reader.ValueIsEscaped) - { - return Encoding.UTF8.GetString(span); - } - - var builder = new StringBuilder(span.Length); - var runStart = 0; - var i = 0; - while (i < span.Length) - { - if (span[i] != (byte)'\\') - { - i++; - continue; - } - - builder.Append(Encoding.UTF8.GetString(span.Slice(runStart, i - runStart))); - var escape = (char)span[i + 1]; - switch (escape) - { - case '"': builder.Append('"'); i += 2; break; - case '\\': builder.Append('\\'); i += 2; break; - case '/': builder.Append('/'); i += 2; break; - case 'b': builder.Append('\b'); i += 2; break; - case 'f': builder.Append('\f'); i += 2; break; - case 'n': builder.Append('\n'); i += 2; break; - case 'r': builder.Append('\r'); i += 2; break; - case 't': builder.Append('\t'); i += 2; break; - case 'u': - builder.Append((char)int.Parse(Encoding.ASCII.GetString(span.Slice(i + 2, 4)), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); - i += 6; - break; - default: - throw new JsonException($"Bad escaped character '{escape}'"); - } - - runStart = i; - } - - builder.Append(Encoding.UTF8.GetString(span.Slice(runStart))); - return builder.ToString(); - } - - /// JSON.stringify(value) or, with , JSON.stringify(value, null, indent). - public static string Stringify(JsonValue value, int indent = 0) - { - var builder = new StringBuilder(); - Write(builder, value, indent, 0); - return builder.ToString(); - } - - private static void Write(StringBuilder builder, JsonValue value, int indent, int depth) - { - switch (value) - { - case JsonNull: - builder.Append("null"); - break; - case JsonBool b: - builder.Append(b.Value ? "true" : "false"); - break; - case JsonNumber n: - builder.Append(JsNumber.ToJson(n.Value)); - break; - case JsonString s: - Quote(builder, s.Value); - break; - case JsonArray a: - if (a.Count == 0) - { - builder.Append("[]"); - break; - } - - builder.Append('['); - for (var i = 0; i < a.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - NewLine(builder, indent, depth + 1); - Write(builder, a[i], indent, depth + 1); - } - - NewLine(builder, indent, depth); - builder.Append(']'); - break; - case JsonObject o: - var keys = o.Keys; - if (keys.Count == 0) - { - builder.Append("{}"); - break; - } - - builder.Append('{'); - for (var i = 0; i < keys.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - NewLine(builder, indent, depth + 1); - Quote(builder, keys[i]); - builder.Append(indent > 0 ? ": " : ":"); - Write(builder, o.Get(keys[i])!, indent, depth + 1); - } - - NewLine(builder, indent, depth); - builder.Append('}'); - break; - default: - throw new InvalidOperationException("unknown JSON value"); - } - } - - private static void NewLine(StringBuilder builder, int indent, int depth) - { - if (indent > 0) - { - builder.Append('\n'); - builder.Append(' ', indent * depth); - } - } - - /// JSON.stringify(string): ECMAScript QuoteJSONString, including well-formed lone-surrogate escapes. - public static string Quote(string value) - { - var builder = new StringBuilder(value.Length + 2); - Quote(builder, value); - return builder.ToString(); - } - - public static void Quote(StringBuilder builder, string value) - { - builder.Append('"'); - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - switch (c) - { - case '"': builder.Append("\\\""); continue; - case '\\': builder.Append("\\\\"); continue; - case '\b': builder.Append("\\b"); continue; - case '\f': builder.Append("\\f"); continue; - case '\n': builder.Append("\\n"); continue; - case '\r': builder.Append("\\r"); continue; - case '\t': builder.Append("\\t"); continue; - } - - if (c < 0x20) - { - AppendUnicodeEscape(builder, c); - } - else if (char.IsHighSurrogate(c)) - { - if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - builder.Append(c).Append(value[i + 1]); - i++; - } - else - { - AppendUnicodeEscape(builder, c); - } - } - else if (char.IsLowSurrogate(c)) - { - AppendUnicodeEscape(builder, c); - } - else - { - builder.Append(c); - } - } - - builder.Append('"'); - } - - private static void AppendUnicodeEscape(StringBuilder builder, char c) - { - builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs deleted file mode 100644 index e935d32cd..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Paths.cs +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text; -using System.Text.RegularExpressions; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// -/// Ports of Node's path.win32 / path.posix normalize, isAbsolute, and -/// parse().root (node v24.20.0 lib/path.js), plus the catalog's path rules. -/// -internal static class Paths -{ - private static readonly string[] WindowsReservedNames = - { - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - "COM\u00b9", "COM\u00b2", "COM\u00b3", - "LPT\u00b9", "LPT\u00b2", "LPT\u00b3", - }; - - private static readonly Regex TrailingSeparators = new("[\\\\/]+\\z", RegexOptions.CultureInvariant); - private static readonly Regex WindowsSeparators = new("[\\\\/]+", RegexOptions.CultureInvariant); - private static readonly Regex PosixSeparators = new("/+", RegexOptions.CultureInvariant); - - /// JavaScript String.prototype.slice. - internal static string Slice(string value, int start, int? end = null) - { - var length = value.Length; - var from = start < 0 ? Math.Max(length + start, 0) : Math.Min(start, length); - var e = end ?? length; - var to = e < 0 ? Math.Max(length + e, 0) : Math.Min(e, length); - return to <= from ? string.Empty : value.Substring(from, to - from); - } - - private static bool IsPathSeparator(char c) => c == '/' || c == '\\'; - - private static bool IsPosixPathSeparator(char c) => c == '/'; - - private static bool IsWindowsDeviceRoot(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); - - private static bool IsWindowsReservedName(string path, int colonIndex) - { - var devicePart = Slice(path, 0, colonIndex).ToUpperInvariant(); - return Array.IndexOf(WindowsReservedNames, devicePart) >= 0; - } - - private static string NormalizeString(string path, bool allowAboveRoot, char separator, Func isPathSeparator) - { - var res = string.Empty; - var lastSegmentLength = 0; - var lastSlash = -1; - var dots = 0; - var code = '\0'; - for (var i = 0; i <= path.Length; ++i) - { - if (i < path.Length) - { - code = path[i]; - } - else if (isPathSeparator(code)) - { - break; - } - else - { - code = '/'; - } - - if (isPathSeparator(code)) - { - if (lastSlash == i - 1 || dots == 1) - { - // NOOP - } - else if (dots == 2) - { - if (res.Length < 2 || lastSegmentLength != 2 || res[res.Length - 1] != '.' || res[res.Length - 2] != '.') - { - if (res.Length > 2) - { - var lastSlashIndex = res.Length - lastSegmentLength - 1; - if (lastSlashIndex == -1) - { - res = string.Empty; - lastSegmentLength = 0; - } - else - { - res = Slice(res, 0, lastSlashIndex); - lastSegmentLength = res.Length - 1 - res.LastIndexOf(separator); - } - - lastSlash = i; - dots = 0; - continue; - } - else if (res.Length != 0) - { - res = string.Empty; - lastSegmentLength = 0; - lastSlash = i; - dots = 0; - continue; - } - } - - if (allowAboveRoot) - { - res += res.Length > 0 ? $"{separator}.." : ".."; - lastSegmentLength = 2; - } - } - else - { - if (res.Length > 0) - { - res += separator + Slice(path, lastSlash + 1, i); - } - else - { - res = Slice(path, lastSlash + 1, i); - } - - lastSegmentLength = i - lastSlash - 1; - } - - lastSlash = i; - dots = 0; - } - else if (code == '.' && dots != -1) - { - ++dots; - } - else - { - dots = -1; - } - } - - return res; - } - - /// Node path.win32.normalize. - public static string Win32Normalize(string path) - { - var len = path.Length; - if (len == 0) - { - return "."; - } - - var rootEnd = 0; - string? device = null; - var isAbsolute = false; - var code = path[0]; - - if (len == 1) - { - return IsPosixPathSeparator(code) ? "\\" : path; - } - - if (IsPathSeparator(code)) - { - isAbsolute = true; - if (IsPathSeparator(path[1])) - { - var j = 2; - var last = j; - while (j < len && !IsPathSeparator(path[j])) - { - j++; - } - - if (j < len && j != last) - { - var firstPart = Slice(path, last, j); - last = j; - while (j < len && IsPathSeparator(path[j])) - { - j++; - } - - if (j < len && j != last) - { - last = j; - while (j < len && !IsPathSeparator(path[j])) - { - j++; - } - - if (j == len || j != last) - { - if (firstPart == "." || firstPart == "?") - { - device = $"\\\\{firstPart}"; - rootEnd = 4; - var colon = path.IndexOf(':'); - var possibleDevice = Slice(path, 4, colon + 1); - if (IsWindowsReservedName(possibleDevice, possibleDevice.Length - 1)) - { - device = $"\\\\?\\{possibleDevice}"; - rootEnd = 4 + possibleDevice.Length; - } - } - else if (j == len) - { - return $"\\\\{firstPart}\\{Slice(path, last)}\\"; - } - else - { - device = $"\\\\{firstPart}\\{Slice(path, last, j)}"; - rootEnd = j; - } - } - } - } - } - else - { - rootEnd = 1; - } - } - else - { - var colon = path.IndexOf(':'); - if (colon > 0) - { - if (IsWindowsDeviceRoot(code) && colon == 1) - { - device = Slice(path, 0, 2); - rootEnd = 2; - if (len > 2 && IsPathSeparator(path[2])) - { - isAbsolute = true; - rootEnd = 3; - } - } - else if (IsWindowsReservedName(path, colon)) - { - device = Slice(path, 0, colon + 1); - rootEnd = colon + 1; - } - } - } - - var tail = rootEnd < len ? NormalizeString(Slice(path, rootEnd), !isAbsolute, '\\', IsPathSeparator) : string.Empty; - if (tail.Length == 0 && !isAbsolute) - { - tail = "."; - } - - if (tail.Length > 0 && IsPathSeparator(path[len - 1])) - { - tail += "\\"; - } - - if (!isAbsolute && device is null && path.Contains(':')) - { - if (tail.Length >= 2 && IsWindowsDeviceRoot(tail[0]) && tail[1] == ':') - { - return $".\\{tail}"; - } - - var index = path.IndexOf(':'); - do - { - if (index == len - 1 || IsPathSeparator(path[index + 1])) - { - return $".\\{tail}"; - } - } - while ((index = path.IndexOf(':', index + 1)) != -1); - } - - var colonIndex = path.IndexOf(':'); - if (IsWindowsReservedName(path, colonIndex)) - { - return $".\\{device ?? string.Empty}{tail}"; - } - - if (device is null) - { - return isAbsolute ? $"\\{tail}" : tail; - } - - return isAbsolute ? $"{device}\\{tail}" : $"{device}{tail}"; - } - - /// Node path.win32.isAbsolute. - public static bool Win32IsAbsolute(string path) - { - var len = path.Length; - if (len == 0) - { - return false; - } - - var code = path[0]; - return IsPathSeparator(code) || (len > 2 && IsWindowsDeviceRoot(code) && path[1] == ':' && IsPathSeparator(path[2])); - } - - /// Node path.win32.parse(path).root. - public static string Win32Root(string path) - { - if (path.Length == 0) - { - return string.Empty; - } - - var len = path.Length; - var rootEnd = 0; - var code = path[0]; - if (len == 1) - { - return IsPathSeparator(code) ? path : string.Empty; - } - - if (IsPathSeparator(code)) - { - rootEnd = 1; - if (IsPathSeparator(path[1])) - { - var j = 2; - var last = j; - while (j < len && !IsPathSeparator(path[j])) - { - j++; - } - - if (j < len && j != last) - { - last = j; - while (j < len && IsPathSeparator(path[j])) - { - j++; - } - - if (j < len && j != last) - { - last = j; - while (j < len && !IsPathSeparator(path[j])) - { - j++; - } - - if (j == len) - { - rootEnd = j; - } - else if (j != last) - { - rootEnd = j + 1; - } - } - } - } - } - else if (IsWindowsDeviceRoot(code) && path[1] == ':') - { - if (len <= 2) - { - return path; - } - - rootEnd = 2; - if (IsPathSeparator(path[2])) - { - if (len == 3) - { - return path; - } - - rootEnd = 3; - } - } - - return rootEnd > 0 ? Slice(path, 0, rootEnd) : string.Empty; - } - - /// Node path.posix.normalize. - public static string PosixNormalize(string path) - { - if (path.Length == 0) - { - return "."; - } - - var isAbsolute = path[0] == '/'; - var trailingSeparator = path[path.Length - 1] == '/'; - path = NormalizeString(path, !isAbsolute, '/', IsPosixPathSeparator); - if (path.Length == 0) - { - if (isAbsolute) - { - return "/"; - } - - return trailingSeparator ? "./" : "."; - } - - if (trailingSeparator) - { - path += "/"; - } - - return isAbsolute ? $"/{path}" : path; - } - - /// Node path.posix.isAbsolute. - public static bool PosixIsAbsolute(string path) => path.Length > 0 && path[0] == '/'; - - /// Node path.posix.parse(path).root. - public static string PosixRoot(string path) => path.Length > 0 && path[0] == '/' ? "/" : string.Empty; - - /// The one casing rule: Windows and macOS fold case, Linux is exact. - public static bool FoldsCase(string platform) => platform != CatalogPlatforms.Linux; - - /// Applies to one value. - public static string CaseKey(string value, string platform) => FoldsCase(platform) ? JsString.ToLower(value) : value; - - /// Catalog normalizePath: normalize, then drop trailing separators unless only the root remains. - public static string NormalizePath(string value, string platform) - { - var windows = platform == CatalogPlatforms.Windows; - var normalized = windows ? Win32Normalize(value) : PosixNormalize(value); - var root = windows ? Win32Root(normalized) : PosixRoot(normalized); - return normalized.Length > root.Length ? TrailingSeparators.Replace(normalized, string.Empty) : normalized; - } - - /// Catalog isAbsolutePath. - public static bool IsAbsolutePath(string value, string platform) => - platform == CatalogPlatforms.Windows ? Win32IsAbsolute(value) : PosixIsAbsolute(value); - - /// Catalog pathKeySegments. - public static List PathKeySegments(string value, string platform) - { - var separators = platform == CatalogPlatforms.Windows ? WindowsSeparators : PosixSeparators; - var raw = separators.Split(value); - var segments = new List(); - for (var index = 0; index < raw.Length; index++) - { - var segment = raw[index]; - if (segment != "." && (segment.Length != 0 || index == 0)) - { - segments.Add(segment); - } - } - - if (segments.Count > 1 && segments[segments.Count - 1].Length == 0) - { - segments.RemoveAt(segments.Count - 1); - } - - return segments.Select(segment => CaseKey(segment, platform)).ToList(); - } - - /// Joins key segments into one comparison key (TypeScript joins with U+0000). - public static string JoinKey(IEnumerable segments) - { - var builder = new StringBuilder(); - var first = true; - foreach (var segment in segments) - { - if (!first) - { - builder.Append('\0'); - } - - builder.Append(segment); - first = false; - } - - return builder.ToString(); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs deleted file mode 100644 index 02ec62f01..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/Purl.cs +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text; -using System.Text.RegularExpressions; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// Parsed package URL, reduced to the parts identity matching uses (TypeScript purl.ts). -internal sealed record ParsedPurl(string Key, string? Version); - -internal static class Purl -{ - private static readonly Regex TypePattern = new("^[a-zA-Z][a-zA-Z0-9.+-]*\\z", RegexOptions.CultureInvariant); - - /// Parses pkg:type/namespace/name@version?qualifiers#subpath; null when invalid. - public static ParsedPurl? Parse(string value) - { - if (!value.StartsWith("pkg:", StringComparison.Ordinal)) - { - return null; - } - - var rest = value.Substring("pkg:".Length); - var hash = rest.IndexOf('#'); - if (hash >= 0) - { - rest = rest.Substring(0, hash); - } - - var query = rest.IndexOf('?'); - if (query >= 0) - { - rest = rest.Substring(0, query); - } - - var lastSlash = rest.LastIndexOf('/'); - var at = rest.LastIndexOf('@'); - string? version = null; - if (at > lastSlash) - { - version = DecodeUriComponent(rest.Substring(at + 1)); - if (version is null) - { - return null; - } - - rest = rest.Substring(0, at); - } - - var segments = rest.Split('/'); - if (segments.Length < 2 || segments.Any(segment => segment.Length == 0)) - { - return null; - } - - var type = segments[0]; - if (!TypePattern.IsMatch(type)) - { - return null; - } - - return new ParsedPurl( - $"{type.ToLowerInvariant()}/{string.Join("/", segments.Skip(1))}", - version is not null && version.Length > 0 ? version : null); - } - - /// ECMAScript decodeURIComponent; null where JavaScript throws URIError. - public static string? DecodeUriComponent(string value) - { - if (value.IndexOf('%') < 0) - { - return value; - } - - var builder = new StringBuilder(value.Length); - var i = 0; - while (i < value.Length) - { - var c = value[i]; - if (c != '%') - { - builder.Append(c); - i++; - continue; - } - - if (!TryHexByte(value, i, out var first)) - { - return null; - } - - i += 3; - if (first < 0x80) - { - builder.Append((char)first); - continue; - } - - int count; - int codePoint; - if ((first & 0xE0) == 0xC0) - { - count = 1; - codePoint = first & 0x1F; - } - else if ((first & 0xF0) == 0xE0) - { - count = 2; - codePoint = first & 0x0F; - } - else if ((first & 0xF8) == 0xF0) - { - count = 3; - codePoint = first & 0x07; - } - else - { - return null; - } - - for (var k = 0; k < count; k++) - { - if (i >= value.Length || value[i] != '%' || !TryHexByte(value, i, out var next) || (next & 0xC0) != 0x80) - { - return null; - } - - codePoint = (codePoint << 6) | (next & 0x3F); - i += 3; - } - - var minimum = count switch { 1 => 0x80, 2 => 0x800, _ => 0x10000 }; - if (codePoint < minimum || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) - { - return null; - } - - builder.Append(char.ConvertFromUtf32(codePoint)); - } - - return builder.ToString(); - } - - private static bool TryHexByte(string value, int percent, out int result) - { - result = 0; - if (percent + 2 >= value.Length) - { - return false; - } - - var high = HexValue(value[percent + 1]); - var low = HexValue(value[percent + 2]); - if (high < 0 || low < 0) - { - return false; - } - - result = (high << 4) | low; - return true; - } - - private static int HexValue(char c) => c switch - { - >= '0' and <= '9' => c - '0', - >= 'a' and <= 'f' => c - 'a' + 10, - >= 'A' and <= 'F' => c - 'A' + 10, - _ => -1, - }; -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs deleted file mode 100644 index 483721705..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Internal/VersionRange.cs +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Globalization; -using System.Text.RegularExpressions; - -namespace Microsoft.Mxc.PolicyCatalog.Internal; - -/// -/// Minimal version-range support for catalog v1 (TypeScript version-range.ts). -/// range := set ("||" set)*, set := comparator (" " comparator)*, -/// comparator := op? N(.N(.N)?)?. No operator is a prefix match. -/// Prerelease/build metadata on evidence versions is ignored. -/// -internal static class VersionRange -{ - private enum Op - { - GreaterOrEqual, - LessOrEqual, - Greater, - Less, - Equal, - Prefix, - } - - private sealed record Comparator(Op Op, double[] Version, int Parts); - - private static readonly Regex ComparatorPattern = new("^(>=|<=|>|<|=)?([0-9]+)(?:\\.([0-9]+)(?:\\.([0-9]+))?)?\\z", RegexOptions.CultureInvariant); - - private static readonly Regex EvidencePattern = new( - "^v?([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(?:[-+]" + JsString.AnyButLineTerminator + "*)?\\z", - RegexOptions.CultureInvariant); - - private static readonly Regex Whitespace = new(JsString.WhitespaceClass + "+", RegexOptions.CultureInvariant); - - private static double Num(Group group) => group.Success ? double.Parse(group.Value, NumberStyles.None, CultureInfo.InvariantCulture) : 0; - - private static Comparator? ParseComparator(string token) - { - var match = ComparatorPattern.Match(token); - if (!match.Success) - { - return null; - } - - var parts = match.Groups[4].Success ? 3 : match.Groups[3].Success ? 2 : 1; - var op = match.Groups[1].Success - ? match.Groups[1].Value switch - { - ">=" => Op.GreaterOrEqual, - "<=" => Op.LessOrEqual, - ">" => Op.Greater, - "<" => Op.Less, - _ => Op.Equal, - } - : Op.Prefix; - return new Comparator(op, new[] { Num(match.Groups[2]), Num(match.Groups[3]), Num(match.Groups[4]) }, parts); - } - - private static List>? ParseRange(string range) - { - if (JsString.Trim(range).Length == 0) - { - return null; - } - - var sets = new List>(); - foreach (var alternative in range.Split("||")) - { - var tokens = Whitespace.Split(JsString.Trim(alternative)).Where(token => token.Length > 0).ToList(); - if (tokens.Count == 0) - { - return null; - } - - var comparators = new List(); - foreach (var token in tokens) - { - var comparator = ParseComparator(token); - if (comparator is null) - { - return null; - } - - comparators.Add(comparator); - } - - sets.Add(comparators); - } - - return sets; - } - - /// True when uses the supported v1 range grammar. - public static bool IsValid(string range) => ParseRange(range) is not null; - - /// Parses tool version evidence such as v22.3.1 or 10.9.0-rc.1. - public static double[]? ParseEvidence(string value) - { - var match = EvidencePattern.Match(JsString.Trim(value)); - if (!match.Success) - { - return null; - } - - return new[] { Num(match.Groups[1]), Num(match.Groups[2]), Num(match.Groups[3]) }; - } - - private static int Compare(double[] left, double[] right) - { - for (var i = 0; i < 3; i++) - { - if (left[i] != right[i]) - { - return left[i] < right[i] ? -1 : 1; - } - } - - return 0; - } - - private static bool Satisfies(double[] version, Comparator comparator) - { - var order = Compare(version, comparator.Version); - return comparator.Op switch - { - Op.GreaterOrEqual => order >= 0, - Op.LessOrEqual => order <= 0, - Op.Greater => order > 0, - Op.Less => order < 0, - Op.Equal => order == 0, - _ => Enumerable.Range(0, comparator.Parts).All(i => version[i] == comparator.Version[i]), - }; - } - - /// Evaluates against ; null when either cannot be evaluated. - public static bool? Satisfies(string version, string range) - { - var parsedVersion = ParseEvidence(version); - var parsedRange = ParseRange(range); - if (parsedVersion is null || parsedRange is null) - { - return null; - } - - return parsedRange.Any(set => set.All(comparator => Satisfies(parsedVersion, comparator))); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj deleted file mode 100644 index ba4c77930..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj +++ /dev/null @@ -1,46 +0,0 @@ - - - - net8.0 - enable - enable - latest - true - true - Microsoft.Mxc.PolicyCatalog - Microsoft.Mxc.PolicyCatalog - Microsoft.Mxc.PolicyCatalog - 0.0.0-prototype - Microsoft - PROTOTYPE - not supported for production use. Native .NET implementation of the MXC policy catalog: resolves tool inputs to a candidate lower-bound SandboxPolicy from an integrity-validated, bundled catalog. - MIT - true - - - - - - - - - - - - - - diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs deleted file mode 100644 index 1f043d834..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalog.cs +++ /dev/null @@ -1,579 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.RegularExpressions; -using Microsoft.Mxc.PolicyCatalog.Internal; - -namespace Microsoft.Mxc.PolicyCatalog; - -/// -/// Read-only view over one catalog store. Runtime resolution and inspection are separate operations; -/// neither mutates catalog data or any consumer state. -/// -public sealed class PolicyCatalog -{ - private readonly CatalogStore _store; - private readonly IHostEnvironment _host; - - /// Creates a catalog over . - /// The catalog store. - /// Host facts; defaults to . - public PolicyCatalog(CatalogStore store, IHostEnvironment? host = null) - { - _store = store ?? throw new ArgumentNullException(nameof(store)); - _host = host ?? SystemHostEnvironment.Instance; - } - - /// The store this catalog reads. - public CatalogStore Store => _store; - - /// The installed default revision's schema version and ID (design §5.2). - /// The catalog info. - /// The revision fails integrity or validation. - public CatalogInfo GetCatalogInfo() - { - var revision = _store.Revision(); - return new CatalogInfo(revision.SchemaVersion, revision.CatalogRevision); - } - - /// Metadata for every entry in the installed revision, ordered by entry ID. Never includes a policy body. - /// Entry metadata. - /// The revision fails integrity or validation. - public IReadOnlyList ListCatalogEntries() - { - var revision = _store.Revision(); - return revision.Entries - .OrderBy(entry => entry.EntryId, StringComparer.Ordinal) - .Select(entry => new CatalogEntryMetadata( - revision.CatalogRevision, - entry.EntryId, - entry.EntryRevision, - entry.DisplayName, - entry.Identity.Select(predicate => predicate switch - { - PurlPredicate purl => new CatalogIdentityMetadata("purl") { Value = purl.Value, VersionRange = purl.VersionRange }, - NamePredicate names => new CatalogIdentityMetadata("invocation-name") { Names = names.Names.ToList() }, - _ => throw new InvalidOperationException(), - }).ToList(), - entry.Variants.Select(variant => new CatalogVariantMetadata( - variant.Platform, - variant.Architecture, - variant.DependencyList.Select(dependency => dependency.EntryId).ToList(), - variant.PolicyVersion)).ToList(), - new CatalogProvenance(entry.Method, entry.SourceRevision))) - .ToList(); - } - - /// The composed candidate policy for several tools, or null when no policy can be resolved. - /// Tool inputs, resolved in one pass. - /// Lookup context; null means all defaults. - /// The policy or null. - /// A library failure; never reported as absence. - public CatalogSandboxPolicy? ResolveSandboxPolicy(IReadOnlyList tools, ResolveContext? context = null) => - Resolve(tools, context).Policy; - - /// The composed candidate policy for one tool; exactly a one-element list. - /// The tool input (a string converts implicitly). - /// Lookup context. - /// The policy or null. - /// A library failure. - public CatalogSandboxPolicy? ResolveSandboxPolicy(ToolInput tool, ResolveContext? context = null) => - Resolve(new[] { tool }, context).Policy; - - /// - /// Resolves several tools in one pass and returns the composed candidate policy with attribution and warnings. - /// is null for an empty input, when nothing matched, or - /// when a required symbol is unresolved. The result is a candidate lower bound, not authorization. - /// - /// Tool inputs. - /// Lookup context. - /// The resolution. - /// A library failure. - public SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => - Resolve(tools, context); - - /// Resolves one tool; exactly a one-element list. - /// The tool input. - /// Lookup context. - /// The resolution. - /// A library failure. - public SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(ToolInput tool, ResolveContext? context = null) => - Resolve(new[] { tool }, context); - - private sealed record EntryMatch(Entry Entry, VariantSelection Selection, List Satisfied); - - private static PolicyCatalogException InvalidContext(string message) => new(PolicyCatalogErrorReason.InvalidContext, message); - - private static ToolInput ToCandidate(ToolInput? input, int index) - { - if (input is null) - { - throw InvalidContext($"tool input {index} must be a string or a ToolCandidate"); - } - - if (string.IsNullOrEmpty(input.InvocationName)) - { - throw InvalidContext($"tool input {index}: invocationName must be a non-empty string"); - } - - if (input.InvocationName.IndexOfAny(new[] { '\\', '/' }) >= 0) - { - throw InvalidContext($"tool input {index}: invocationName must be a bare name, not a path"); - } - - if (input.PackageUrl is { Length: 0 }) - { - throw InvalidContext($"tool input {index}: packageUrl must be a non-empty string when present"); - } - - if (input.DetectedVersion is { Length: 0 }) - { - throw InvalidContext($"tool input {index}: detectedVersion must be a non-empty string when present"); - } - - return input; - } - - private static string DescribeInput(int index, ToolInput tool) => $"input {index} ('{tool.InvocationName}')"; - - /// Own keys in ECMAScript property order (array-index keys first, ascending). - internal static List JsKeyOrder(IEnumerable keys) - { - var obj = new JsonObject(); - foreach (var key in keys) - { - obj.Set(key, JsonNull.Instance); - } - - return obj.Keys.ToList(); - } - - private SandboxConfigResolution Resolve(IReadOnlyList tools, ResolveContext? context) - { - ArgumentNullException.ThrowIfNull(tools); - var ctx = context ?? new ResolveContext(); - var candidates = tools.Select((tool, index) => ToCandidate(tool, index)).ToList(); - ValidateContext(ctx); - var revision = _store.Revision(ctx.CatalogRevision); - var platform = ctx.Platform ?? _host.Platform(); - var allowWeak = ctx.AllowWeakIdentityFallback; - - // The native architecture is resolved lazily: a lookup that never needs - // variant selection must not fail on host detection (design §4.4). - var architecture = ctx.Architecture; - string EffectiveArchitecture() - { - architecture ??= _host.NativeArchitecture(); - return architecture; - } - - var warnings = new List(); - var toolRecords = new List(); - var selected = new List(); - var selectedIds = new HashSet(StringComparer.Ordinal); - var byId = new Dictionary(StringComparer.Ordinal); - foreach (var entry in revision.Entries) - { - byId[entry.EntryId] = entry; - } - - var ordered = revision.Entries.OrderBy(entry => entry.EntryId, StringComparer.Ordinal).ToList(); - for (var inputIndex = 0; inputIndex < candidates.Count; inputIndex++) - { - var tool = candidates[inputIndex]; - var matches = MatchTool(ordered, tool, inputIndex, platform, allowWeak, EffectiveArchitecture, warnings); - toolRecords.Add(new ToolDiagnostics( - inputIndex, - matches.Select(match => new ToolMatch( - match.Entry.EntryId, - match.Entry.EntryRevision, - match.Satisfied.Select(predicate => new MatchedIdentity(predicate.Kind, predicate.Strength)).ToList())).ToList())); - if (matches.Count > 1) - { - warnings.Add($"{DescribeInput(inputIndex, tool)} matched {matches.Count} entries ({string.Join(", ", matches.Select(m => m.Entry.EntryId))}); all contribute"); - } - - foreach (var match in matches) - { - var closure = Catalog.DependencyClosure(match.Entry, match.Selection, byId, platform, EffectiveArchitecture()); - if (!closure.Ok) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.InvalidCatalog, $"dependency resolution failed: {closure.Reason} ({closure.Detail})"); - } - - foreach (var node in closure.Nodes!) - { - if (selectedIds.Add(node.Entry.EntryId)) - { - selected.Add(node); - } - } - } - } - - var diagnostics = new ResolutionDiagnostics(revision.CatalogRevision, toolRecords, DependencyRecords(selected, byId), warnings); - if (selected.Count == 0) - { - return new SandboxConfigResolution(null, diagnostics); - } - - if (ctx.Architecture is null) - { - warnings.Add($"architecture was not specified; variants were selected for the native system architecture '{EffectiveArchitecture()}'; the tool's architecture was not verified"); - } - - foreach (var node in selected) - { - if (!node.Exact) - { - warnings.Add($"{node.Entry.EntryId} uses its architecture-neutral {platform} variant; no {EffectiveArchitecture()}-specific variant exists"); - } - } - - var violation = Catalog.CompositionViolation(selected); - if (violation is not null) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.CompositionConflict, $"selected entries cannot be composed: {violation}"); - } - - var symbols = ResolveSymbols(selected, ctx, platform, warnings); - if (symbols is null) - { - return new SandboxConfigResolution(null, diagnostics); - } - - return new SandboxConfigResolution(PolicyConversion.ToPolicy(ComposePolicy(selected, symbols, platform)), diagnostics); - } - - private static List MatchTool( - List ordered, - ToolInput tool, - int inputIndex, - string platform, - bool allowWeak, - Func architecture, - List warnings) - { - ParsedPurl? purl = null; - if (tool.PackageUrl is not null) - { - purl = Purl.Parse(tool.PackageUrl) - ?? throw InvalidContext($"{DescribeInput(inputIndex, tool)}: '{tool.PackageUrl}' is not a valid package URL"); - } - - var invocation = Paths.CaseKey(tool.InvocationName, platform); - var matches = new List(); - var skipped = new List(); - foreach (var entry in ordered) - { - var satisfied = entry.Identity.Where(predicate => predicate switch - { - PurlPredicate p => purl is not null && Purl.Parse(p.Value)?.Key == purl.Key, - NamePredicate n => n.Names.Any(name => Paths.CaseKey(name, platform) == invocation), - _ => false, - }).ToList(); - if (satisfied.Count == 0) - { - continue; - } - - var strong = satisfied.Any(predicate => predicate.Strength == "strong"); - if (!strong && !allowWeak) - { - skipped.Add($"{entry.EntryId} matched only by invocation name and allowWeakIdentityFallback is not enabled"); - continue; - } - - var selection = Catalog.SelectVariant(entry, platform, architecture()); - if (selection is null) - { - skipped.Add($"{entry.EntryId} has no variant for {platform}/{architecture()}"); - continue; - } - - foreach (var predicate in satisfied) - { - if (predicate is not PurlPredicate { VersionRange: { } range }) - { - continue; - } - - var evidence = tool.DetectedVersion ?? purl?.Version; - if (evidence is null) - { - continue; - } - - var inRange = VersionRange.Satisfies(evidence, range); - if (inRange != true) - { - warnings.Add($"{DescribeInput(inputIndex, tool)}: detected version '{evidence}' {(inRange == false ? "is outside" : "could not be compared with")} the reviewed range '{range}' for {entry.EntryId}"); - } - } - - if (!strong) - { - warnings.Add($"{DescribeInput(inputIndex, tool)} matched {entry.EntryId} only by invocation name (weak identity)"); - } - - matches.Add(new EntryMatch(entry, selection, satisfied)); - } - - if (matches.Count == 0) - { - warnings.Add($"{DescribeInput(inputIndex, tool)} matched no eligible catalog entry{(skipped.Count > 0 ? $": {string.Join("; ", skipped)}" : string.Empty)}"); - } - - return matches; - } - - private void ValidateContext(ResolveContext ctx) - { - if (ctx.Platform is not null && !CatalogPlatforms.All.Contains(ctx.Platform)) - { - throw InvalidContext($"ResolveContext.platform '{ctx.Platform}' is unsupported"); - } - - if (ctx.Architecture is not null && !CatalogArchitectures.All.Contains(ctx.Architecture)) - { - throw InvalidContext($"ResolveContext.architecture '{ctx.Architecture}' is unsupported"); - } - - if (ctx.ProjectRoot is { Length: 0 }) - { - throw InvalidContext("ResolveContext.projectRoot must be a non-empty string when present"); - } - - if (ctx.Symbols is null) - { - return; - } - - foreach (var name in JsKeyOrder(ctx.Symbols.Keys)) - { - if (!_store.Contract.Symbols.TryGetValue(name, out var definition)) - { - throw InvalidContext($"ResolveContext.symbols.{name} is not a catalog symbol"); - } - - if (definition.Source == "context") - { - throw InvalidContext($"symbol '{name}' is supplied through ResolveContext.projectRoot, not symbols"); - } - - if (ctx.Symbols[name] is null) - { - throw InvalidContext($"ResolveContext.symbols.{name} must be a string"); - } - } - } - - private Dictionary? ResolveSymbols(List nodes, ResolveContext ctx, string platform, List warnings) - { - var contract = _store.Contract; - var values = new Dictionary(StringComparer.Ordinal); - var missing = new Dictionary>(StringComparer.Ordinal); - var missingOrder = new List(); - foreach (var node in nodes) - { - foreach (var name in Catalog.PolicySymbols(node.Variant)) - { - if (values.ContainsKey(name)) - { - continue; - } - - var definition = contract.Symbols[name]; - string? value; - if (definition.Source == "context") - { - value = ctx.ProjectRoot; - } - else - { - value = ctx.Symbols is not null && ctx.Symbols.TryGetValue(name, out var supplied) ? supplied : null; - // Host-derived symbols describe the current host only. - if (value is null && definition.Source == "host" && platform == _host.Platform()) - { - value = _host.Symbol(name); - } - } - - if (value is null) - { - if (!missing.TryGetValue(name, out var ids)) - { - ids = new List(); - missing[name] = ids; - missingOrder.Add(name); - } - - ids.Add(node.Entry.EntryId); - continue; - } - - if (value.Contains("${", StringComparison.Ordinal) || !Paths.IsAbsolutePath(value, platform)) - { - throw InvalidContext($"symbol '{name}' must resolve to an absolute {platform} path"); - } - - values[name] = value; - } - } - - if (missing.Count > 0) - { - foreach (var name in missingOrder.OrderBy(n => n, StringComparer.Ordinal)) - { - var hint = contract.Symbols[name].Source == "context" ? "ResolveContext.projectRoot" : $"ResolveContext.symbols.{name}"; - warnings.Add($"required symbol '{name}' (needed by {string.Join(", ", missing[name])}) is unresolved; supply {hint}; no policy was returned"); - } - - return null; - } - - return values; - } - - private static List DependencyRecords(List nodes, Dictionary byId) - { - var records = new List(); - foreach (var node in nodes) - { - foreach (var dependency in node.Variant.DependencyList) - { - var target = byId[dependency.EntryId]; - var record = new ResolvedDependency(target.EntryId, target.EntryRevision, dependency.VersionRange); - if (!records.Contains(record)) - { - records.Add(record); - } - } - } - - records.Sort((a, b) => - { - var byEntry = string.CompareOrdinal(a.EntryId, b.EntryId); - if (byEntry != 0) - { - return Math.Sign(byEntry); - } - - if (a.EntryRevision != b.EntryRevision) - { - return a.EntryRevision < b.EntryRevision ? -1 : 1; - } - - if (a.RequiredVersionRange is null) - { - return b.RequiredVersionRange is null ? 0 : -1; - } - - return b.RequiredVersionRange is null ? 1 : Math.Sign(string.CompareOrdinal(a.RequiredVersionRange, b.RequiredVersionRange)); - }); - return records; - } - - private static JsonObject ComposePolicy(List nodes, Dictionary symbols, string platform) - { - var classes = new Dictionary>(StringComparer.Ordinal); - foreach (var field in Catalog.ComposableFields) - { - var seen = new HashSet(StringComparer.Ordinal); - var output = new List(); - foreach (var node in nodes) - { - foreach (var template in node.Variant.Paths(field)) - { - var substituted = Catalog.SymbolPattern.Replace(template, (Match match) => symbols[match.Groups[1].Value]); - var resolved = Paths.NormalizePath(substituted, platform); - var key = Paths.JoinKey(Paths.PathKeySegments(resolved, platform)); - if (seen.Add(key)) - { - output.Add(resolved); - } - } - } - - if (output.Count > 0) - { - classes[field] = output; - } - } - - var overlap = Catalog.FindCrossClassOverlap(classes, platform); - if (overlap is not null) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.CompositionConflict, $"resolved paths overlap across access classes: {overlap}"); - } - - var root = nodes[0].Variant.Policy; - var result = new JsonObject(); - result.Set("version", root.Get("version")!); - if (nodes.Any(node => node.Variant.Policy.Has("filesystem"))) - { - var filesystem = new JsonObject(); - foreach (var field in Catalog.ComposableFields) - { - if (classes.TryGetValue(field, out var list)) - { - filesystem.Set(field, new JsonArray(list.Select(path => (JsonValue)new JsonString(path)))); - } - } - - result.Set("filesystem", filesystem); - } - - // compositionViolation guarantees these exist only when exactly one entry is selected. - foreach (var key in new[] { "network", "ui", "timeoutMs" }) - { - if (root.Get(key) is { } value) - { - result.Set(key, value); - } - } - - return result; - } -} - -/// Convenience functions over the catalog embedded in this package. -public static class BundledPolicyCatalog -{ - private static readonly Lazy Default = new(() => new PolicyCatalog(CatalogStore.Bundled()), LazyThreadSafetyMode.ExecutionAndPublication); - - /// The bundled catalog with the system host environment. - public static PolicyCatalog Catalog => Default.Value; - - /// Composed candidate policy for several tools, from the bundled catalog. - /// Tool inputs. - /// Lookup context. - /// The policy or null. - public static CatalogSandboxPolicy? ResolveSandboxPolicy(IReadOnlyList tools, ResolveContext? context = null) => Catalog.ResolveSandboxPolicy(tools, context); - - /// Composed candidate policy for one tool, from the bundled catalog. - /// The tool input. - /// Lookup context. - /// The policy or null. - public static CatalogSandboxPolicy? ResolveSandboxPolicy(ToolInput tool, ResolveContext? context = null) => Catalog.ResolveSandboxPolicy(tool, context); - - /// Composed candidate policy plus attribution for several tools. - /// Tool inputs. - /// Lookup context. - /// The resolution. - public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(IReadOnlyList tools, ResolveContext? context = null) => Catalog.ResolveSandboxPolicyWithDiagnostics(tools, context); - - /// Composed candidate policy plus attribution for one tool. - /// The tool input. - /// Lookup context. - /// The resolution. - public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics(ToolInput tool, ResolveContext? context = null) => Catalog.ResolveSandboxPolicyWithDiagnostics(tool, context); - - /// Bundled catalog entry metadata. - /// Entry metadata ordered by entry ID. - public static IReadOnlyList ListCatalogEntries() => Catalog.ListCatalogEntries(); - - /// The bundled catalog schema version and revision. - /// The catalog info. - public static CatalogInfo GetCatalogInfo() => Catalog.GetCatalogInfo(); -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs deleted file mode 100644 index 5d38b1685..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogException.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.Mxc.PolicyCatalog; - -/// -/// MXC error codes reused by the policy catalog (MXC's closed MxcError code set). -/// The library adds no code of its own. carries the -/// snake_case wire form. -/// -public enum PolicyCatalogErrorCode -{ - /// policy_validation: catalog data or the selected composition violates the catalog contract. - PolicyValidation, - - /// malformed_request: the caller's or is structurally invalid. - MalformedRequest, - - /// unsupported_containment: the host platform or native architecture has no catalog selector, or cannot be detected. - UnsupportedContainment, - - /// backend_error: installed catalog data is unavailable, unreadable, or fails integrity. - BackendError, -} - -/// -/// Stable, language-neutral sub-reason carried in details.reason. Every reason maps to exactly one -/// . carries the snake_case wire form. -/// -public enum PolicyCatalogErrorReason -{ - /// invalid_catalog (policy_validation): catalog, manifest, or contract data is invalid. - InvalidCatalog, - - /// composition_conflict (policy_validation): selected entries or resolved paths cannot be composed. - CompositionConflict, - - /// invalid_context (malformed_request): invalid resolve context or tool input. - InvalidContext, - - /// unsupported_host (unsupported_containment): host platform/architecture has no selector or cannot be detected. - UnsupportedHost, - - /// integrity (backend_error): catalog data does not match its published digest, or cannot be read. - Integrity, - - /// revision_unavailable (backend_error): an explicitly requested catalog revision is not installed. - RevisionUnavailable, -} - -/// A policy catalog library failure. A failure is never a "no match" result. -public sealed class PolicyCatalogException : Exception -{ - /// Creates a failure for ; becomes [code] message. - /// The stable sub-reason; it determines the code. - /// The message text without the code prefix. - public PolicyCatalogException(PolicyCatalogErrorReason reason, string message) - : base($"[{ToWire(CodeFor(reason))}] {message}") - { - ErrorReason = reason; - ErrorCode = CodeFor(reason); - } - - /// The MXC error code. - public PolicyCatalogErrorCode ErrorCode { get; } - - /// The stable sub-reason. - public PolicyCatalogErrorReason ErrorReason { get; } - - /// The MXC error code in its wire form, e.g. backend_error. - public string Code => ToWire(ErrorCode); - - /// The stable sub-reason in its wire form (details.reason), e.g. integrity. - public string Reason => ToWire(ErrorReason); - - /// The one code each reason maps to (TypeScript ERROR_CODE_FOR_REASON). - /// A sub-reason. - /// The code for . - public static PolicyCatalogErrorCode CodeFor(PolicyCatalogErrorReason reason) => reason switch - { - PolicyCatalogErrorReason.InvalidCatalog => PolicyCatalogErrorCode.PolicyValidation, - PolicyCatalogErrorReason.CompositionConflict => PolicyCatalogErrorCode.PolicyValidation, - PolicyCatalogErrorReason.InvalidContext => PolicyCatalogErrorCode.MalformedRequest, - PolicyCatalogErrorReason.UnsupportedHost => PolicyCatalogErrorCode.UnsupportedContainment, - PolicyCatalogErrorReason.Integrity => PolicyCatalogErrorCode.BackendError, - PolicyCatalogErrorReason.RevisionUnavailable => PolicyCatalogErrorCode.BackendError, - _ => throw new ArgumentOutOfRangeException(nameof(reason)), - }; - - /// The snake_case wire form of a code. - /// A code. - /// For example policy_validation. - public static string ToWire(PolicyCatalogErrorCode code) => code switch - { - PolicyCatalogErrorCode.PolicyValidation => "policy_validation", - PolicyCatalogErrorCode.MalformedRequest => "malformed_request", - PolicyCatalogErrorCode.UnsupportedContainment => "unsupported_containment", - PolicyCatalogErrorCode.BackendError => "backend_error", - _ => throw new ArgumentOutOfRangeException(nameof(code)), - }; - - /// The snake_case wire form of a reason. - /// A reason. - /// For example invalid_catalog. - public static string ToWire(PolicyCatalogErrorReason reason) => reason switch - { - PolicyCatalogErrorReason.InvalidCatalog => "invalid_catalog", - PolicyCatalogErrorReason.CompositionConflict => "composition_conflict", - PolicyCatalogErrorReason.InvalidContext => "invalid_context", - PolicyCatalogErrorReason.UnsupportedHost => "unsupported_host", - PolicyCatalogErrorReason.Integrity => "integrity", - PolicyCatalogErrorReason.RevisionUnavailable => "revision_unavailable", - _ => throw new ArgumentOutOfRangeException(nameof(reason)), - }; -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs deleted file mode 100644 index 51ccb7575..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/PolicyCatalogJson.cs +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Mxc.PolicyCatalog.Internal; - -namespace Microsoft.Mxc.PolicyCatalog.Internal -{ - /// Converts validated raw policy JSON into the typed policy records. - internal static class PolicyConversion - { - private static string? Str(JsonObject obj, string key) => obj.Get(key) is JsonString s ? s.Value : null; - - private static double? Num(JsonObject obj, string key) => obj.Get(key) is JsonNumber n ? n.Value : null; - - private static bool? Bool(JsonObject obj, string key) => obj.Get(key) is JsonBool b ? b.Value : null; - - private static List? Strings(JsonObject obj, string key) => - obj.Get(key) is JsonArray a ? a.Items.Select(item => ((JsonString)item).Value).ToList() : null; - - public static CatalogSandboxPolicy ToPolicy(JsonObject raw) - { - var policy = new CatalogSandboxPolicy(Str(raw, "version")!) { TimeoutMs = Num(raw, "timeoutMs") }; - if (raw.Get("filesystem") is JsonObject fs) - { - policy = policy with - { - Filesystem = new CatalogFilesystemPolicy - { - DeniedPaths = Strings(fs, "deniedPaths"), - ReadonlyPaths = Strings(fs, "readonlyPaths"), - ReadwritePaths = Strings(fs, "readwritePaths"), - }, - }; - } - - if (raw.Get("network") is JsonObject network) - { - CatalogEgressPolicy? egress = null; - if (network.Get("egress") is JsonObject e) - { - egress = new CatalogEgressPolicy { Default = Str(e, "default"), Allow = Rules(e.Get("allow")), Deny = Rules(e.Get("deny")) }; - } - - CatalogIngressPolicy? ingress = null; - if (network.Get("ingress") is JsonObject i) - { - ingress = new CatalogIngressPolicy { Default = Str(i, "default"), HostLoopback = Str(i, "hostLoopback") }; - } - - policy = policy with { Network = new CatalogNetworkPolicy { Egress = egress, Ingress = ingress } }; - } - - if (raw.Get("ui") is JsonObject ui) - { - policy = policy with - { - Ui = new CatalogUiPolicy { AllowWindows = Bool(ui, "allowWindows"), Clipboard = Str(ui, "clipboard"), AllowInputInjection = Bool(ui, "allowInputInjection") }, - }; - } - - return policy; - } - - private static List? Rules(JsonValue? value) - { - if (value is not JsonArray rules) - { - return null; - } - - return rules.Items.Cast().Select(rule => new CatalogNetworkRule - { - To = rule.Get("to") is JsonArray to - ? to.Items.Cast().Select(peer => new CatalogNetworkPeer(Str(peer, "cidr")!) { Except = Strings(peer, "except") }).ToList() - : null, - Ports = rule.Get("ports") is JsonArray ports - ? ports.Items.Cast().Select(port => new CatalogNetworkPort { Protocol = Str(port, "protocol"), Port = Num(port, "port"), EndPort = Num(port, "endPort") }).ToList() - : null, - }).ToList(); - } - } -} - -namespace Microsoft.Mxc.PolicyCatalog -{ - /// - /// Serializes results to the exact JSON shape the TypeScript reference emits (JSON.stringify): - /// absent optional fields are omitted, never null; numbers use ECMAScript formatting. - /// - public static class PolicyCatalogJson - { - /// Serializes a policy; null becomes null. - /// The policy or null. - /// Indentation (0 = compact, 2 = the CLI form). - /// JSON text without a trailing newline. - public static string Serialize(CatalogSandboxPolicy? policy, int indent = 0) => JsonText.Stringify(ToJson(policy), indent); - - /// Serializes a resolution; policy is omitted when absent. - /// The resolution. - /// Indentation. - /// JSON text. - public static string Serialize(SandboxConfigResolution resolution, int indent = 0) => JsonText.Stringify(ToJson(resolution), indent); - - /// Serializes catalog info. - /// The info. - /// Indentation. - /// JSON text. - public static string Serialize(CatalogInfo info, int indent = 0) => JsonText.Stringify(ToJson(info), indent); - - /// Serializes entry metadata as a JSON array. - /// The entries. - /// Indentation. - /// JSON text. - public static string Serialize(IReadOnlyList entries, int indent = 0) => JsonText.Stringify(ToJson(entries), indent); - - /// Serializes the CLI's inspect output, {"info": ..., "entries": [...]}. - /// The catalog info. - /// The entries. - /// Indentation. - /// JSON text. - public static string SerializeInspect(CatalogInfo info, IReadOnlyList entries, int indent = 0) => - JsonText.Stringify(new JsonObject().With("info", ToJson(info)).With("entries", ToJson(entries)), indent); - - /// Serializes a validation report. - /// The report. - /// Indentation. - /// JSON text. - public static string Serialize(CatalogValidationReport report, int indent = 0) => JsonText.Stringify(ToJson(report), indent); - - /// Serializes a failure as the CLI's {"error": {"code", "message", "details": {"reason"}}}. - /// The failure. - /// Indentation. - /// JSON text. - public static string Serialize(PolicyCatalogException error, int indent = 0) - { - ArgumentNullException.ThrowIfNull(error); - var body = new JsonObject() - .With("code", new JsonString(error.Code)) - .With("message", new JsonString(error.Message)) - .With("details", new JsonObject().With("reason", new JsonString(error.Reason))); - return JsonText.Stringify(new JsonObject().With("error", body), indent); - } - - /// The canonical JSON form (sorted keys, no whitespace) of JSON text, as used for revision digests. - /// JSON text. - /// Canonical JSON text. - public static string Canonicalize(string json) => CanonicalJson.Serialize(JsonText.Parse(json)); - - /// Lower-case hex SHA-256 over the canonical JSON form of JSON text. - /// JSON text. - /// The digest. - public static string CanonicalSha256(string json) => CanonicalJson.Sha256(JsonText.Parse(json)); - - private static JsonValue S(string? value) => value is null ? null! : new JsonString(value); - - private static JsonValue? OptS(string? value) => value is null ? null : new JsonString(value); - - private static JsonValue? OptN(double? value) => value is null ? null : new JsonNumber(value.Value); - - private static JsonValue? OptB(bool? value) => value is null ? null : JsonBool.Of(value.Value); - - private static JsonValue? OptList(IReadOnlyList? values) => values is null ? null : Strings(values); - - private static JsonArray Strings(IEnumerable values) => new(values.Select(value => (JsonValue)new JsonString(value))); - - internal static JsonValue ToJson(CatalogSandboxPolicy? policy) - { - if (policy is null) - { - return JsonNull.Instance; - } - - var obj = new JsonObject().With("version", S(policy.Version)); - if (policy.Filesystem is { } fs) - { - obj.Set("filesystem", new JsonObject() - .With("deniedPaths", OptList(fs.DeniedPaths)) - .With("readonlyPaths", OptList(fs.ReadonlyPaths)) - .With("readwritePaths", OptList(fs.ReadwritePaths))); - } - - if (policy.Network is { } network) - { - var n = new JsonObject(); - if (network.Egress is { } egress) - { - n.Set("egress", new JsonObject() - .With("default", OptS(egress.Default)) - .With("allow", Rules(egress.Allow)) - .With("deny", Rules(egress.Deny))); - } - - if (network.Ingress is { } ingress) - { - n.Set("ingress", new JsonObject() - .With("default", OptS(ingress.Default)) - .With("hostLoopback", OptS(ingress.HostLoopback))); - } - - obj.Set("network", n); - } - - if (policy.Ui is { } ui) - { - obj.Set("ui", new JsonObject() - .With("allowWindows", OptB(ui.AllowWindows)) - .With("clipboard", OptS(ui.Clipboard)) - .With("allowInputInjection", OptB(ui.AllowInputInjection))); - } - - return obj.With("timeoutMs", OptN(policy.TimeoutMs)); - } - - private static JsonValue? Rules(IReadOnlyList? rules) - { - if (rules is null) - { - return null; - } - - return new JsonArray(rules.Select(rule => (JsonValue)new JsonObject() - .With("to", rule.To is null ? null : new JsonArray(rule.To.Select(peer => (JsonValue)new JsonObject() - .With("cidr", S(peer.Cidr)) - .With("except", OptList(peer.Except))))) - .With("ports", rule.Ports is null ? null : new JsonArray(rule.Ports.Select(port => (JsonValue)new JsonObject() - .With("protocol", OptS(port.Protocol)) - .With("port", OptN(port.Port)) - .With("endPort", OptN(port.EndPort))))))); - } - - internal static JsonValue ToJson(SandboxConfigResolution resolution) - { - ArgumentNullException.ThrowIfNull(resolution); - var d = resolution.Diagnostics; - var diagnostics = new JsonObject() - .With("catalogRevision", S(d.CatalogRevision)) - .With("tools", new JsonArray(d.Tools.Select(tool => (JsonValue)new JsonObject() - .With("inputIndex", new JsonNumber(tool.InputIndex)) - .With("matches", new JsonArray(tool.Matches.Select(match => (JsonValue)new JsonObject() - .With("entryId", S(match.EntryId)) - .With("entryRevision", new JsonNumber(match.EntryRevision)) - .With("matchedIdentities", new JsonArray(match.MatchedIdentities.Select(identity => (JsonValue)new JsonObject() - .With("kind", S(identity.Kind)) - .With("strength", S(identity.Strength))))))))))) - .With("resolvedDependencies", new JsonArray(d.ResolvedDependencies.Select(dependency => (JsonValue)new JsonObject() - .With("entryId", S(dependency.EntryId)) - .With("entryRevision", new JsonNumber(dependency.EntryRevision)) - .With("requiredVersionRange", OptS(dependency.RequiredVersionRange))))) - .With("warnings", Strings(d.Warnings)); - return new JsonObject() - .With("policy", resolution.Policy is null ? null : ToJson(resolution.Policy)) - .With("diagnostics", diagnostics); - } - - internal static JsonValue ToJson(CatalogInfo info) => new JsonObject() - .With("catalogSchemaVersion", S(info.CatalogSchemaVersion)) - .With("catalogRevision", S(info.CatalogRevision)); - - internal static JsonValue ToJson(IReadOnlyList entries) => new JsonArray(entries.Select(entry => (JsonValue)new JsonObject() - .With("catalogRevision", S(entry.CatalogRevision)) - .With("entryId", S(entry.EntryId)) - .With("entryRevision", new JsonNumber(entry.EntryRevision)) - .With("displayName", S(entry.DisplayName)) - .With("identity", new JsonArray(entry.Identity.Select(identity => (JsonValue)(identity.Kind == "purl" - ? new JsonObject().With("kind", S(identity.Kind)).With("value", OptS(identity.Value)).With("versionRange", OptS(identity.VersionRange)) - : new JsonObject().With("kind", S(identity.Kind)).With("names", OptList(identity.Names)))))) - .With("platformVariants", new JsonArray(entry.PlatformVariants.Select(variant => (JsonValue)new JsonObject() - .With("platform", S(variant.Platform)) - .With("architecture", OptS(variant.Architecture)) - .With("dependencyEntryIds", Strings(variant.DependencyEntryIds)) - .With("sandboxPolicyVersion", S(variant.SandboxPolicyVersion))))) - .With("provenance", new JsonObject() - .With("method", S(entry.Provenance.Method)) - .With("sourceRevision", S(entry.Provenance.SourceRevision))))); - - internal static JsonValue ToJson(CatalogValidationReport report) => new JsonObject() - .With("ok", JsonBool.Of(report.Ok)) - .With("catalogDir", S(report.CatalogDir)) - .With("defaultRevision", OptS(report.DefaultRevision)) - .With("revisions", OptList(report.Revisions)) - .With("baseRef", report.BaseRef is null ? null : new JsonObject() - .With("ref", S(report.BaseRef.Ref)) - .With("comparedRevisions", new JsonNumber(report.BaseRef.ComparedRevisions))) - .With("errors", Strings(report.Errors)); - } -} diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs deleted file mode 100644 index 60ab674fd..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/Results.cs +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.Mxc.PolicyCatalog; - -// Numbers are JSON numbers with JavaScript semantics (IEEE doubles) so every -// value round-trips exactly as the TypeScript reference emits it. The catalog -// contract only admits integers where these appear. - -/// -/// The catalog-supported subset of MXC's SandboxPolicy authoring contract. Structurally compatible -/// with the MXC SDK policy; backend-specific keys are never part of it. A candidate lower bound, not authorization. -/// -/// The SandboxPolicy contract version. -public sealed record CatalogSandboxPolicy(string Version) -{ - /// Filesystem requirements, or null when absent. - public CatalogFilesystemPolicy? Filesystem { get; init; } - - /// Network requirements, or null when absent. - public CatalogNetworkPolicy? Network { get; init; } - - /// UI requirements, or null when absent. - public CatalogUiPolicy? Ui { get; init; } - - /// Timeout in milliseconds, or null when absent. - public double? TimeoutMs { get; init; } -} - -/// Filesystem access classes. A null list is absent from the JSON form. -public sealed record CatalogFilesystemPolicy -{ - /// Paths the tool must not access. - public IReadOnlyList? DeniedPaths { get; init; } - - /// Paths the tool reads. - public IReadOnlyList? ReadonlyPaths { get; init; } - - /// Paths the tool reads and writes. - public IReadOnlyList? ReadwritePaths { get; init; } -} - -/// Network requirements. -public sealed record CatalogNetworkPolicy -{ - /// Egress rules. - public CatalogEgressPolicy? Egress { get; init; } - - /// Ingress rules. - public CatalogIngressPolicy? Ingress { get; init; } -} - -/// Egress rules. -public sealed record CatalogEgressPolicy -{ - /// deny (the only default the catalog admits) or null. - public string? Default { get; init; } - - /// Allowed peers. - public IReadOnlyList? Allow { get; init; } - - /// Denied peers. - public IReadOnlyList? Deny { get; init; } -} - -/// Ingress rules. -public sealed record CatalogIngressPolicy -{ - /// deny or null. - public string? Default { get; init; } - - /// allow, deny, or null. - public string? HostLoopback { get; init; } -} - -/// One network rule. -public sealed record CatalogNetworkRule -{ - /// Peers. - public IReadOnlyList? To { get; init; } - - /// Ports. - public IReadOnlyList? Ports { get; init; } -} - -/// A network peer. -/// The peer CIDR. -public sealed record CatalogNetworkPeer(string Cidr) -{ - /// Excluded CIDRs. - public IReadOnlyList? Except { get; init; } -} - -/// A port or port range. -public sealed record CatalogNetworkPort -{ - /// tcp, udp, icmp, any, or null. - public string? Protocol { get; init; } - - /// First port. - public double? Port { get; init; } - - /// Last port of a range. - public double? EndPort { get; init; } -} - -/// UI requirements. -public sealed record CatalogUiPolicy -{ - /// Whether windows are allowed. - public bool? AllowWindows { get; init; } - - /// none, read, write, all, or null. - public string? Clipboard { get; init; } - - /// Whether input injection is allowed. - public bool? AllowInputInjection { get; init; } -} - -/// Strength of a matched identity predicate kind. -/// purl or invocation-name. -/// strong or weak. -public sealed record MatchedIdentity(string Kind, string Strength); - -/// One entry matched by one input. -/// The entry ID. -/// The entry revision. -/// Satisfied predicates, in declaration order. -public sealed record ToolMatch(string EntryId, double EntryRevision, IReadOnlyList MatchedIdentities); - -/// Per-input attribution, in input order. -/// Zero-based input index. -/// Matches ordered by entry ID. -public sealed record ToolDiagnostics(int InputIndex, IReadOnlyList Matches); - -/// A dependency edge among the selected entries. -/// The dependency's entry ID. -/// The dependency's entry revision. -/// The declared (unevaluated) version range, or null. -public sealed record ResolvedDependency(string EntryId, double EntryRevision, string? RequiredVersionRange); - -/// Attribution and warnings for one lookup (design §5.1). -/// The revision used. -/// Per-input records, in input order. -/// Distinct dependency edges, sorted. -/// Plain-text warnings. -public sealed record ResolutionDiagnostics( - string CatalogRevision, - IReadOnlyList Tools, - IReadOnlyList ResolvedDependencies, - IReadOnlyList Warnings); - -/// Result of . -/// The composed candidate policy, or null when none can be resolved. -/// Attribution and warnings. -public sealed record SandboxConfigResolution(CatalogSandboxPolicy? Policy, ResolutionDiagnostics Diagnostics); - -/// Result of . -/// The catalog schema version. -/// The installed default revision. -public sealed record CatalogInfo(string CatalogSchemaVersion, string CatalogRevision); - -/// Identity metadata: either a package URL () or invocation names (). -/// purl or invocation-name. -public sealed record CatalogIdentityMetadata(string Kind) -{ - /// The package URL for purl. - public string? Value { get; init; } - - /// The reviewed version range for purl, when declared. - public string? VersionRange { get; init; } - - /// The names for invocation-name. - public IReadOnlyList? Names { get; init; } -} - -/// Platform variant metadata. -/// The platform selector. -/// The architecture selector, or null for the architecture-neutral variant. -/// Declared dependencies. -/// The embedded policy's contract version. -public sealed record CatalogVariantMetadata(string Platform, string? Architecture, IReadOnlyList DependencyEntryIds, string SandboxPolicyVersion); - -/// Entry provenance. -/// How the entry was produced. -/// Where it came from. -public sealed record CatalogProvenance(string Method, string SourceRevision); - -/// Inspection metadata (design §5.2). It never exposes a policy body. -/// The revision. -/// The entry ID. -/// The entry revision. -/// The display name. -/// Identity predicates, in declaration order. -/// Variants, in declaration order. -/// Provenance. -public sealed record CatalogEntryMetadata( - string CatalogRevision, - string EntryId, - double EntryRevision, - string DisplayName, - IReadOnlyList Identity, - IReadOnlyList PlatformVariants, - CatalogProvenance Provenance); - -/// Result of catalog-directory validation (policy-catalog validate). -/// True only when every requested check ran and passed. -/// The absolute catalog directory. -/// The default revision, when the manifest loaded. -/// Listed revisions, when the manifest loaded. -/// The base-ref comparison, when requested. -/// Error strings. -public sealed record CatalogValidationReport( - bool Ok, - string CatalogDir, - string? DefaultRevision, - IReadOnlyList? Revisions, - BaseRefReport? BaseRef, - IReadOnlyList Errors); - -/// The base-ref part of a validation report. -/// The requested ref. -/// Published revisions compared; 0 when the base has no catalog. -public sealed record BaseRefReport(string Ref, int ComparedRevisions); diff --git a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs b/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs deleted file mode 100644 index 9c12111b8..000000000 --- a/policy-catalog/dotnet/Microsoft.Mxc.PolicyCatalog/SystemHostEnvironment.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; - -namespace Microsoft.Mxc.PolicyCatalog; - -/// -/// The default host environment (TypeScript nodeHostEnvironment). The native architecture is detected -/// once per process, on first need, and never taken from the process or compile-time architecture. -/// -public sealed class SystemHostEnvironment : IHostEnvironment -{ - private static readonly Lazy NativeArchitectureCache = new(DetectNativeArchitecture, LazyThreadSafetyMode.ExecutionAndPublication); - - private SystemHostEnvironment() - { - } - - /// The shared instance. - public static SystemHostEnvironment Instance { get; } = new(); - - /// - public string Platform() - { - if (OperatingSystem.IsWindows()) - { - return CatalogPlatforms.Windows; - } - - if (OperatingSystem.IsMacOS()) - { - return CatalogPlatforms.MacOS; - } - - if (OperatingSystem.IsLinux()) - { - return CatalogPlatforms.Linux; - } - - throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, $"host platform '{NodePlatformName()}' has no catalog selector"); - } - - /// - public string NativeArchitecture() - { - // A failed detection is not cached as a value; Lazy caches the exception, matching a - // deterministic host fact for the process lifetime. - return NativeArchitectureCache.Value; - } - - /// - public string? Symbol(string name) - { - switch (name) - { - case "user_home": - return NonEmpty(HomeDirectory()); - case "temp_dir": - return NonEmpty(TempDirectory()); - default: - return null; - } - } - - private static string? NonEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; - - // Node os.homedir(): USERPROFILE on Windows, HOME on POSIX, then the account database. - private static string? HomeDirectory() - { - var fromEnvironment = Environment.GetEnvironmentVariable(OperatingSystem.IsWindows() ? "USERPROFILE" : "HOME"); - return string.IsNullOrEmpty(fromEnvironment) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : fromEnvironment; - } - - // Node os.tmpdir(): Windows TEMP/TMP/%SystemRoot%\temp with a trailing '\' removed unless after ':'; - // POSIX TMPDIR/TMP/TEMP or /tmp with a trailing '/' removed. - private static string TempDirectory() - { - if (OperatingSystem.IsWindows()) - { - var path = FirstEnv("TEMP", "TMP") ?? ((Environment.GetEnvironmentVariable("SystemRoot") ?? Environment.GetEnvironmentVariable("windir")) + "\\temp"); - if (path.Length > 1 && path[path.Length - 1] == '\\' && path[path.Length - 2] != ':') - { - path = path.Substring(0, path.Length - 1); - } - - return path; - } - - var posix = FirstEnv("TMPDIR", "TMP", "TEMP") ?? "/tmp"; - if (posix.Length > 1 && posix[posix.Length - 1] == '/') - { - posix = posix.Substring(0, posix.Length - 1); - } - - return posix; - } - - private static string? FirstEnv(params string[] names) - { - foreach (var name in names) - { - var value = Environment.GetEnvironmentVariable(name); - if (!string.IsNullOrEmpty(value)) - { - return value; - } - } - - return null; - } - - private static string NodePlatformName() - { - if (OperatingSystem.IsFreeBSD()) - { - return "freebsd"; - } - - return RuntimeInformation.OSDescription; - } - - /// Maps an OS-reported machine/architecture string to a catalog selector (TypeScript architectureFromMachine). - /// For example AMD64, x86_64, ARM64, aarch64. - /// x64, arm64, or null when unknown. - public static string? ArchitectureFromMachine(string machine) - { - switch (Internal.JsString.ToLower(Internal.JsString.Trim(machine))) - { - case "x86_64": - case "amd64": - case "x64": - return CatalogArchitectures.X64; - case "arm64": - case "aarch64": - return CatalogArchitectures.Arm64; - default: - return null; - } - } - - /// - /// Windows: the machine-wide PROCESSOR_ARCHITECTURE under - /// HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment (registry API; an emulated - /// process still reads the native value). macOS: /usr/sbin/sysctl -n hw.optional.arm64 (absent key - /// = Intel), then /usr/bin/uname -m. Linux: /usr/bin/uname -m (or /bin/uname), the - /// kernel machine type. Executables are invoked by absolute path. - /// - private static string DetectNativeArchitecture() - { - string? reported; - try - { - if (OperatingSystem.IsWindows()) - { - reported = ReadWindowsRegistryArchitecture(); - } - else if (OperatingSystem.IsMacOS()) - { - var appleSilicon = false; - try - { - appleSilicon = Run("/usr/sbin/sysctl", "-n", "hw.optional.arm64").Trim() == "1"; - } - catch (Exception) - { - // The key is absent on Intel Macs. - } - - reported = appleSilicon ? "arm64" : Run("/usr/bin/uname", "-m").Trim(); - } - else - { - var uname = File.Exists("/usr/bin/uname") ? "/usr/bin/uname" : "/bin/uname"; - reported = Run(uname, "-m").Trim(); - } - } - catch (Exception error) - { - throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, $"native system architecture could not be determined: {error.Message}"); - } - - var architecture = reported is null ? null : ArchitectureFromMachine(reported); - return architecture - ?? throw new PolicyCatalogException(PolicyCatalogErrorReason.UnsupportedHost, $"native system architecture '{reported ?? "unknown"}' has no catalog selector"); - } - - [System.Runtime.Versioning.SupportedOSPlatform("windows")] - private static string? ReadWindowsRegistryArchitecture() - { - // RegistryView.Default from a 64-bit process, and Registry64 from a 32-bit one, both see the - // machine-wide value; this key is not redirected by WOW64 anyway. - using var hive = Microsoft.Win32.RegistryKey.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryView.Registry64); - using var key = hive.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Environment") - ?? throw new InvalidOperationException(@"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment could not be opened"); - var value = key.GetValue("PROCESSOR_ARCHITECTURE", null, Microsoft.Win32.RegistryValueOptions.DoNotExpandEnvironmentNames) as string; - // reg.exe output is matched with \S+ in the TypeScript reference; keep the first token. - return value is null ? null : Regex.Match(value, "^\\S*").Value; - } - - private static string Run(string file, params string[] args) - { - var start = new ProcessStartInfo(file) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - foreach (var arg in args) - { - start.ArgumentList.Add(arg); - } - - using var process = Process.Start(start) ?? throw new InvalidOperationException($"{file} could not be started"); - process.StandardInput.Close(); - var stderr = process.StandardError.ReadToEndAsync(); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - _ = stderr.Result; - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"Command failed: {file} {string.Join(" ", args)}"); - } - - return output; - } -} diff --git a/policy-catalog/dotnet/README.md b/policy-catalog/dotnet/README.md deleted file mode 100644 index 65d956da5..000000000 --- a/policy-catalog/dotnet/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Microsoft.Mxc.PolicyCatalog (.NET) — PROTOTYPE - -Native C# implementation of the policy catalog library and the `policy-catalog` -CLI, behavior-equivalent to the TypeScript reference in `../src`. It targets -`net8.0` and depends only on inbox libraries (`System.Text.Json`, -`Microsoft.Win32.Registry` on Windows). The bundled catalog is embedded from -`../catalog/` (the single source of truth; nothing is copied into this folder). - -`global.json` pins SDK `10.0.401` (`rollForward: latestFeature`) with the -Microsoft.Testing.Platform test runner. SDK 8.0.x cannot run MTP-mode -`dotnet test` or read `.slnx` solutions. The projects still target `net8.0`. - -Run everything from this directory (`dotnet/`): - -```sh -# Build (warnings are errors) -dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release - -# Unit, conformance (../conformance/fixtures), and vector (../conformance/vectors) tests -dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release - -# Pack the library -dotnet pack Microsoft.Mxc.PolicyCatalog/Microsoft.Mxc.PolicyCatalog.csproj -c Release -o - -# CLI (from source) -dotnet Microsoft.Mxc.PolicyCatalog.Cli/bin/Release/net8.0/policy-catalog.dll inspect -``` - -## Functional tests (packaged artifact) - -```sh -# from policy-catalog/ -node scripts/dotnet-functional.mjs -``` - -The driver packs the library into a local feed under `os.tmpdir()`, then -builds two consumers outside the repository against that package: - -- the CLI (`functional/policy-catalog-cli-consumer` + `Microsoft.Mxc.PolicyCatalog.Cli/Program.cs`) -- the functional tests (`functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests`) - -Each consumer gets a generated `nuget.config` and an isolated `NUGET_PACKAGES` -directory. The tests call the packaged library directly and run the packaged -CLI in a separate process. The functional project is not part of the solution. - -Third-party packages (xunit.v3) come from nuget.org. Where nuget.org is not -directly reachable, set `POLICY_CATALOG_NUGET_UPSTREAM` to a mirror feed URL. -Set `POLICY_CATALOG_KEEP_TEMP=1` to keep the temporary directory. diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs deleted file mode 100644 index 90449ca47..000000000 --- a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/BaseRefTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// validate --base-ref (published-revision immutability) through the packaged CLI. -using System.Diagnostics; -using System.Text.Json.Nodes; -using Xunit; -using static Microsoft.Mxc.PolicyCatalog.FunctionalTests.Fx; - -namespace Microsoft.Mxc.PolicyCatalog.FunctionalTests; - -public sealed class BaseRefTests : WorkDirTest -{ - private static string Git(string cwd, params string[] args) - { - var start = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; - foreach (var arg in new[] { "-c", "user.name=functional-test", "-c", "user.email=functional-test@invalid", "-c", "core.autocrlf=false", "-c", "commit.gpgsign=false" }.Concat(args)) - { - start.ArgumentList.Add(arg); - } - - using var process = Process.Start(start)!; - var stderr = process.StandardError.ReadToEndAsync(); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - Assert.True(process.ExitCode == 0, $"git {string.Join(" ", args)}: {stderr.Result}"); - return output; - } - - private (string Repo, string Dir) PublishedRepo() - { - var repo = NewDir("repo-"); - Git(repo, "init", "-q"); - var dir = Path.Combine(repo, "catalog"); - WriteCatalog(dir, new[] { Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1") }); - Git(repo, "add", "-A"); - Git(repo, "commit", "-q", "-m", "publish"); - return (repo, dir); - } - - [Fact] - public void UnchangedOrAppendedPasses() - { - var (_, dir) = PublishedRepo(); - var unchanged = Cli("validate", "--catalog", dir, "--base-ref", "HEAD"); - Assert.Equal(0, unchanged.Status); - Assert.Equal("""{"ref":"HEAD","comparedRevisions":1}""", unchanged.Json!["baseRef"]!.ToJsonString(Relaxed)); - WriteCatalog(dir, new[] - { - Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1"), - Revision(new JsonNode[] { Entry("tool:a"), Entry("tool:b") }, "2000-01-02.1"), - }); - Assert.Equal(0, Cli("validate", "--catalog", dir, "--base-ref", "HEAD").Status); - } - - [Fact] - public void RewritingAPublishedRevisionFails() - { - var (_, dir) = PublishedRepo(); - WriteCatalog(dir, new[] { Revision(new JsonNode[] { Entry("tool:a", """{ "entryRevision": 2, "displayName": "changed" }""") }, "2000-01-01.1") }); - Assert.Equal(0, Cli("validate", "--catalog", dir).Status); - var result = Cli("validate", "--catalog", dir, "--base-ref", "HEAD"); - Assert.Equal(1, result.Status); - Assert.Equal( - """["[immutability] published revision '2000-01-01.1' manifest entry was modified","[immutability] published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead"]""", - result.Json!["errors"]!.ToJsonString(Relaxed)); - } - - [Fact] - public void BadRefsAndNonGitDirectoriesAreErrors() - { - var (repo, dir) = PublishedRepo(); - Assert.Contains("base-ref check: 'no-such-ref' does not name a commit", Cli("validate", "--catalog", dir, "--base-ref", "no-such-ref").Stdout, StringComparison.Ordinal); - var optionLike = Cli("validate", "--catalog", dir, "--base-ref", "-h"); - Assert.Equal(1, optionLike.Status); - Assert.Contains("base-ref check: '-h' is not a valid git ref", optionLike.Stdout, StringComparison.Ordinal); - var outside = WriteCatalog(Path.Combine(Path.GetTempPath(), "policy-catalog-dotnet-nogit-" + Guid.NewGuid().ToString("N")), new[] { Revision(new JsonNode[] { Entry("tool:a") }) }); - try - { - var noGit = Cli("validate", "--catalog", outside, "--base-ref", "HEAD"); - Assert.Equal(1, noGit.Status); - Assert.Contains("is not inside a git work tree", noGit.Stdout, StringComparison.Ordinal); - } - finally - { - Directory.Delete(outside, true); - } - - var emptyTree = Git(repo, "mktree").Trim(); - var orphan = Git(repo, "commit-tree", emptyTree, "-m", "empty").Trim(); - var empty = Cli("validate", "--catalog", dir, "--base-ref", orphan); - Assert.Equal(0, empty.Status); - Assert.Equal(0, empty.Json!["baseRef"]!["comparedRevisions"]!.GetValue()); - } -} diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs deleted file mode 100644 index e15bdc906..000000000 --- a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/FunctionalTests.cs +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Functional tests through the packaged CLI and library: unknown tools, weak -// opt-in, composition conflicts, unavailable revisions, architecture -// selection, multi-match, integrity tamper, and dependency cycles. -using System.Text.Json.Nodes; -using Xunit; -using static Microsoft.Mxc.PolicyCatalog.FunctionalTests.Fx; - -namespace Microsoft.Mxc.PolicyCatalog.FunctionalTests; - -public sealed class CliCommandTests -{ - private static string[] Split(string joined) => joined.Length == 0 ? Array.Empty() : joined.Split('|'); - - [Fact] - public void InspectReportsMetadataOnly() - { - var result = Cli("inspect"); - Assert.Equal(0, result.Status); - Assert.Equal(InstalledManifest["defaultRevision"]!.GetValue(), result.Json!["info"]!["catalogRevision"]!.GetValue()); - Assert.Equal(new[] { "tool:git", "tool:node", "tool:npm" }, result.Json["entries"]!.AsArray().Select(e => e!["entryId"]!.GetValue())); - Assert.DoesNotContain("readwritePaths", result.Stdout, StringComparison.Ordinal); - Assert.DoesNotContain("${", result.Stdout, StringComparison.Ordinal); - Assert.EndsWith("}\n", result.Stdout, StringComparison.Ordinal); - } - - [Fact] - public void ValidateBundledCatalog() - { - var result = Cli("validate"); - Assert.Equal(0, result.Status); - Assert.True(result.Json!["ok"]!.GetValue()); - Assert.Empty(result.Json["errors"]!.AsArray()); - Assert.Null(result.Json["baseRef"]); - } - - [Theory] - [InlineData("")] - [InlineData("bogus")] - [InlineData("info")] - [InlineData("resolve|--platform")] - [InlineData("resolve|--symbol|noequals|git")] - [InlineData("resolve|--symbol|=x|git")] - [InlineData("resolve|--purl|pkg:npm/npm")] - [InlineData("resolve|--base-ref|HEAD|git")] - [InlineData("resolve|--platform|--allow-weak|git")] - [InlineData("inspect|extra")] - [InlineData("validate|extra")] - [InlineData("validate|--base-ref")] - [InlineData("resolve|--catalog")] - public void UsageErrorsExit2WithoutStdout(string joined) - { - var result = Cli(Split(joined)); - Assert.Equal(2, result.Status); - Assert.Equal(string.Empty, result.Stdout); - Assert.StartsWith("policy-catalog: ", result.Stderr, StringComparison.Ordinal); - Assert.EndsWith("usage: policy-catalog [options]\n", result.Stderr.Replace("\r\n", "\n"), StringComparison.Ordinal); - } - - [Theory] - [InlineData("resolve|--platform|plan9|git")] - [InlineData("resolve|--architecture|mips|git")] - [InlineData("resolve|--platform|linux|--architecture|x64|--allow-weak|--symbol|node_prefix=relative|node")] - [InlineData("resolve|--platform|linux|--architecture|x64|--symbol|unknown_symbol=/x|git")] - [InlineData("resolve|--platform|linux|--architecture|x64|--symbol|__proto__=/x|git")] - [InlineData("resolve|--platform|linux|--architecture|x64|--purl|not-a-purl|npm")] - [InlineData("resolve|--platform|linux|--architecture|x64|./bin/git")] - public void InvalidCallerInputIsInvalidContext(string joined) - { - var result = Cli(Split(joined)); - Assert.Equal(1, result.Status); - Assert.Equal("malformed_request", result.Json!["error"]!["code"]!.GetValue()); - Assert.Equal("invalid_context", result.ErrorReason); - } - - public static TheoryData PlatformArch() - { - var data = new TheoryData(); - foreach (var p in Platforms) - { - foreach (var a in Architectures) - { - data.Add(p, a); - } - } - - return data; - } - - [Theory] - [MemberData(nameof(PlatformArch))] - public void ResolvesFullToolSetDeterministically(string platform, string architecture) - { - var c = PlatformContext[platform]; - var first = Cli(Args("resolve", "--diagnostics", FullContext(platform, architecture), "git", "npm", "node")); - var second = Cli(Args("resolve", "--diagnostics", FullContext(platform, architecture), "git", "npm", "node")); - Assert.Equal(0, first.Status); - Assert.Equal(first.Stdout, second.Stdout); - var policy = first.Json!["policy"]!; - Assert.Equal(new[] { c.Root, c.Cache }, policy["filesystem"]!["readwritePaths"]!.AsArray().Select(p => p!.GetValue())); - Assert.Equal(new[] { c.Prefix }, policy["filesystem"]!["readonlyPaths"]!.AsArray().Select(p => p!.GetValue())); - Assert.Null(policy["network"]); - Assert.Equal("""[{"entryId":"tool:node","entryRevision":1}]""", first.Json["diagnostics"]!["resolvedDependencies"]!.ToJsonString(Relaxed)); - Assert.Contains($"tool:git uses its architecture-neutral {platform} variant; no {architecture}-specific variant exists", first.Warnings, StringComparison.Ordinal); - Assert.DoesNotContain("architecture was not specified", first.Warnings, StringComparison.Ordinal); - var plain = Cli(Args("resolve", FullContext(platform, architecture), "git", "npm", "node")); - Assert.Equal(policy.ToJsonString(Relaxed), plain.Json!.ToJsonString(Relaxed)); - } - - [Fact] - public void EmptyInputAndUnresolvedSymbolYieldNoPolicy() - { - Assert.Equal("null\n", Cli("resolve", "--platform", "linux", "--architecture", "x64").Stdout); - var empty = Cli("resolve", "--diagnostics", "--platform", "linux", "--architecture", "x64"); - Assert.Equal(0, empty.Status); - Assert.Null(empty.Json!["policy"]); - Assert.DoesNotContain("\"policy\"", empty.Stdout, StringComparison.Ordinal); - var unresolved = Cli("resolve", "--diagnostics", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/usr/bin", "git"); - Assert.Contains("required symbol 'project_root' (needed by tool:git) is unresolved; supply ResolveContext.projectRoot; no policy was returned", unresolved.Warnings, StringComparison.Ordinal); - } -} - -public sealed class ResolutionTests : WorkDirTest -{ - private string MultiCatalog() => WriteCatalog(NewDir("multi-"), new[] - { - Revision(new JsonNode[] - { - Entry("tool:app", """{ "identity": [{ "kind": "purl", "value": "pkg:npm/app" }, { "kind": "invocation-name", "names": ["app"] }] }"""), - Entry("tool:app-plugin", """{ "identity": [{ "kind": "invocation-name", "names": ["app"] }] }"""), - }), - }); - - [Fact] - public void UnknownToolYieldsNoPolicyWithWarning() - { - var result = Cli(Args("resolve", "--diagnostics", FullContext("linux", "x64"), "cargo")); - Assert.Equal(0, result.Status); - Assert.Null(result.Json!["policy"]); - Assert.Equal("""[{"inputIndex":0,"matches":[]}]""", result.Json["diagnostics"]!["tools"]!.ToJsonString(Relaxed)); - Assert.Equal("input 0 ('cargo') matched no eligible catalog entry", result.Warnings); - Assert.Equal("null\n", Cli(Args("resolve", FullContext("linux", "x64"), "cargo")).Stdout); - var library = BundledPolicyCatalog.ResolveSandboxPolicyWithDiagnostics("cargo", new ResolveContext { Platform = "linux", Architecture = "x64" }); - Assert.Null(library.Policy); - Assert.Equal(new[] { "input 0 ('cargo') matched no eligible catalog entry" }, library.Diagnostics.Warnings); - } - - [Fact] - public void WeakIdentityRequiresOptIn() - { - var common = Args("--diagnostics", "--platform", "linux", "--architecture", "x64", SymbolArgs("linux")); - var off = Cli(Args("resolve", common, "git")); - Assert.Equal(0, off.Status); - Assert.Null(off.Json!["policy"]); - Assert.Equal("input 0 ('git') matched no eligible catalog entry: tool:git matched only by invocation name and allowWeakIdentityFallback is not enabled", off.Warnings); - var on = Cli(Args("resolve", common, "--allow-weak", "git")); - Assert.NotNull(on.Json!["policy"]); - Assert.Contains("input 0 ('git') matched tool:git only by invocation name (weak identity)", on.Warnings, StringComparison.Ordinal); - - var catalog = new PolicyCatalog(CatalogStore.Bundled()); - var ctx = new ResolveContext { Platform = "linux", Architecture = "x64", ProjectRoot = "/p", Symbols = new Dictionary { ["git_prefix"] = "/usr/bin" } }; - Assert.Null(catalog.ResolveSandboxPolicy("git", ctx)); - Assert.NotNull(catalog.ResolveSandboxPolicy(new ToolInput("git"), ctx with { AllowWeakIdentityFallback = true })); - - var strong = Cli(Args("resolve", common, "--purl", "pkg:npm/npm@11.0.0", "npm")); - Assert.NotNull(strong.Json!["policy"]); - Assert.DoesNotContain("weak identity", strong.Warnings, StringComparison.Ordinal); - } - - [Fact] - public void MultiMatchWarnsAndComposesEveryEntry() - { - var dir = MultiCatalog(); - var result = Cli("resolve", "--catalog", dir, "--diagnostics", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "app"); - Assert.Equal(0, result.Status); - Assert.Contains("input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute", result.Warnings, StringComparison.Ordinal); - Assert.Equal("""["/opt/app","/opt/app-plugin"]""", result.Json!["policy"]!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); - var strongOnly = Cli("resolve", "--catalog", dir, "--diagnostics", "--platform", "linux", "--architecture", "x64", "--symbol", "git_prefix=/opt", "--purl", "pkg:npm/app", "app"); - Assert.DoesNotContain("matched 2 entries", strongOnly.Warnings, StringComparison.Ordinal); - } - - [Fact] - public void CompositionConflicts() - { - var overlap = Cli("resolve", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--project-root", "/opt", "--symbol", "git_prefix=/usr/bin", "--symbol", "node_prefix=/opt/node", "git", "node"); - Assert.Equal(1, overlap.Status); - Assert.Equal("composition_conflict", overlap.ErrorReason); - Assert.Contains("overlap across access classes", overlap.ErrorMessage, StringComparison.Ordinal); - - var dir = WriteCatalog(NewDir("compose-"), new[] - { - Revision(new JsonNode[] - { - Entry("tool:a"), - Entry("tool:b", """{ "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "network": { "egress": { "default": "deny" } } } }] }"""), - }), - }); - Assert.Equal(0, Cli("validate", "--catalog", dir).Status); - var alone = Cli("resolve", "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "b"); - Assert.Equal("""{"egress":{"default":"deny"}}""", alone.Json!["network"]!.ToJsonString(Relaxed)); - var both = Cli("resolve", "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a", "b"); - Assert.Equal(1, both.Status); - Assert.Equal("[policy_validation] selected entries cannot be composed: 'tool:b' uses 'network', which has no v1 cross-entry composition rule", both.ErrorMessage); - } - - [Fact] - public void UnavailableRevisionIsNeverSubstituted() - { - var result = Cli(Args("resolve", "--revision", "2099-01-01.1", FullContext("linux", "x64"), "git")); - Assert.Equal(1, result.Status); - Assert.Equal("""{"code":"backend_error","message":"[backend_error] catalog revision '2099-01-01.1' is not installed","details":{"reason":"revision_unavailable"}}""", result.Json!["error"]!.ToJsonString(Relaxed)); - var error = Assert.Throws(() => BundledPolicyCatalog.ResolveSandboxPolicy("git", new ResolveContext { CatalogRevision = "2099-01-01.1", Platform = "linux", Architecture = "x64" })); - Assert.Equal("revision_unavailable", error.Reason); - - var r1 = Revision(new JsonNode[] { Entry("tool:a") }, "2000-01-01.1"); - var r2 = Revision(new JsonNode[] { Entry("tool:a", """{ "entryRevision": 2, "platformVariants": [{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/v2"] } } }] }""") }, "2000-01-02.1"); - var dir = WriteCatalog(NewDir("revisions-"), new[] { r1, r2 }); - Assert.Equal(0, Cli("validate", "--catalog", dir).Status); - var common = new[] { "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a" }; - Assert.Equal("""["/opt/v2"]""", Cli(Args("resolve", common)).Json!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); - Assert.Equal("""["/opt/a"]""", Cli(Args("resolve", "--revision", "2000-01-01.1", common)).Json!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); - } - - [Theory] - [MemberData(nameof(CliCommandTests.PlatformArch), MemberType = typeof(CliCommandTests))] - public void HostDefaultArchitectureAndNeutralFallbackWarnings(string platform, string architecture) - { - var catalog = new PolicyCatalog(CatalogStore.Bundled(), new FixedHost(platform, architecture)); - var c = PlatformContext[platform]; - var ctx = new ResolveContext { AllowWeakIdentityFallback = true, ProjectRoot = c.Root, Symbols = new Dictionary { ["git_prefix"] = c.Prefix } }; - var omitted = catalog.ResolveSandboxPolicyWithDiagnostics("git", ctx); - Assert.Equal( - new[] - { - "input 0 ('git') matched tool:git only by invocation name (weak identity)", - $"architecture was not specified; variants were selected for the native system architecture '{architecture}'; the tool's architecture was not verified", - $"tool:git uses its architecture-neutral {platform} variant; no {architecture}-specific variant exists", - }, - omitted.Diagnostics.Warnings); - var explicitArch = catalog.ResolveSandboxPolicyWithDiagnostics("git", ctx with { Platform = platform, Architecture = architecture }); - Assert.DoesNotContain(explicitArch.Diagnostics.Warnings, w => w.StartsWith("architecture was not specified", StringComparison.Ordinal)); - Assert.Equal(PolicyCatalogJson.Serialize(omitted.Policy), PolicyCatalogJson.Serialize(explicitArch.Policy)); - - // The packaged CLI detects this machine's native architecture. - var cli = Cli(Args("resolve", "--diagnostics", "--platform", platform, "--allow-weak", SymbolArgs(platform), "git")); - Assert.Equal(0, cli.Status); - Assert.Matches("architecture was not specified; variants were selected for the native system architecture '(x64|arm64)'; the tool's architecture was not verified", cli.Warnings); - } - - [Fact] - public void ArchitectureMismatchIsNoMatchWithSkippedReason() - { - var dir = WriteCatalog(NewDir("arch-"), new[] - { - Revision(new JsonNode[] - { - Entry("tool:a", """ - { "platformVariants": [ - { "when": { "platform": "windows", "architecture": "arm64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\arm64"] } } }, - { "when": { "platform": "windows", "architecture": "x64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\x64"] } } }, - { "when": { "platform": "linux", "architecture": "x64" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/x64"] } } } ] } - """), - }), - }); - foreach (var architecture in Architectures) - { - var result = Cli("resolve", "--catalog", dir, "--diagnostics", "--allow-weak", "--platform", "windows", "--architecture", architecture, "--symbol", "git_prefix=C:\\t", "a"); - Assert.Equal($"[\"C:\\\\t\\\\{architecture}\"]", result.Json!["policy"]!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); - Assert.DoesNotContain("architecture-neutral", result.Warnings, StringComparison.Ordinal); - } - - var noArm = Cli("resolve", "--catalog", dir, "--diagnostics", "--allow-weak", "--platform", "linux", "--architecture", "arm64", "--symbol", "git_prefix=/t", "a"); - Assert.Equal(0, noArm.Status); - Assert.Null(noArm.Json!["policy"]); - Assert.Equal("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/arm64", noArm.Warnings); - } -} - -public sealed class IntegrityTests : WorkDirTest -{ - private string CopyInstalled() - { - var dir = NewDir("catalog-"); - CopyDirectory(InstalledCatalogDir, dir); - return dir; - } - - private static string LatestFile(string dir) => - Path.Combine(dir, InstalledManifest["revisions"]!.AsArray()[^1]!["file"]!.GetValue()); - - [Fact] - public void UntouchedCopyValidatesAndFormattingOnlyEditsKeepTheDigest() - { - var dir = CopyInstalled(); - Assert.Equal(0, Cli("validate", "--catalog", dir).Status); - var ctx = FullContext("linux", "x64"); - Assert.Equal(Cli(Args("resolve", ctx, "git")).Stdout, Cli(Args("resolve", "--catalog", dir, ctx, "git")).Stdout); - var file = LatestFile(dir); - File.WriteAllText(file, JsonNode.Parse(File.ReadAllText(file))!.ToJsonString(Relaxed).Replace(",", ",\r\n")); - Assert.Equal(0, Cli("validate", "--catalog", dir).Status); - } - - [Fact] - public void TamperedRevisionFailsIntegrityEverywhere() - { - var dir = CopyInstalled(); - var file = LatestFile(dir); - var data = JsonNode.Parse(File.ReadAllText(file))!; - data["entries"]![0]!["platformVariants"]![0]!["sandboxPolicy"]!["filesystem"]!["readwritePaths"]!.AsArray().Add("${user_home}"); - File.WriteAllText(file, data.ToJsonString(Relaxed)); - - var validate = Cli("validate", "--catalog", dir); - Assert.Equal(1, validate.Status); - Assert.False(validate.Json!["ok"]!.GetValue()); - Assert.Matches("\\[backend_error\\] catalog revision '[^']+' digest [0-9a-f]{64} does not match the published digest", validate.Json["errors"]!.ToJsonString(Relaxed)); - - var resolve = Cli(Args("resolve", "--catalog", dir, FullContext("windows", "x64"), "git")); - Assert.Equal(1, resolve.Status); - Assert.Equal("integrity", resolve.ErrorReason); - Assert.Equal("backend_error", resolve.Json!["error"]!["code"]!.GetValue()); - Assert.Equal("integrity", Cli("inspect", "--catalog", dir).ErrorReason); - - var error = Assert.Throws(() => new PolicyCatalog(CatalogStore.FromDirectory(dir)).GetCatalogInfo()); - Assert.Equal(("backend_error", "integrity"), (error.Code, error.Reason)); - } - - [Fact] - public void MissingRevisionFileAndBadManifest() - { - var dir = CopyInstalled(); - File.Delete(LatestFile(dir)); - Assert.Equal("integrity", Cli("inspect", "--catalog", dir).ErrorReason); - Assert.Contains("could not be read", Cli("validate", "--catalog", dir).Json!["errors"]!.ToJsonString(Relaxed), StringComparison.Ordinal); - - var bad = CopyInstalled(); - var manifest = InstalledManifest; - manifest["defaultRevision"] = "2099-01-01.1"; - File.WriteAllText(Path.Combine(bad, "manifest.json"), manifest.ToJsonString(Relaxed)); - Assert.Equal("invalid_catalog", Cli("inspect", "--catalog", bad).ErrorReason); - Assert.Equal( - """["[policy_validation] manifest.defaultRevision must name a listed revision"]""", - Cli("validate", "--catalog", bad).Json!["errors"]!.ToJsonString(Relaxed)); - } - - [Fact] - public void ValidateRejectsADependencyCycle() - { - var dir = WriteCatalog(NewDir("cycle-"), new[] - { - Revision(new JsonNode[] { DependsOn("tool:a", "tool:b"), DependsOn("tool:b", "tool:c"), DependsOn("tool:c", "tool:a") }), - }); - var result = Cli("validate", "--catalog", dir); - Assert.Equal(1, result.Status); - Assert.False(result.Json!["ok"]!.GetValue()); - Assert.Contains("[policy_validation] 'tool:a' on linux/x64: cycle (tool:a -> tool:b -> tool:c -> tool:a)", result.Json["errors"]![0]!.GetValue(), StringComparison.Ordinal); - } - - [Fact] - public void ResolveNeverReturnsAPolicyFromACyclicCatalog() - { - var dir = WriteCatalog(NewDir("cycle-"), new[] { Revision(new JsonNode[] { DependsOn("tool:a", "tool:b"), DependsOn("tool:b", "tool:a") }) }); - var result = Cli("resolve", "--catalog", dir, "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a"); - Assert.Equal(1, result.Status); - Assert.Equal("policy_validation", result.Json?["error"]?["code"]?.GetValue()); - Assert.Equal("invalid_catalog", result.ErrorReason); - Assert.Contains("cycle (tool:a -> tool:b -> tool:a)", result.ErrorMessage, StringComparison.Ordinal); - var error = Assert.Throws(() => new PolicyCatalog(CatalogStore.FromDirectory(dir)).ResolveSandboxPolicy("a", new ResolveContext { AllowWeakIdentityFallback = true, Platform = "linux", Architecture = "x64" })); - Assert.Equal("invalid_catalog", error.Reason); - } - - [Fact] - public void DiamondIsValidAndContributesTheSharedEntryOnce() - { - var dir = WriteCatalog(NewDir("diamond-"), new[] - { - Revision(new JsonNode[] - { - Entry("tool:a", """{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "tool:b" }, { "entryId": "tool:c" }], "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/a"] } } }] }"""), - DependsOn("tool:b", "tool:d"), - DependsOn("tool:c", "tool:d"), - Entry("tool:d"), - }), - }); - Assert.Equal(0, Cli("validate", "--catalog", dir).Status); - var result = Cli("resolve", "--catalog", dir, "--diagnostics", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt", "a"); - Assert.Equal("""["/opt/a","/opt/d-dep","/opt/d"]""", result.Json!["policy"]!["filesystem"]!["readonlyPaths"]!.ToJsonString(Relaxed)); - } -} diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs deleted file mode 100644 index 3531f3364..000000000 --- a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Helpers.cs +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Shared helpers for the functional suite. The library is the packed -// Microsoft.Mxc.PolicyCatalog package restored from a local feed, and the CLI -// is a consumer build of policy-catalog against that same package; the driver -// (scripts/dotnet-functional.mjs) sets POLICY_CATALOG_CLI_DLL and -// POLICY_CATALOG_REPO_ROOT. Nothing here reads the repository's sources. -using System.Diagnostics; -using System.Text; -using System.Text.Json.Nodes; -using Xunit; - -namespace Microsoft.Mxc.PolicyCatalog.FunctionalTests; - -internal sealed record CliResult(int Status, string Stdout, string Stderr) -{ - public JsonNode? Json => string.IsNullOrWhiteSpace(Stdout) ? null : JsonNode.Parse(Stdout); - - public string Warnings => string.Join("\n", Json?["diagnostics"]?["warnings"]?.AsArray().Select(w => w!.GetValue()) ?? Array.Empty()); - - public string? ErrorReason => Json?["error"]?["details"]?["reason"]?.GetValue(); - - public string ErrorMessage => Json?["error"]?["message"]?.GetValue() ?? string.Empty; -} - -internal sealed class FixedHost : IHostEnvironment -{ - private readonly string _platform; - private readonly string _architecture; - - public FixedHost(string platform, string architecture) - { - _platform = platform; - _architecture = architecture; - } - - public string Platform() => _platform; - - public string NativeArchitecture() => _architecture; - - public string? Symbol(string name) => null; -} - -internal static class Fx -{ - /// JSON.stringify-like escaping for comparisons (System.Text.Json escapes quotes by default). - public static readonly System.Text.Json.JsonSerializerOptions Relaxed = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver() }; - - public static readonly string[] Platforms = { "windows", "linux", "macos" }; - public static readonly string[] Architectures = { "x64", "arm64" }; - - public static string CliDll => Environment.GetEnvironmentVariable("POLICY_CATALOG_CLI_DLL") - ?? throw new InvalidOperationException("POLICY_CATALOG_CLI_DLL is not set; run the functional suite through scripts/dotnet-functional.mjs"); - - /// The catalog directory shipped in the package (contentFiles copied to output). - public static string InstalledCatalogDir => Path.Combine(AppContext.BaseDirectory, "catalog"); - - public static JsonNode InstalledManifest => JsonNode.Parse(File.ReadAllText(Path.Combine(InstalledCatalogDir, "manifest.json")))!; - - public static CliResult Cli(params string[] args) - { - var start = new ProcessStartInfo("dotnet") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - StandardOutputEncoding = new UTF8Encoding(false), - StandardErrorEncoding = new UTF8Encoding(false), - WorkingDirectory = Path.GetTempPath(), - }; - start.ArgumentList.Add(CliDll); - foreach (var arg in args) - { - start.ArgumentList.Add(arg); - } - - using var process = Process.Start(start)!; - var stderr = process.StandardError.ReadToEndAsync(); - var stdout = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - return new CliResult(process.ExitCode, stdout, stderr.Result); - } - - public static readonly Dictionary PlatformContext = new() - { - ["windows"] = ("C:\\work\\app", "C:\\tools", "C:\\cache\\npm"), - ["linux"] = ("/work/app", "/opt/tools", "/var/cache/npm"), - ["macos"] = ("/Users/dev/app", "/opt/homebrew/bin", "/Users/dev/.npm"), - }; - - public static string[] SymbolArgs(string platform) - { - var c = PlatformContext[platform]; - return new[] - { - "--project-root", c.Root, - "--symbol", $"git_prefix={c.Prefix}", - "--symbol", $"node_prefix={c.Prefix}", - "--symbol", $"npm_prefix={c.Prefix}", - "--symbol", $"npm_cache={c.Cache}", - }; - } - - public static string[] FullContext(string platform, string architecture) => - new[] { "--platform", platform, "--architecture", architecture, "--allow-weak" }.Concat(SymbolArgs(platform)).ToArray(); - - public static string[] Args(params object[] parts) => - parts.SelectMany(part => part is string s ? new[] { s } : (IEnumerable)part).ToArray(); - - public static JsonObject Entry(string entryId, string? overrides = null) - { - var name = entryId.Split(':')[1]; - var entry = new JsonObject - { - ["entryId"] = entryId, - ["entryRevision"] = 1, - ["displayName"] = name, - ["identity"] = JsonNode.Parse($$"""[{ "kind": "invocation-name", "names": ["{{name}}"] }]"""), - ["platformVariants"] = JsonNode.Parse($$$"""[{ "when": { "platform": "linux" }, "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/{{{name}}}"] } } }]"""), - ["provenance"] = JsonNode.Parse("""{ "method": "functional-test", "sourceRevision": "functional-test" }"""), - }; - if (overrides is not null) - { - foreach (var (key, value) in JsonNode.Parse(overrides)!.AsObject().ToList()) - { - entry[key] = value?.DeepClone(); - } - } - - return entry; - } - - public static JsonObject DependsOn(string entryId, string target) - { - var name = target.Split(':')[1]; - return Entry(entryId, $$$"""{ "platformVariants": [{ "when": { "platform": "linux" }, "dependencies": [{ "entryId": "{{{target}}}" }], "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/{{{name}}}-dep"] } } }] }"""); - } - - public static JsonObject Revision(IEnumerable entries, string catalogRevision = "2000-01-01.1") => new() - { - ["catalogSchemaVersion"] = "1", - ["catalogRevision"] = catalogRevision, - ["entries"] = new JsonArray(entries.ToArray()), - }; - - /// A complete catalog directory with correct digests, so the only defect is the one a test adds. - public static string WriteCatalog(string dir, IReadOnlyList revisions, string? defaultRevision = null) - { - Directory.CreateDirectory(Path.Combine(dir, "revisions")); - File.Copy(Path.Combine(InstalledCatalogDir, "contract.v1.json"), Path.Combine(dir, "contract.v1.json"), true); - var list = new JsonArray(); - foreach (var revision in revisions) - { - var id = revision["catalogRevision"]!.GetValue(); - var file = $"revisions/{id}.json"; - var text = revision.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(dir, file), text + "\n"); - list.Add(new JsonObject { ["catalogRevision"] = id, ["file"] = file, ["sha256"] = PolicyCatalogJson.CanonicalSha256(text) }); - } - - var manifest = new JsonObject - { - ["catalogSchemaVersion"] = "1", - ["defaultRevision"] = defaultRevision ?? revisions[^1]["catalogRevision"]!.GetValue(), - ["revisions"] = list, - }; - File.WriteAllText(Path.Combine(dir, "manifest.json"), manifest.ToJsonString() + "\n"); - return dir; - } - - public static void CopyDirectory(string from, string to) - { - Directory.CreateDirectory(to); - foreach (var file in Directory.GetFiles(from, "*", SearchOption.AllDirectories)) - { - var target = Path.Combine(to, Path.GetRelativePath(from, file)); - Directory.CreateDirectory(Path.GetDirectoryName(target)!); - File.Copy(file, target, true); - } - } -} - -/// A per-test temporary directory outside the repository. -public abstract class WorkDirTest : IDisposable -{ - protected WorkDirTest() - { - Work = Path.Combine(Path.GetTempPath(), "policy-catalog-dotnet-functional-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Work); - } - - protected string Work { get; } - - protected string NewDir(string prefix) - { - var dir = Path.Combine(Work, prefix + Guid.NewGuid().ToString("N").Substring(0, 8)); - Directory.CreateDirectory(dir); - return dir; - } - - public void Dispose() - { - try - { - foreach (var file in Directory.GetFiles(Work, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - - Directory.Delete(Work, true); - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - - GC.SuppressFinalize(this); - } -} - -public sealed class PackageTests -{ - [Fact] - public void LibraryComesFromTheRestoredPackageNotTheSourceTree() - { - var location = typeof(PolicyCatalog).Assembly.Location; - var repo = Environment.GetEnvironmentVariable("POLICY_CATALOG_REPO_ROOT"); - Assert.False(string.IsNullOrEmpty(repo), "POLICY_CATALOG_REPO_ROOT is not set"); - Assert.False(location.StartsWith(Path.GetFullPath(repo!), StringComparison.OrdinalIgnoreCase), $"{location} is inside the repository {repo}"); - Assert.False(Fx.CliDll.StartsWith(Path.GetFullPath(repo!), StringComparison.OrdinalIgnoreCase), $"{Fx.CliDll} is inside the repository"); - var packages = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); - Assert.False(string.IsNullOrEmpty(packages)); - Assert.True(File.Exists(Path.Combine(packages!, "microsoft.mxc.policycatalog", "0.0.0-prototype", "lib", "net8.0", "Microsoft.Mxc.PolicyCatalog.dll"))); - Assert.True(File.Exists(Path.Combine(Fx.InstalledCatalogDir, "manifest.json"))); - } -} diff --git a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj b/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj deleted file mode 100644 index 6cb94cb64..000000000 --- a/policy-catalog/dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests/Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - - net8.0 - Exe - enable - enable - latest - false - true - 0.0.0-prototype - - - - - - - - diff --git a/policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj b/policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj deleted file mode 100644 index b7808fe22..000000000 --- a/policy-catalog/dotnet/functional/policy-catalog-cli-consumer/policy-catalog-cli-consumer.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - - net8.0 - Exe - enable - enable - latest - true - policy-catalog - false - false - 0.0.0-prototype - - - - - - - diff --git a/policy-catalog/dotnet/global.json b/policy-catalog/dotnet/global.json deleted file mode 100644 index 0777cf772..000000000 --- a/policy-catalog/dotnet/global.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "sdk": { - "version": "10.0.401", - "rollForward": "latestFeature" - }, - "test": { - "runner": "Microsoft.Testing.Platform" - } -} diff --git a/policy-catalog/dotnet/tools/compare-cli.mjs b/policy-catalog/dotnet/tools/compare-cli.mjs deleted file mode 100644 index 73573284a..000000000 --- a/policy-catalog/dotnet/tools/compare-cli.mjs +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Spot comparison of the .NET CLI against the TypeScript CLI (node dist/cli.js). -// Feeds identical arguments to both, then compares exit codes, stderr (usage -// errors), and stdout JSON canonicalized with sorted keys, after replacing the -// absolute catalog directory with a placeholder and truncating OS I/O error -// text after "could not be read: ". -// -// npm run build (once, from policy-catalog/) -// dotnet build dotnet/Microsoft.Mxc.PolicyCatalog.slnx -c Release -// node dotnet/tools/compare-cli.mjs -import { spawnSync } from 'node:child_process'; -import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { canonicalJson, canonicalSha256 } from '../../dist/canonical-json.js'; - -const root = fileURLToPath(new URL('../../', import.meta.url)); -const tsCli = join(root, 'dist', 'cli.js'); -const csCli = join(root, 'dotnet', 'Microsoft.Mxc.PolicyCatalog.Cli', 'bin', 'Release', 'net8.0', 'policy-catalog.dll'); -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-compare-')); - -function writeCatalog(dir, revisions) { - mkdirSync(join(dir, 'revisions'), { recursive: true }); - cpSync(join(root, 'catalog', 'contract.v1.json'), join(dir, 'contract.v1.json')); - const manifest = { catalogSchemaVersion: '1', defaultRevision: revisions.at(-1).catalogRevision, revisions: [] }; - for (const r of revisions) { - const file = `revisions/${r.catalogRevision}.json`; - writeFileSync(join(dir, file), JSON.stringify(r, null, 2)); - manifest.revisions.push({ catalogRevision: r.catalogRevision, file, sha256: canonicalSha256(r) }); - } - writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); - return dir; -} -const entry = (id, deps = []) => ({ - entryId: id, entryRevision: 1, displayName: id, identity: [{ kind: 'invocation-name', names: [id.split(':')[1]] }], - platformVariants: [{ when: { platform: 'linux' }, ...(deps.length ? { dependencies: deps.map(entryId => ({ entryId })) } : {}), sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${id.split(':')[1]}`] } } }], - provenance: { method: 'cmp', sourceRevision: 'cmp' }, -}); - -const tampered = join(work, 'tampered'); -cpSync(join(root, 'catalog'), tampered, { recursive: true }); -const revFile = join(tampered, 'revisions', '2026-09-29.1.json'); -const data = JSON.parse(readFileSync(revFile, 'utf8')); -data.entries[0].platformVariants[0].sandboxPolicy.filesystem.readwritePaths.push('${user_home}'); -writeFileSync(revFile, JSON.stringify(data)); -const cyclic = writeCatalog(join(work, 'cyclic'), [{ catalogSchemaVersion: '1', catalogRevision: '2000-01-01.1', entries: [entry('tool:a', ['tool:b']), entry('tool:b', ['tool:c']), entry('tool:c', ['tool:a'])] }]); -const missing = join(work, 'missing'); -cpSync(join(root, 'catalog'), missing, { recursive: true }); -rmSync(join(missing, 'revisions', '2026-09-29.1.json')); - -const ctx = { - windows: ['--project-root', 'C:\\work\\app', '--symbol', 'git_prefix=C:\\tools', '--symbol', 'node_prefix=C:\\tools', '--symbol', 'npm_prefix=C:\\tools', '--symbol', 'npm_cache=C:\\cache\\npm', '--symbol', 'user_home=C:\\Users\\u', '--symbol', 'temp_dir=C:\\t'], - linux: ['--project-root', '/work/app', '--symbol', 'git_prefix=/opt/tools', '--symbol', 'node_prefix=/opt/tools', '--symbol', 'npm_prefix=/opt/tools', '--symbol', 'npm_cache=/var/cache/npm', '--symbol', 'user_home=/home/u', '--symbol', 'temp_dir=/tmp'], - macos: ['--project-root', '/Users/dev/app', '--symbol', 'git_prefix=/opt/homebrew/bin', '--symbol', 'node_prefix=/opt/homebrew/bin', '--symbol', 'npm_prefix=/opt/homebrew/bin', '--symbol', 'npm_cache=/Users/dev/.npm', '--symbol', 'user_home=/Users/dev', '--symbol', 'temp_dir=/tmp'], -}; -const cases = [ - ['inspect'], ['validate'], ['validate', '--catalog', join(root, 'catalog')], - ['validate', '--catalog', tampered], ['inspect', '--catalog', tampered], ['resolve', '--catalog', tampered, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', ...ctx.linux, 'git'], - ['validate', '--catalog', cyclic], ['resolve', '--catalog', cyclic, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'], - ['validate', '--catalog', missing], ['inspect', '--catalog', missing], ['inspect', '--catalog', join(work, 'nope')], ['validate', '--catalog', join(work, 'nope')], - ['validate', '--base-ref', 'HEAD'], ['validate', '--base-ref', '-h'], ['validate', '--base-ref', 'no-such-ref-xyz'], ['validate', '--catalog', cyclic, '--base-ref', 'HEAD'], -]; -for (const platform of ['windows', 'linux', 'macos']) { - for (const architecture of ['x64', 'arm64']) { - for (const diag of [[], ['--diagnostics']]) { - cases.push(['resolve', ...diag, '--platform', platform, '--architecture', architecture, '--allow-weak', ...ctx[platform], 'git', 'npm', 'node']); - cases.push(['resolve', ...diag, '--platform', platform, '--architecture', architecture, ...ctx[platform], 'git', 'npm', 'node']); - } - } - cases.push(['resolve', '--diagnostics', '--platform', platform, '--allow-weak', ...ctx[platform], 'git', 'npm', 'node']); -} -for (const diag of [[], ['--diagnostics']]) { - cases.push( - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', ...ctx.linux, '--purl', 'pkg:npm/npm@11.0.0', 'npm'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', ...ctx.linux, '--purl', 'pkg:npm/npm@11.0.0', '--detected-version', 'nightly', 'npm'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', ...ctx.linux, 'cargo'], - ['resolve', ...diag, '--revision', '2099-01-01.1', '--platform', 'linux', '--architecture', 'x64', 'git'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'mips', 'git'], - ['resolve', ...diag, '--platform', 'plan9', 'git'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'node_prefix=bin', 'node'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', '__proto__=/x', 'node'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'project_root=/x', 'git'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/g', 'git'], - ['resolve', ...diag, '--allow-weak', ...ctx.windows, 'git', 'npm', 'node'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], - ['resolve', ...diag, '--platform', 'windows', '--architecture', 'x64', '--allow-weak', '--project-root', 'C:\\W', '--symbol', 'git_prefix=c:\\w\\bin', 'GIT.EXE'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', ...ctx.linux, 'GIT'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--purl', 'not-a-purl', 'npm'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', '--purl', 'pkg:npm/npm@%E0%A4%A', 'npm'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64', './bin/git'], - ['resolve', ...diag, '--platform', 'linux', '--architecture', 'x64'], - ); -} -cases.push( - [], ['bogus'], ['info'], ['resolve', '--platform'], ['resolve', '--symbol', 'noequals', 'git'], ['resolve', '--symbol', '=x', 'git'], - ['resolve', '--purl', 'pkg:npm/npm'], ['resolve', '--base-ref', 'HEAD', 'git'], ['inspect', 'extra'], ['validate', 'extra'], - ['validate', '--base-ref'], ['resolve', '--catalog'], ['resolve', '--platform', '--allow-weak', 'git'], ['resolve', '--bogus'], - ['inspect', '--base-ref', 'HEAD'], -); - -function normalize(stdout, dirs) { - if (stdout.trim() === '') { - return ''; - } - let text = stdout; - for (const dir of dirs) { - text = text.split(JSON.stringify(dir).slice(1, -1)).join(''); - } - const value = JSON.parse(text); - const truncate = v => (typeof v === 'string' && v.includes('could not be read: ') ? v.slice(0, v.indexOf('could not be read: ') + 19) : v); - const walk = v => (Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(truncate(x))])) : truncate(v)); - return canonicalJson(walk(value)); -} - -let mismatches = 0; -const bundledDirs = [join(root, 'catalog'), join(root, 'dotnet', 'Microsoft.Mxc.PolicyCatalog.Cli', 'bin', 'Release', 'net8.0', 'catalog')]; -for (const args of cases) { - const ts = spawnSync(process.execPath, [tsCli, ...args], { cwd: root, encoding: 'utf8' }); - const cs = spawnSync('dotnet', [csCli, ...args], { cwd: root, encoding: 'utf8' }); - const dirs = [...bundledDirs, ...args.filter((_, i) => args[i - 1] === '--catalog').map(d => join(d))].map(d => d.replace(/[\\/]+$/, '')); - const a = { status: ts.status, out: normalize(ts.stdout, dirs), err: ts.stderr }; - const b = { status: cs.status, out: normalize(cs.stdout, dirs), err: cs.stderr.replace(/\r\n/g, '\n') }; - const same = a.status === b.status && a.out === b.out && a.err === b.err; - if (!same) { - mismatches += 1; - console.log(`MISMATCH: ${args.join(' ')}\n ts: ${a.status} ${a.out} ${a.err}\n cs: ${b.status} ${b.out} ${b.err}`); - } -} -console.log(`${cases.length} cases, ${cases.length - mismatches} identical, ${mismatches} mismatched`); -rmSync(work, { recursive: true, force: true }); -process.exit(mismatches === 0 ? 0 : 1); diff --git a/policy-catalog/package-lock.json b/policy-catalog/package-lock.json deleted file mode 100644 index d68394f75..000000000 --- a/policy-catalog/package-lock.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "name": "@mxc-prototype/policy-catalog", - "version": "0.0.0-prototype", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@mxc-prototype/policy-catalog", - "version": "0.0.0-prototype", - "license": "MIT", - "devDependencies": { - "@types/node": "24.13.4", - "ajv": "8.20.0", - "typescript": "5.9.3" - }, - "engines": { - "node": ">=24.0.0" - } - }, - "node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/policy-catalog/package.json b/policy-catalog/package.json deleted file mode 100644 index 505efc30a..000000000 --- a/policy-catalog/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@mxc-prototype/policy-catalog", - "version": "0.0.0-prototype", - "private": true, - "description": "PROTOTYPE of the known-tool policy catalog library (TypeScript/JavaScript). Not published; the package name is a placeholder until a dedicated repository exists.", - "type": "module", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./tooling": { - "types": "./dist/tooling.d.ts", - "import": "./dist/tooling.js" - }, - "./package.json": "./package.json" - }, - "bin": { - "policy-catalog": "dist/cli.js" - }, - "files": [ - "dist/", - "catalog/", - "schema/", - "LICENSE.md", - "README.md" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "build:test-unit": "tsc -p tests/unit/tsconfig.json", - "build:test-functional": "tsc -p tests/functional/tsconfig.json", - "clean": "node scripts/clean.mjs", - "typecheck": "npm run build && npm run build:test-unit && npm run build:test-functional", - "test": "npm run test:unit", - "test:unit": "npm run build && npm run build:test-unit && node scripts/check-tests-present.mjs tests/unit/dist && node tests/unit/run-tests.js", - "test:functional": "npm run build && npm run build:test-functional && node scripts/check-tests-present.mjs tests/functional/dist && node tests/functional/run-tests.js", - "validate": "npm run build && node scripts/validate-catalog.mjs", - "digest": "node scripts/catalog-digest.mjs", - "check:pack": "npm run build && node scripts/check-pack.mjs", - "check:extract": "node scripts/check-extractable.mjs", - "smoke:package": "npm run build && node scripts/package-smoke.mjs", - "check": "npm run typecheck && npm run test:unit && npm run test:functional && npm run validate && npm run check:pack && npm run smoke:package", - "prepublishOnly": "npm run build" - }, - "license": "MIT", - "devDependencies": { - "@types/node": "24.13.4", - "ajv": "8.20.0", - "typescript": "5.9.3" - }, - "engines": { - "node": ">=24.0.0" - } -} diff --git a/policy-catalog/rust/.gitignore b/policy-catalog/rust/.gitignore deleted file mode 100644 index 9f6bb2c28..000000000 --- a/policy-catalog/rust/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -target/ -# Copy of ../catalog made by scripts/sync-catalog.mjs for `cargo package`. -/catalog/ -*.crate diff --git a/policy-catalog/rust/Cargo.lock b/policy-catalog/rust/Cargo.lock deleted file mode 100644 index 6ba014eb7..000000000 --- a/policy-catalog/rust/Cargo.lock +++ /dev/null @@ -1,93 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "mxc-policy-catalog" -version = "0.0.0-prototype" -dependencies = [ - "libc", - "sha2", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" diff --git a/policy-catalog/rust/Cargo.toml b/policy-catalog/rust/Cargo.toml deleted file mode 100644 index 86577115f..000000000 --- a/policy-catalog/rust/Cargo.toml +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -[package] -name = "mxc-policy-catalog" -version = "0.0.0-prototype" -edition = "2021" -rust-version = "1.93" -license = "MIT" -publish = false -description = "PROTOTYPE of the known-tool policy catalog library (native Rust). Not published; the crate name is a placeholder until a dedicated repository exists." -build = "build.rs" -# Only these files ship in the .crate. `catalog/` is git-ignored (a copy of -# ../catalog made by scripts/sync-catalog.mjs) and must be listed here. -include = [ - "Cargo.toml", - "Cargo.lock", - "build.rs", - "README.md", - "rust-toolchain.toml", - "src/**/*.rs", - "catalog/contract.v1.json", - "catalog/manifest.json", - "catalog/revisions/*.json", -] - -# A standalone workspace: never part of an enclosing Cargo workspace. -[workspace] - -[lib] -name = "mxc_policy_catalog" -path = "src/lib.rs" - -[[bin]] -name = "policy-catalog" -path = "src/bin/policy-catalog.rs" - -[dependencies] -sha2 = "0.11" - -[target.'cfg(unix)'.dependencies] -# uname(2) for the kernel machine type (Node `os.machine()`), Linux/macOS only. -libc = "0.2" diff --git a/policy-catalog/rust/functional/Cargo.toml.in b/policy-catalog/rust/functional/Cargo.toml.in deleted file mode 100644 index 8114730dc..000000000 --- a/policy-catalog/rust/functional/Cargo.toml.in +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Template for the temporary consumer crate built by scripts/rust-functional.mjs. -# @CRATE_PATH@ is replaced with the extracted .crate directory (outside the repository). -[package] -name = "policy-catalog-functional-consumer" -version = "0.0.0" -edition = "2021" -license = "MIT" -publish = false - -# Standalone: never part of an enclosing workspace. -[workspace] - -[dependencies] -mxc-policy-catalog = { path = "@CRATE_PATH@" } diff --git a/policy-catalog/rust/functional/src/lib.rs b/policy-catalog/rust/functional/src/lib.rs deleted file mode 100644 index e78595839..000000000 --- a/policy-catalog/rust/functional/src/lib.rs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Helpers for the packaged-crate functional tests. Everything resolves the -//! library from the extracted `.crate` (a `path` dependency outside the -//! repository) and the CLI from the binary built from that same crate -//! (`POLICY_CATALOG_BIN`). Nothing reads the repository. - -use mxc_policy_catalog::tooling::{bundled_catalog_files, canonical_sha256, Json, JsonObject}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::atomic::{AtomicUsize, Ordering}; - -/// Result of one CLI run. -#[derive(Debug)] -pub struct CliResult { - pub status: i32, - pub json: Option, - pub stdout: String, - pub stderr: String, -} - -impl CliResult { - pub fn warnings(&self) -> String { - self.json - .as_ref() - .and_then(|j| j.get("diagnostics")) - .and_then(|d| d.get("warnings")) - .and_then(Json::as_array) - .map(|w| w.iter().filter_map(Json::as_str).collect::>().join("\n")) - .unwrap_or_default() - } - - pub fn error_reason(&self) -> Option { - self.json - .as_ref()? - .get("error")? - .get("details")? - .get("reason")? - .as_str() - .map(str::to_string) - } - - pub fn errors(&self) -> String { - self.json - .as_ref() - .and_then(|j| j.get("errors")) - .and_then(Json::as_array) - .map(|e| e.iter().filter_map(Json::as_str).collect::>().join("\n")) - .unwrap_or_default() - } -} - -/// The packaged `policy-catalog` binary. -pub fn cli_path() -> PathBuf { - PathBuf::from(std::env::var("POLICY_CATALOG_BIN").expect("POLICY_CATALOG_BIN is set by scripts/rust-functional.mjs")) -} - -/// Runs the packaged CLI in a separate process. -pub fn cli(args: &[&str]) -> CliResult { - let output = Command::new(cli_path()).args(args).output().expect("run policy-catalog"); - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - let json = if stdout.trim().is_empty() { None } else { Json::parse(&stdout).ok() }; - CliResult { - status: output.status.code().unwrap_or(-1), - json, - stdout, - stderr, - } -} - -static COUNTER: AtomicUsize = AtomicUsize::new(0); - -/// A fresh directory under the driver's work directory (outside the repository). -pub fn work_dir(prefix: &str) -> PathBuf { - let base = std::env::var("POLICY_CATALOG_WORK").map_or_else(|_| std::env::temp_dir(), PathBuf::from); - let dir = base.join(format!( - "{prefix}-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, Ordering::SeqCst) - )); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -pub fn j(text: &str) -> Json { - Json::parse(text).unwrap_or_else(|e| panic!("{e}: {text}")) -} - -pub fn s(path: &Path) -> String { - path.to_string_lossy().into_owned() -} - -/// Writes the catalog shipped inside the packaged crate, byte for byte. -pub fn copy_bundled(dir: &Path) -> PathBuf { - for (name, text) in bundled_catalog_files() { - let path = dir.join(name); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, text).unwrap(); - } - dir.to_path_buf() -} - -/// Writes a complete catalog directory (packaged contract, correct digests), -/// so the only defect is the one a test introduces. -pub fn write_catalog(dir: &Path, revisions: &[Json]) -> PathBuf { - std::fs::create_dir_all(dir.join("revisions")).unwrap(); - let contract = bundled_catalog_files().into_iter().find(|(n, _)| *n == "contract.v1.json").unwrap().1; - std::fs::write(dir.join("contract.v1.json"), contract).unwrap(); - let mut listed = Vec::new(); - for revision in revisions { - let id = revision.get("catalogRevision").and_then(Json::as_str).unwrap(); - let file = format!("revisions/{id}.json"); - std::fs::write(dir.join(&file), format!("{}\n", revision.to_pretty_string())).unwrap(); - listed.push(j(&format!( - r#"{{"catalogRevision":"{id}","file":"{file}","sha256":"{}"}}"#, - canonical_sha256(revision) - ))); - } - let mut manifest = JsonObject::new(); - manifest.insert("catalogSchemaVersion", "1".into()); - let last = revisions.last().unwrap().get("catalogRevision").unwrap().clone(); - manifest.insert("defaultRevision", last); - manifest.insert("revisions", Json::Array(listed)); - std::fs::write(dir.join("manifest.json"), Json::Object(manifest).to_pretty_string()).unwrap(); - dir.to_path_buf() -} - -/// Minimal valid entry (linux, readonly `${git_prefix}/`) with 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}", - "identity":[{{"kind":"invocation-name","names":["{name}"]}}], - "platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readonlyPaths":["${{git_prefix}}/{name}"]}}}}}}], - "provenance":{{"method":"functional-test","sourceRevision":"functional-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 revision(entries: Vec, id: &str) -> Json { - let mut o = JsonObject::new(); - o.insert("catalogSchemaVersion", "1".into()); - o.insert("catalogRevision", id.into()); - o.insert("entries", Json::Array(entries)); - Json::Object(o) -} - -/// Resolve flags with every bundled symbol for `platform`. -pub fn full_context(platform: &str, architecture: &str) -> Vec { - let (root, prefix, cache) = match platform { - "windows" => ("C:\\work\\app", "C:\\tools", "C:\\cache\\npm"), - "macos" => ("/Users/dev/app", "/opt/homebrew/bin", "/Users/dev/.npm"), - _ => ("/work/app", "/opt/tools", "/var/cache/npm"), - }; - let mut args: Vec = vec!["--platform".into(), platform.into()]; - if !architecture.is_empty() { - args.extend(["--architecture".into(), architecture.into()]); - } - args.extend( - [ - "--allow-weak", - "--project-root", - root, - "--symbol", - &format!("git_prefix={prefix}"), - "--symbol", - &format!("node_prefix={prefix}"), - "--symbol", - &format!("npm_prefix={prefix}"), - "--symbol", - &format!("npm_cache={cache}"), - ] - .map(str::to_string), - ); - args -} - -pub fn args<'a>(parts: &'a [&'a str], extra: &'a [String]) -> Vec<&'a str> { - let mut all: Vec<&str> = parts.to_vec(); - all.extend(extra.iter().map(String::as_str)); - all -} diff --git a/policy-catalog/rust/functional/tests/functional.rs b/policy-catalog/rust/functional/tests/functional.rs deleted file mode 100644 index 4e8b12dac..000000000 --- a/policy-catalog/rust/functional/tests/functional.rs +++ /dev/null @@ -1,441 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Functional tests of the PACKAGED crate: the library comes from the -//! extracted `.crate` and the CLI is the binary built from it. Every catalog -//! here is a synthetic directory or a copy in a temporary folder. - -use mxc_policy_catalog::tooling::Json; -use mxc_policy_catalog::{ - load_catalog_directory, Architecture, ErrorReason, FixedHost, Platform, PolicyCatalog, ResolveContext, - ToolCandidate, -}; -use policy_catalog_functional_consumer::*; -use std::sync::Arc; - -fn catalog_at(dir: &std::path::Path, platform: Platform, architecture: Architecture) -> PolicyCatalog { - PolicyCatalog::with_host( - Arc::new(load_catalog_directory(dir).unwrap()), - Arc::new(FixedHost::new(platform, architecture)), - ) -} - -fn depends_on(target: &str) -> String { - let name = target.split(':').nth(1).unwrap(); - format!( - r#"{{"platformVariants":[{{"when":{{"platform":"linux"}},"dependencies":[{{"entryId":"{target}"}}],"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readonlyPaths":["${{git_prefix}}/{name}-dep"]}}}}}}]}}"# - ) -} - -// --------------------------------------------------------------------------- -// Unknown tool -// --------------------------------------------------------------------------- - -#[test] -fn unknown_tool_is_no_policy_with_warning() { - let ctx = full_context("linux", "x64"); - let result = cli(&args(&["resolve", "--diagnostics"], &ctx).into_iter().chain(["cargo"]).collect::>()); - assert_eq!(result.status, 0, "{}", result.stderr); - let json = result.json.as_ref().unwrap(); - assert!(json.get("policy").is_none(), "policy key must be omitted"); - assert_eq!(json.get("diagnostics").unwrap().get("tools").unwrap(), &j(r#"[{"inputIndex":0,"matches":[]}]"#)); - assert!(result.warnings().lines().any(|l| l == "input 0 ('cargo') matched no eligible catalog entry")); - let plain = cli(&args(&["resolve"], &ctx).into_iter().chain(["cargo"]).collect::>()); - assert_eq!(plain.stdout, "null\n"); - // Library API over the bundled catalog. - let r = mxc_policy_catalog::resolve_sandbox_policy_with_diagnostics( - "cargo", - &ResolveContext::new().platform(Platform::Linux).architecture(Architecture::X64).allow_weak(true), - ) - .unwrap(); - assert_eq!(r.policy, None); -} - -// --------------------------------------------------------------------------- -// Dependency cycle -// --------------------------------------------------------------------------- - -#[test] -fn dependency_cycle_fails_validate_and_resolve() { - let dir = write_catalog( - &work_dir("cycle"), - &[revision( - vec![ - entry("tool:a", &depends_on("tool:b")), - entry("tool:b", &depends_on("tool:c")), - entry("tool:c", &depends_on("tool:a")), - ], - "2000-01-01.1", - )], - ); - let validate = cli(&["validate", "--catalog", &s(&dir)]); - assert_eq!(validate.status, 1, "{}", validate.stdout); - assert_eq!(validate.json.as_ref().unwrap().get("ok"), Some(&Json::Bool(false))); - assert!( - validate - .errors() - .contains("[policy_validation] 'tool:a' on linux/x64: cycle (tool:a -> tool:b -> tool:c -> tool:a)"), - "{}", - validate.errors() - ); - let resolve = cli(&[ - "resolve", "--catalog", &s(&dir), "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", - "git_prefix=/opt", "a", - ]); - assert_eq!(resolve.status, 1, "{}", resolve.stdout); - let error = resolve.json.as_ref().unwrap().get("error").unwrap(); - assert_eq!(error.get("code").and_then(Json::as_str), Some("policy_validation")); - assert_eq!(resolve.error_reason().as_deref(), Some("invalid_catalog")); - // Library API. - let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); - let err = catalog - .resolve_sandbox_policy("a", &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) - .unwrap_err(); - assert_eq!(err.reason(), ErrorReason::InvalidCatalog); - assert!(err.message().contains("cycle ("), "{}", err.message()); -} - -#[test] -fn diamond_is_valid_and_contributes_shared_entry_once() { - let dir = write_catalog( - &work_dir("diamond"), - &[revision( - vec![ - entry( - "tool:a", - r#"{"platformVariants":[{"when":{"platform":"linux"},"dependencies":[{"entryId":"tool:b"},{"entryId":"tool:c"}],"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}/a"]}}}]}"#, - ), - entry("tool:b", &depends_on("tool:d")), - entry("tool:c", &depends_on("tool:d")), - entry("tool:d", ""), - ], - "2000-01-01.1", - )], - ); - assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); - let r = cli(&[ - "resolve", "--catalog", &s(&dir), "--diagnostics", "--platform", "linux", "--architecture", "x64", - "--allow-weak", "--symbol", "git_prefix=/opt", "a", - ]); - assert_eq!(r.status, 0, "{}", r.stderr); - let json = r.json.unwrap(); - assert_eq!( - json.get("policy").unwrap().get("filesystem").unwrap().get("readonlyPaths").unwrap(), - &j(r#"["/opt/a","/opt/d-dep","/opt/d"]"#) - ); -} - -// --------------------------------------------------------------------------- -// Integrity tamper -// --------------------------------------------------------------------------- - -#[test] -fn integrity_tamper_fails_resolve_inspect_and_validate() { - let dir = copy_bundled(&work_dir("tamper")); - let untouched = cli(&["validate", "--catalog", &s(&dir)]); - assert_eq!(untouched.status, 0, "{}", untouched.stdout); - let manifest = j(&std::fs::read_to_string(dir.join("manifest.json")).unwrap()); - let file = manifest.get("revisions").unwrap().as_array().unwrap().last().unwrap().get("file").unwrap(); - let path = dir.join(file.as_str().unwrap()); - // Formatting-only change keeps the digest valid. - let original = std::fs::read_to_string(&path).unwrap(); - std::fs::write(&path, j(&original).to_compact_string().replace(',', ",\r\n")).unwrap(); - assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); - // Semantic widening: the tamper integrity checking exists to stop. - let widened = original.replacen("\"readwritePaths\": [\"${project_root}\"]", "\"readwritePaths\": [\"${project_root}\", \"${user_home}\"]", 1); - assert_ne!(widened, original, "tamper edit applied"); - std::fs::write(&path, widened).unwrap(); - - let validate = cli(&["validate", "--catalog", &s(&dir)]); - assert_eq!(validate.status, 1, "{}", validate.stdout); - assert_eq!(validate.json.as_ref().unwrap().get("ok"), Some(&Json::Bool(false))); - assert!(validate.errors().contains("does not match the published digest"), "{}", validate.errors()); - let ctx = full_context("windows", "x64"); - let resolve = cli(&args(&["resolve", "--catalog", &s(&dir)], &ctx).into_iter().chain(["git"]).collect::>()); - assert_eq!(resolve.status, 1, "{}", resolve.stdout); - assert_eq!(resolve.error_reason().as_deref(), Some("integrity")); - let error = resolve.json.as_ref().unwrap().get("error").unwrap(); - assert_eq!(error.get("code").and_then(Json::as_str), Some("backend_error")); - let inspect = cli(&["inspect", "--catalog", &s(&dir)]); - assert_eq!(inspect.status, 1); - assert_eq!(inspect.error_reason().as_deref(), Some("integrity")); - // Library API. - let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); - assert_eq!(catalog.list_catalog_entries().unwrap_err().reason(), ErrorReason::Integrity); -} - -#[test] -fn missing_revision_file_is_integrity() { - let dir = copy_bundled(&work_dir("missing")); - for entry in std::fs::read_dir(dir.join("revisions")).unwrap() { - std::fs::remove_file(entry.unwrap().path()).unwrap(); - } - let inspect = cli(&["inspect", "--catalog", &s(&dir)]); - assert_eq!(inspect.status, 1); - assert_eq!(inspect.error_reason().as_deref(), Some("integrity")); - assert!(cli(&["validate", "--catalog", &s(&dir)]).errors().contains("could not be read")); -} - -// --------------------------------------------------------------------------- -// Unavailable revision -// --------------------------------------------------------------------------- - -#[test] -fn unavailable_revision_is_an_error_never_a_substitution() { - let ctx = full_context("linux", "x64"); - let r = cli(&args(&["resolve", "--revision", "2099-01-01.1"], &ctx).into_iter().chain(["git"]).collect::>()); - assert_eq!(r.status, 1); - assert_eq!(r.error_reason().as_deref(), Some("revision_unavailable")); - let message = r.json.as_ref().unwrap().get("error").unwrap().get("message").and_then(Json::as_str).unwrap(); - assert_eq!(message, "[backend_error] catalog revision '2099-01-01.1' is not installed"); - let err = mxc_policy_catalog::resolve_sandbox_policy("git", &ResolveContext::new().catalog_revision("1999-01-01.1")).unwrap_err(); - assert_eq!(err.reason(), ErrorReason::RevisionUnavailable); - - // An older installed revision stays addressable. - let r1 = revision(vec![entry("tool:a", "")], "2000-01-01.1"); - let r2 = revision( - vec![entry( - "tool:a", - r#"{"entryRevision":2,"platformVariants":[{"when":{"platform":"linux"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}/v2"]}}}]}"#, - )], - "2000-01-02.1", - ); - let dir = write_catalog(&work_dir("revisions"), &[r1, r2]); - assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); - let base = ["resolve", "--catalog", &s(&dir), "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", "git_prefix=/opt"]; - let latest = cli(&[&base[..], &["a"]].concat()); - assert_eq!(latest.json.unwrap().get("filesystem").unwrap(), &j(r#"{"readonlyPaths":["/opt/v2"]}"#)); - let older = cli(&[&base[..], &["--revision", "2000-01-01.1", "a"]].concat()); - assert_eq!(older.json.unwrap().get("filesystem").unwrap(), &j(r#"{"readonlyPaths":["/opt/a"]}"#)); - let missing = cli(&[&base[..], &["--revision", "2000-01-03.1", "a"]].concat()); - assert_eq!(missing.error_reason().as_deref(), Some("revision_unavailable")); -} - -// --------------------------------------------------------------------------- -// Weak identity opt-in -// --------------------------------------------------------------------------- - -#[test] -fn weak_identity_requires_opt_in() { - let symbols = [ - "--platform", "linux", "--architecture", "x64", "--project-root", "/w", "--symbol", "git_prefix=/usr/bin", - ]; - let off = cli(&[&["resolve", "--diagnostics"], &symbols[..], &["git"]].concat()); - assert_eq!(off.status, 0); - assert!(off.json.as_ref().unwrap().get("policy").is_none()); - assert!(off.warnings().contains( - "input 0 ('git') matched no eligible catalog entry: tool:git matched only by invocation name and allowWeakIdentityFallback is not enabled" - )); - assert_eq!(cli(&[&["resolve"], &symbols[..], &["git"]].concat()).stdout, "null\n"); - let on = cli(&[&["resolve", "--diagnostics", "--allow-weak"], &symbols[..], &["git"]].concat()); - assert_eq!(on.status, 0); - assert!(on.json.as_ref().unwrap().get("policy").is_some()); - assert!(on.warnings().contains("input 0 ('git') matched tool:git only by invocation name (weak identity)")); - // Strong purl needs no opt-in and is range-checked. - let strong = cli(&[ - "resolve", "--diagnostics", "--platform", "linux", "--architecture", "x64", "--project-root", "/w", "--symbol", - "npm_prefix=/n", "--symbol", "npm_cache=/c", "--symbol", "node_prefix=/n", "--purl", "pkg:npm/npm@11.0.0", "npm", - ]); - assert_eq!(strong.status, 0, "{}", strong.stderr); - assert!(strong.json.as_ref().unwrap().get("policy").is_some()); - assert!(!strong.warnings().contains("weak identity")); - // Library API: string and object inputs obey the option identically. - let catalog = PolicyCatalog::new(mxc_policy_catalog::bundled_catalog_store().unwrap()); - let ctx = ResolveContext::new() - .platform(Platform::Linux) - .architecture(Architecture::X64) - .project_root("/p") - .symbol("git_prefix", "/usr/bin"); - assert_eq!(catalog.resolve_sandbox_policy("git", &ctx).unwrap(), None); - assert_eq!(catalog.resolve_sandbox_policy(ToolCandidate::new("git"), &ctx).unwrap(), None); - let on = ctx.allow_weak(true); - assert!(catalog.resolve_sandbox_policy("git", &on).unwrap().is_some()); -} - -// --------------------------------------------------------------------------- -// Architecture selection -// --------------------------------------------------------------------------- - -#[test] -fn arch_mismatch_is_no_match_with_skipped_reason() { - let dir = write_catalog( - &work_dir("arch"), - &[revision( - vec![entry( - "tool:a", - r#"{"platformVariants":[ - {"when":{"platform":"windows","architecture":"arm64"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}\\arm64"]}}}, - {"when":{"platform":"windows","architecture":"x64"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}\\x64"]}}}, - {"when":{"platform":"linux","architecture":"x64"},"sandboxPolicy":{"version":"0.9.0-alpha","filesystem":{"readonlyPaths":["${git_prefix}/x64"]}}}]}"#, - )], - "2000-01-01.1", - )], - ); - for arch in ["x64", "arm64"] { - let r = cli(&[ - "resolve", "--catalog", &s(&dir), "--diagnostics", "--allow-weak", "--platform", "windows", - "--architecture", arch, "--symbol", "git_prefix=C:\\t", "a", - ]); - assert_eq!(r.status, 0, "{}", r.stderr); - let paths = r.json.as_ref().unwrap().get("policy").unwrap().get("filesystem").unwrap().get("readonlyPaths").unwrap().clone(); - assert_eq!(paths, Json::Array(vec![Json::from(format!("C:\\t\\{arch}"))])); - assert!(!r.warnings().contains("architecture-neutral")); - } - let no_arm = cli(&[ - "resolve", "--catalog", &s(&dir), "--diagnostics", "--allow-weak", "--platform", "linux", "--architecture", - "arm64", "--symbol", "git_prefix=/t", "a", - ]); - assert_eq!(no_arm.status, 0); - assert!(no_arm.json.as_ref().unwrap().get("policy").is_none()); - assert!(no_arm.warnings().contains("input 0 ('a') matched no eligible catalog entry: tool:a has no variant for linux/arm64")); -} - -#[test] -fn host_default_architecture_and_neutral_fallback_warnings() { - for platform in [Platform::Windows, Platform::Linux, Platform::Macos] { - for arch in [Architecture::X64, Architecture::Arm64] { - // Library with an injected native architecture. - let catalog = PolicyCatalog::with_host( - mxc_policy_catalog::bundled_catalog_store().unwrap(), - Arc::new(FixedHost::new(platform, arch)), - ); - let root = if platform == Platform::Windows { "C:\\w" } else { "/w" }; - let prefix = if platform == Platform::Windows { "C:\\g" } else { "/g" }; - let ctx = ResolveContext::new().allow_weak(true).project_root(root).symbol("git_prefix", prefix); - let omitted = catalog.resolve_sandbox_policy_with_diagnostics("git", &ctx).unwrap(); - let w = omitted.diagnostics.warnings.join("\n"); - assert!(w.contains(&format!( - "architecture was not specified; variants were selected for the native system architecture '{arch}'; the tool's architecture was not verified" - )), "{w}"); - assert!(w.contains(&format!("tool:git uses its architecture-neutral {platform} variant; no {arch}-specific variant exists"))); - let explicit = catalog - .resolve_sandbox_policy_with_diagnostics("git", &ctx.clone().platform(platform).architecture(arch)) - .unwrap(); - assert!(!explicit.diagnostics.warnings.join("\n").contains("was not verified")); - assert_eq!(explicit.policy, omitted.policy); - - // Packaged CLI: explicit architecture warns about neutral fallback only. - let r = cli(&args(&["resolve", "--diagnostics"], &full_context(platform.as_str(), arch.as_str())) - .into_iter() - .chain(["git"]) - .collect::>()); - assert!(r.warnings().contains(&format!("no {arch}-specific variant exists"))); - assert!(!r.warnings().contains("was not verified")); - } - // Packaged CLI on this real host: native detection is used and reported. - let r = cli(&args(&["resolve", "--diagnostics"], &full_context(platform.as_str(), "")).into_iter().chain(["git"]).collect::>()); - assert_eq!(r.status, 0, "{}", r.stdout); - assert!(r.warnings().contains("the tool's architecture was not verified"), "{}", r.warnings()); - } - let nothing = cli(&["resolve", "--diagnostics", "--platform", "linux", "cargo"]); - assert!(!nothing.warnings().contains("architecture")); -} - -// --------------------------------------------------------------------------- -// Multi-match -// --------------------------------------------------------------------------- - -#[test] -fn one_input_matching_several_entries_warns_and_composes_all() { - let dir = write_catalog( - &work_dir("multi"), - &[revision( - vec![ - entry("tool:app", r#"{"identity":[{"kind":"purl","value":"pkg:npm/app"},{"kind":"invocation-name","names":["app"]}]}"#), - entry("tool:app-plugin", r#"{"identity":[{"kind":"invocation-name","names":["app"]}]}"#), - ], - "2000-01-01.1", - )], - ); - let base = ["resolve", "--catalog", &s(&dir), "--diagnostics", "--platform", "linux", "--architecture", "x64", "--symbol", "git_prefix=/opt"]; - let r = cli(&[&base[..], &["--allow-weak", "app"]].concat()); - assert_eq!(r.status, 0, "{}", r.stderr); - assert!(r.warnings().contains("input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute")); - assert_eq!( - r.json.as_ref().unwrap().get("policy").unwrap().get("filesystem").unwrap().get("readonlyPaths").unwrap(), - &j(r#"["/opt/app","/opt/app-plugin"]"#) - ); - let strong_only = cli(&[&base[..], &["--purl", "pkg:npm/app", "app"]].concat()); - assert!(!strong_only.warnings().contains("matched 2 entries")); - // Library API. - let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); - let res = catalog - .resolve_sandbox_policy_with_diagnostics("app", &ResolveContext::new().allow_weak(true).architecture(Architecture::X64).symbol("git_prefix", "/opt")) - .unwrap(); - assert_eq!(res.diagnostics.tools[0].matches.len(), 2); -} - -// --------------------------------------------------------------------------- -// Composition conflict -// --------------------------------------------------------------------------- - -#[test] -fn composition_conflicts_are_errors_not_choices() { - let r = cli(&[ - "resolve", "--platform", "linux", "--architecture", "x64", "--allow-weak", "--project-root", "/opt", "--symbol", - "git_prefix=/usr/bin", "--symbol", "node_prefix=/opt/node", "git", "node", - ]); - assert_eq!(r.status, 1, "{}", r.stdout); - assert_eq!(r.error_reason().as_deref(), Some("composition_conflict")); - let message = r.json.as_ref().unwrap().get("error").unwrap().get("message").and_then(Json::as_str).unwrap().to_string(); - assert!(message.contains("overlap across access classes"), "{message}"); - - let dir = write_catalog( - &work_dir("compose"), - &[revision( - vec![ - entry("tool:a", ""), - entry( - "tool:b", - r#"{"platformVariants":[{"when":{"platform":"linux"},"sandboxPolicy":{"version":"0.9.0-alpha","network":{"egress":{"default":"deny"}}}}]}"#, - ), - ], - "2000-01-01.1", - )], - ); - assert_eq!(cli(&["validate", "--catalog", &s(&dir)]).status, 0); - let both = cli(&[ - "resolve", "--catalog", &s(&dir), "--platform", "linux", "--architecture", "x64", "--allow-weak", "--symbol", - "git_prefix=/opt", "a", "b", - ]); - assert_eq!(both.status, 1); - assert_eq!(both.error_reason().as_deref(), Some("composition_conflict")); - // Library API. - let catalog = catalog_at(&dir, Platform::Linux, Architecture::X64); - let err = catalog - .resolve_sandbox_policy(vec!["a", "b"], &ResolveContext::new().allow_weak(true).symbol("git_prefix", "/opt")) - .unwrap_err(); - assert_eq!(err.reason(), ErrorReason::CompositionConflict); -} - -// --------------------------------------------------------------------------- -// CLI surface -// --------------------------------------------------------------------------- - -#[test] -fn cli_usage_errors_and_inspect() { - for (argv, message) in [ - (vec![], "missing command"), - (vec!["frob"], "unknown command 'frob'"), - (vec!["inspect", "x"], "inspect: unexpected argument 'x'"), - (vec!["resolve", "--base-ref", "HEAD"], "unknown option '--base-ref'"), - (vec!["resolve", "--symbol", "=x", "a"], "--symbol expects name=value"), - (vec!["resolve", "--platform", "--allow-weak"], "--platform requires a value"), - (vec!["resolve", "--purl", "pkg:npm/x"], "--purl/--detected-version must precede a tool name"), - ] { - let r = cli(&argv); - assert_eq!(r.status, 2, "{argv:?}"); - assert_eq!(r.stdout, ""); - assert_eq!( - r.stderr, - format!("policy-catalog: {message}\nusage: policy-catalog [options]\n") - ); - } - let inspect = cli(&["inspect"]); - assert_eq!(inspect.status, 0); - let json = inspect.json.unwrap(); - assert_eq!(json.get("info").unwrap(), &mxc_policy_catalog::get_catalog_info().unwrap().to_json()); - assert_eq!(json.get("entries").unwrap().as_array().unwrap().len(), mxc_policy_catalog::list_catalog_entries().unwrap().len()); - let validate = cli(&["validate"]); - assert_eq!(validate.status, 0, "{}", validate.stdout); -} diff --git a/policy-catalog/rust/rust-toolchain.toml b/policy-catalog/rust/rust-toolchain.toml deleted file mode 100644 index d71ec23cc..000000000 --- a/policy-catalog/rust/rust-toolchain.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -[toolchain] -channel = "1.93" -components = ["clippy", "rustfmt"] diff --git a/policy-catalog/rust/rustfmt.toml b/policy-catalog/rust/rustfmt.toml deleted file mode 100644 index c497f14c2..000000000 --- a/policy-catalog/rust/rustfmt.toml +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -max_width = 120 diff --git a/policy-catalog/rust/scripts/sync-catalog.mjs b/policy-catalog/rust/scripts/sync-catalog.mjs deleted file mode 100644 index 479765ffb..000000000 --- a/policy-catalog/rust/scripts/sync-catalog.mjs +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Copies the catalog source of truth (../../catalog, i.e. policy-catalog/catalog) -// into the crate-local rust/catalog/ so `cargo package` can ship it inside -// the .crate. rust/catalog/ is git-ignored and listed in Cargo.toml `include`. -// The repository build never reads it: build.rs embeds ../catalog directly -// whenever it is present and the crate is not a packaged copy. -// -// node scripts/sync-catalog.mjs -import { cpSync, existsSync, rmSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -const source = fileURLToPath(new URL('../../catalog/', import.meta.url)); -const target = fileURLToPath(new URL('../catalog/', import.meta.url)); - -if (!existsSync(`${source}manifest.json`)) { - console.error(`sync-catalog: ${source} has no manifest.json`); - process.exit(1); -} -rmSync(target, { recursive: true, force: true }); -cpSync(source, target, { - recursive: true, - filter: path => !/\.(md|txt)$/i.test(path), -}); -console.log(`sync-catalog: copied ${source} -> ${target}`); diff --git a/policy-catalog/scripts/catalog-digest.mjs b/policy-catalog/scripts/catalog-digest.mjs deleted file mode 100644 index 39beac7db..000000000 --- a/policy-catalog/scripts/catalog-digest.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Prints the canonical SHA-256 digest of a catalog revision file, for use in -// catalog/manifest.json when publishing a NEW revision. Never recompute the -// digest of an already-published revision; publish a new revision instead. -// -// Usage: node scripts/catalog-digest.mjs ../catalog/revisions/.json -import { readFileSync } from 'node:fs'; -import { canonicalSha256 } from '../dist/tooling.js'; - -const file = process.argv[2]; -if (!file) { - console.error('usage: node scripts/catalog-digest.mjs '); - process.exit(2); -} -console.log(canonicalSha256(JSON.parse(readFileSync(file, 'utf8')))); diff --git a/policy-catalog/scripts/check-extractable.mjs b/policy-catalog/scripts/check-extractable.mjs deleted file mode 100644 index 0d7ad596f..000000000 --- a/policy-catalog/scripts/check-extractable.mjs +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Extractability gate: copies the git-tracked contents of this directory -// (what `git filter-repo --subdirectory-filter policy-catalog` would keep) -// into a fresh temporary git repository and runs the full check there. It -// fails if anything reaches outside the directory: imports, relative links, -// scripts, or an undeclared dependency on the enclosing checkout. -// -// npm run check:extract # uses committed files (HEAD) -// npm run check:extract -- --worktree # uses tracked files as they are on disk -import { execFileSync } from 'node:child_process'; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const packageDir = fileURLToPath(new URL('..', import.meta.url)); -const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); -const repoRoot = git(['rev-parse', '--show-toplevel'], packageDir).trim(); -const prefix = relative(repoRoot, packageDir).replaceAll('\\', '/'); -const useWorktree = process.argv.includes('--worktree'); - -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-extract-')); -const target = join(work, 'repo'); -try { - if (useWorktree) { - // Tracked plus untracked-but-not-ignored: exactly what the next commit holds. - const files = git(['ls-files', '-co', '--exclude-standard', '-z', '--', '.'], packageDir).split('\0').filter(Boolean); - for (const file of files) { - const destination = join(target, file); - mkdirSync(dirname(destination), { recursive: true }); - copyFileSync(join(packageDir, file), destination); - } - console.log(`Copied ${files.length} files from the working tree.`); - } else { - // `git archive HEAD:` yields exactly the subdirectory-filtered tree. - const tar = join(work, 'tree.tar'); - git(['archive', '--format=tar', '-o', tar, `HEAD:${prefix}`], repoRoot); - mkdirSync(target); - // Inbox bsdtar on Windows; a GNU tar earlier on PATH misreads `C:/...`. - const tarCmd = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; - execFileSync(tarCmd, ['-xf', tar, '-C', target], { stdio: 'inherit' }); - console.log(`Extracted HEAD:${prefix}.`); - } - - // A fresh repository, so git-dependent checks run against the extracted root. - git(['init', '-q'], target); - git(['-c', 'user.name=extract-check', '-c', 'user.email=extract-check@invalid', 'add', '-A'], target); - git(['-c', 'user.name=extract-check', '-c', 'user.email=extract-check@invalid', 'commit', '-q', '-m', 'extract'], target); - - // Relative Markdown links must resolve inside the extracted tree. - const brokenLinks = []; - // docs/design.md is a verbatim copy of the MXC proposal. Its relative links - // point into the MXC repository and are deliberately not rewritten; each - // one must instead have an absolute equivalent listed in the file's header. - const VERBATIM = 'docs/design.md'; - const VERBATIM_MARKER = ''; - for (const file of git(['ls-files', '*.md'], target).split('\n').filter(Boolean)) { - const raw = readFileSync(join(target, file), 'utf8'); - const markerAt = file === VERBATIM ? raw.indexOf(VERBATIM_MARKER) : -1; - if (file === VERBATIM && markerAt < 0) { - brokenLinks.push(`${file}: missing '${VERBATIM_MARKER}'`); - continue; - } - const header = markerAt >= 0 ? raw.slice(0, markerAt) : ''; - const text = raw.replace(/```[\s\S]*?```/g, ''); - for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) { - const link = match[1].split('#')[0]; - if (!link || /^[a-z][a-z0-9+.-]*:/i.test(link)) continue; - if (markerAt >= 0) { - if (!header.includes(`- ${link} -> https://`)) { - brokenLinks.push(`${file}: MXC-relative link '${match[1]}' has no absolute equivalent in the header`); - } - continue; - } - const resolved = resolve(dirname(join(target, file)), link); - if (relative(target, resolved).startsWith('..')) { - brokenLinks.push(`${file}: '${match[1]}' leaves the repository`); - continue; - } - try { - readFileSync(resolved); - } catch (error) { - if (error.code === 'EISDIR') continue; - brokenLinks.push(`${file}: '${match[1]}' does not exist`); - } - } - } - if (brokenLinks.length > 0) { - console.error('Extractability check FAILED (links):'); - for (const link of brokenLinks) console.error(` - ${link}`); - process.exitCode = 1; - } else { - const npmCli = process.env.npm_execpath; - if (!npmCli) throw new Error('run this script through `npm run check:extract`'); - const npm = args => execFileSync(process.execPath, [npmCli, ...args], { cwd: target, stdio: 'inherit' }); - npm(['ci', '--no-audit', '--no-fund']); - // `check` includes typecheck, unit, functional (from the packed tarball), - // validation, pack contents, and the install smoke test. - npm(['run', 'check']); - // The Rust and .NET libraries, their packaged-artifact functional tests, - // and the cross-language check must also work from the extracted tree. - const run = (file, args, cwd = target) => execFileSync(file, args, { cwd, stdio: 'inherit' }); - const rustDir = join(target, 'rust'); - run('cargo', ['fmt', '--check'], rustDir); - run('cargo', ['clippy', '--locked', '--all-targets', '--all-features', '--', '-D', 'warnings'], rustDir); - run('cargo', ['test', '--locked'], rustDir); - run(process.execPath, ['scripts/rust-functional.mjs']); - const dotnetDir = join(target, 'dotnet'); - run('dotnet', ['build', 'Microsoft.Mxc.PolicyCatalog.slnx', '-c', 'Release'], dotnetDir); - run('dotnet', ['test', '--solution', 'Microsoft.Mxc.PolicyCatalog.slnx', '-c', 'Release', '--no-build'], dotnetDir); - run(process.execPath, ['scripts/dotnet-functional.mjs']); - run(process.execPath, ['scripts/cross-language-check.mjs']); - console.log('\nExtractability check OK: the directory builds, tests, validates, and packs as a standalone repository (TypeScript, Rust, .NET, cross-language).'); - } -} finally { - rmSync(work, { recursive: true, force: true }); -} diff --git a/policy-catalog/scripts/check-pack.mjs b/policy-catalog/scripts/check-pack.mjs deleted file mode 100644 index 2b38f9f79..000000000 --- a/policy-catalog/scripts/check-pack.mjs +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Package inclusion gate (design §7): `npm pack --dry-run` must ship the -// compiled library, the CLI, the bundled catalog, and the schemas, and must not -// ship sources, tests, fixtures, or build-only scripts. -import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -const root = fileURLToPath(new URL('..', import.meta.url)); -const npmCli = process.env.npm_execpath; -if (!npmCli) { - console.error('run this script through `npm run check:pack`'); - process.exit(2); -} -const [packed] = JSON.parse(execFileSync(process.execPath, [npmCli, 'pack', '--dry-run', '--json'], { cwd: root, encoding: 'utf8' })); -const files = packed.files.map(file => file.path.replaceAll('\\', '/')).sort(); - -const manifest = JSON.parse(readFileSync(new URL('../catalog/manifest.json', import.meta.url), 'utf8')); -const required = [ - 'LICENSE.md', - 'README.md', - 'package.json', - 'dist/index.js', - 'dist/index.d.ts', - 'dist/tooling.js', - 'dist/tooling.d.ts', - 'dist/cli.js', - 'dist/validate.js', - 'catalog/contract.v1.json', - 'catalog/manifest.json', - ...manifest.revisions.map(revision => `catalog/${revision.file}`), - 'schema/catalog.v1.schema.json', - 'schema/manifest.v1.schema.json', -]; -const forbidden = [/^src\//, /^tests\//, /^conformance\//, /^scripts\//, /^docs\//, /^\.github\//, /^node_modules\//, /\.test\.js$/]; - -const errors = [ - ...required.filter(file => !files.includes(file)).map(file => `missing ${file}`), - ...files.filter(file => forbidden.some(pattern => pattern.test(file))).map(file => `unexpected ${file}`), -]; -if (errors.length > 0) { - console.error('Package contents check FAILED:'); - for (const error of errors) console.error(` - ${error}`); - process.exit(1); -} -console.log(`Package contents check OK: ${files.length} files, ${packed.size} bytes packed (${packed.name}@${packed.version}).`); -for (const file of files) console.log(` ${file}`); diff --git a/policy-catalog/scripts/check-tests-present.mjs b/policy-catalog/scripts/check-tests-present.mjs deleted file mode 100644 index 993372cf4..000000000 --- a/policy-catalog/scripts/check-tests-present.mjs +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Fails when a test directory holds no *.test.js files. `node --test ` -// exits 0 when it finds nothing, so a renamed or empty test directory would -// otherwise pass silently. Mirrors MXC's scripts/versioning/check-tests-present.js. -// -// node scripts/check-tests-present.mjs -import { existsSync, readdirSync } from 'node:fs'; - -const dir = process.argv[2]; -if (!dir || !existsSync(dir)) { - console.error(`Test presence check FAILED: '${dir ?? '(none)'}' does not exist.`); - process.exit(1); -} -// Only regular files count; a directory named `x.test.js` runs nothing. -const files = readdirSync(dir, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.test.js')); -if (files.length === 0) { - console.error(`Test presence check FAILED: no *.test.js files in '${dir}'.`); - process.exit(1); -} -console.log(`Test presence check OK: ${files.length} test file(s) in ${dir}.`); diff --git a/policy-catalog/scripts/ci-ensure-rustup.mjs b/policy-catalog/scripts/ci-ensure-rustup.mjs deleted file mode 100644 index 58ba32735..000000000 --- a/policy-catalog/scripts/ci-ensure-rustup.mjs +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// CI helper: makes sure `rustup` and the toolchain pinned by -// rust/rust-toolchain.toml are available. Most GitHub-hosted images ship -// rustup; some (for example windows-11-arm) do not, and then this installs it -// from the official rustup-init for the runner's architecture. Local -// developers install Rust themselves; this script only prints what it finds -// when rustup is already present. -import { execFileSync, spawnSync } from 'node:child_process'; -import { appendFileSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { homedir, machine, tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const rustDir = fileURLToPath(new URL('../rust/', import.meta.url)); -const has = cmd => spawnSync(cmd, ['--version'], { stdio: 'ignore' }).status === 0; - -if (!has('rustup')) { - if (!process.env.CI) { - console.error('rustup is not installed; install Rust from https://rustup.rs and rerun.'); - process.exit(1); - } - const arch = machine() === 'arm64' || machine() === 'aarch64' ? 'aarch64' : 'x86_64'; - const target = process.platform === 'win32' ? `${arch}-pc-windows-msvc` - : process.platform === 'darwin' ? `${arch}-apple-darwin` : `${arch}-unknown-linux-gnu`; - const exe = process.platform === 'win32' ? '.exe' : ''; - const url = `https://static.rust-lang.org/rustup/dist/${target}/rustup-init${exe}`; - console.log(`rustup not found; installing from ${url}`); - const response = await fetch(url); - if (!response.ok) throw new Error(`download failed: ${response.status}`); - const init = join(mkdtempSync(join(tmpdir(), 'rustup-')), `rustup-init${exe}`); - writeFileSync(init, Buffer.from(await response.arrayBuffer()), { mode: 0o755 }); - execFileSync(init, ['-y', '--profile', 'minimal', '--default-toolchain', 'none', '--no-modify-path'], { stdio: 'inherit' }); - const bin = join(process.env.CARGO_HOME ?? join(homedir(), '.cargo'), 'bin'); - if (process.env.GITHUB_PATH) appendFileSync(process.env.GITHUB_PATH, `${bin}\n`); - process.env.PATH = `${bin}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`; -} - -// Resolving the active toolchain in rust/ installs the pinned channel and its -// components (rustup >= 1.28 no longer installs it implicitly on `show`). -execFileSync('rustup', ['toolchain', 'install'], { cwd: rustDir, stdio: 'inherit' }); -execFileSync('rustc', ['-vV'], { cwd: rustDir, stdio: 'inherit' }); diff --git a/policy-catalog/scripts/clean.mjs b/policy-catalog/scripts/clean.mjs deleted file mode 100644 index fd17d3cdb..000000000 --- a/policy-catalog/scripts/clean.mjs +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Removes build output owned by this package only. -import { rmSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -for (const dir of ['dist', 'tests/unit/dist', 'tests/functional/dist']) { - rmSync(fileURLToPath(new URL(`../${dir}/`, import.meta.url)), { recursive: true, force: true }); -} diff --git a/policy-catalog/scripts/cross-language-check.mjs b/policy-catalog/scripts/cross-language-check.mjs deleted file mode 100644 index ec5238a0c..000000000 --- a/policy-catalog/scripts/cross-language-check.mjs +++ /dev/null @@ -1,319 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Cross-language check: identical inputs must give byte-identical normalized -// JSON output from the TypeScript, Rust, and C# `policy-catalog` CLIs, for -// resolve, inspect, validate, and the error cases. -// -// npm run build -// node scripts/cross-language-check.mjs [--no-build] [--only ts,rust,dotnet] -// -// By default the Rust and .NET CLIs are built from rust/ and dotnet/ first -// (release configuration, their own output directories). Override the -// commands with POLICY_CATALOG_RUST_CLI / POLICY_CATALOG_DOTNET_CLI (a path to -// an executable, or to a .dll that is run with `dotnet`). -// -// Normalization, applied identically to every language: -// - stdout is parsed as JSON and re-serialized with object keys sorted -// (key order is not part of the contract; everything else is); -// - absolute paths of the temporary catalog directories are replaced with -// placeholders; -// - OS I/O error text after "could not be read: " is truncated, because it -// comes from each runtime's file API; -// - `validate` without --catalog reports where each package installed its -// bundled catalog (npm package dir, Rust source tree, .NET output dir), so -// that `catalogDir` must end in `catalog` and is then replaced with -// `/catalog`; -// - for usage errors (exit 2) stdout must be empty and stderr must end with -// the shared usage line. -// Exit code must match exactly. -import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { canonicalSha256 } from '../dist/canonical-json.js'; - -const root = fileURLToPath(new URL('..', import.meta.url)); -const args = process.argv.slice(2); -const noBuild = args.includes('--no-build'); -const onlyAt = args.indexOf('--only'); -const only = onlyAt >= 0 ? new Set(args[onlyAt + 1].split(',')) : new Set(['ts', 'rust', 'dotnet']); -const exe = process.platform === 'win32' ? '.exe' : ''; - -function run(file, argv, cwd = root) { - execFileSync(file, argv, { cwd, stdio: 'inherit' }); -} - -/** Language -> function(args) => { status, stdout, stderr }. */ -const clis = {}; -if (only.has('ts')) { - clis.ts = [process.execPath, join(root, 'dist', 'cli.js')]; -} -if (only.has('rust')) { - let cli = process.env.POLICY_CATALOG_RUST_CLI; - if (!cli) { - const targetDir = join(root, 'rust', 'target'); - if (!noBuild) { - run('cargo', ['build', '--release', '--locked', '--bin', 'policy-catalog', '--target-dir', targetDir], join(root, 'rust')); - } - cli = join(targetDir, 'release', `policy-catalog${exe}`); - } - clis.rust = cli.endsWith('.dll') ? ['dotnet', cli] : [cli]; -} -if (only.has('dotnet')) { - let cli = process.env.POLICY_CATALOG_DOTNET_CLI; - if (!cli) { - const project = join(root, 'dotnet', 'Microsoft.Mxc.PolicyCatalog.Cli'); - if (!noBuild) { - run('dotnet', ['build', join(project, 'Microsoft.Mxc.PolicyCatalog.Cli.csproj'), '-c', 'Release', '--nologo', '-v', 'quiet'], root); - } - cli = join(project, 'bin', 'Release', 'net8.0', 'policy-catalog.dll'); - } - clis.dotnet = cli.endsWith('.dll') ? ['dotnet', cli] : [cli]; -} -for (const [lang, [cmd]] of Object.entries(clis)) { - if (cmd !== 'dotnet' && !existsSync(cmd)) { - console.error(`${lang}: CLI not found at ${cmd}`); - process.exit(2); - } -} - -// --------------------------------------------------------------------------- -// Synthetic catalogs (written once, shared by every language) -// --------------------------------------------------------------------------- - -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-xlang-')); -const placeholders = new Map(); -const contract = readFileSync(join(root, 'catalog', 'contract.v1.json'), 'utf8'); - -function writeCatalog(name, revisions, { tamper, dropFile } = {}) { - const dir = join(work, name); - const files = {}; - const manifest = { - catalogSchemaVersion: '1', - defaultRevision: revisions[revisions.length - 1].catalogRevision, - revisions: revisions.map(r => { - const file = `revisions/${r.catalogRevision}.json`; - files[file] = r; - return { catalogRevision: r.catalogRevision, file, sha256: canonicalSha256(r) }; - }), - }; - mkdirSync(join(dir, 'revisions'), { recursive: true }); - writeFileSync(join(dir, 'contract.v1.json'), contract); - writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - for (const [file, value] of Object.entries(files)) { - if (file === dropFile) continue; - const body = tamper ? tamper(structuredClone(value)) : value; - writeFileSync(join(dir, file), `${JSON.stringify(body, null, 2)}\n`); - } - placeholders.set(dir, `<${name}>`); - // Runtimes may report the same directory in another spelling (on Windows, - // an 8.3 short temp path such as RUNNER~1 versus its long form). - const real = realpathSync.native(dir); - if (real !== dir) placeholders.set(real, `<${name}>`); - return dir; -} - -const entry = (entryId, overrides = {}) => { - const name = entryId.split(':')[1]; - return { - entryId, - entryRevision: 1, - displayName: name, - identity: [{ kind: 'invocation-name', names: [name] }], - platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${name}`] } } }], - provenance: { method: 'xlang', sourceRevision: 'xlang' }, - ...overrides, - }; -}; -const revision = (entries, catalogRevision = '2000-01-01.1') => ({ catalogSchemaVersion: '1', catalogRevision, entries }); -const dependsOn = (id, dep) => entry(id, { - platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: dep }], sandboxPolicy: { version: '0.9.0-alpha' } }], -}); - -const bundledRevision = JSON.parse(readFileSync(join(root, 'catalog', 'revisions', '2026-09-29.1.json'), 'utf8')); -const good = writeCatalog('good', [revision([entry('tool:a'), entry('tool:b')]), revision([entry('tool:a', { entryRevision: 2, displayName: 'a2' }), entry('tool:b')], '2000-01-02.1')]); -const tampered = writeCatalog('tampered', [bundledRevision], { tamper: r => { r.entries[0].displayName = 'Tampered'; return r; } }); -const missing = writeCatalog('missing', [bundledRevision], { dropFile: 'revisions/2026-09-29.1.json' }); -const cyclic = writeCatalog('cyclic', [revision([dependsOn('tool:a', 'tool:b'), dependsOn('tool:b', 'tool:c'), dependsOn('tool:c', 'tool:a')])]); -const multi = writeCatalog('multi', [revision([ - entry('tool:app', { identity: [{ kind: 'purl', value: 'pkg:npm/app', versionRange: '>=2 <3' }, { kind: 'invocation-name', names: ['app'] }] }), - entry('tool:app-plugin', { identity: [{ kind: 'invocation-name', names: ['app'] }] }), - entry('tool:net', { - identity: [{ kind: 'invocation-name', names: ['net'] }], - platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', network: { egress: { default: 'deny', allow: [{ to: [{ cidr: '192.0.2.0/24' }] }] } } } }], - }), - entry('tool:arm', { - identity: [{ kind: 'invocation-name', names: ['arm'] }], - platformVariants: [ - { when: { platform: 'linux', architecture: 'arm64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/arm64'] } } }, - { when: { platform: 'windows' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}\\neutral'] } } }, - ], - }), -])]); - -// --------------------------------------------------------------------------- -// Cases -// --------------------------------------------------------------------------- - -const syms = { - windows: ['--project-root', 'C:\\work\\app', '--symbol', 'git_prefix=C:\\tools', '--symbol', 'node_prefix=C:\\tools', '--symbol', 'npm_prefix=C:\\tools', '--symbol', 'npm_cache=C:\\cache\\npm'], - linux: ['--project-root', '/work/app', '--symbol', 'git_prefix=/opt/tools', '--symbol', 'node_prefix=/opt/tools', '--symbol', 'npm_prefix=/opt/tools', '--symbol', 'npm_cache=/var/cache/npm'], - macos: ['--project-root', '/Users/dev/app', '--symbol', 'git_prefix=/opt/homebrew/bin', '--symbol', 'node_prefix=/opt/homebrew/bin', '--symbol', 'npm_prefix=/opt/homebrew/bin', '--symbol', 'npm_cache=/Users/dev/.npm'], -}; -const cases = [ - ['inspect'], - ['inspect', '--catalog', good], - ['validate'], - ['validate', '--catalog', good], - ['validate', '--catalog', tampered], - ['validate', '--catalog', missing], - ['validate', '--catalog', cyclic], - ['inspect', '--catalog', tampered], - ['inspect', '--catalog', cyclic], -]; -for (const platform of ['windows', 'linux', 'macos']) { - for (const architecture of ['x64', 'arm64']) { - const ctx = ['--platform', platform, '--architecture', architecture, '--allow-weak', ...syms[platform]]; - cases.push(['resolve', ...ctx, 'git', 'npm', 'node']); - cases.push(['resolve', '--diagnostics', ...ctx, 'git', 'npm', 'node', 'cargo']); - cases.push(['resolve', '--diagnostics', ...ctx, 'GIT', 'Npm.CMD']); - } -} -const linux = ['--platform', 'linux', '--architecture', 'x64']; -cases.push( - // weak opt-in off / on - ['resolve', '--diagnostics', ...linux, ...syms.linux, 'npm'], - ['resolve', ...linux, ...syms.linux, 'npm'], - ['resolve', '--diagnostics', ...linux, '--allow-weak', ...syms.linux, 'npm'], - // strong identity and version-range warnings - ['resolve', '--diagnostics', ...linux, ...syms.linux, '--purl', 'pkg:npm/npm@10.9.0', 'npx'], - ['resolve', '--diagnostics', ...linux, ...syms.linux, '--purl', 'pkg:npm/npm', '--detected-version', 'v11.0.0-rc.1', 'npm'], - // unknown tool, empty input, unresolved symbol - ['resolve', '--diagnostics', ...linux, '--allow-weak', ...syms.linux, 'no-such-tool'], - ['resolve', ...linux], - ['resolve', '--diagnostics', ...linux], - ['resolve', '--diagnostics', ...linux, '--allow-weak', '--symbol', 'git_prefix=/usr/bin', 'git'], - // casing: Linux exact, Windows/macOS fold - ['resolve', '--diagnostics', ...linux, '--allow-weak', ...syms.linux, 'GIT'], - ['resolve', '--diagnostics', '--platform', 'macos', '--architecture', 'arm64', '--allow-weak', '--project-root', '/Tools/w', '--symbol', 'git_prefix=/tools', 'Git'], - ['resolve', '--diagnostics', '--platform', 'windows', '--architecture', 'x64', '--allow-weak', '--project-root', 'c:\\tools', '--symbol', 'git_prefix=C:\\Tools\\', 'git'], - // revisions - ['resolve', '--diagnostics', '--catalog', good, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'a'], - ['resolve', '--diagnostics', '--catalog', good, '--revision', '2000-01-01.1', ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'a'], - ['resolve', ...linux, '--revision', '2099-01-01.1', 'git'], - // integrity and cycles - ['resolve', '--catalog', tampered, ...linux, 'git'], - ['resolve', '--catalog', missing, ...linux, 'git'], - ['resolve', '--catalog', cyclic, ...linux, '--allow-weak', 'a'], - // multi-match, composition conflicts, architecture - ['resolve', '--diagnostics', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'app'], - ['resolve', '--diagnostics', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', '--purl', 'pkg:npm/app@3.1.0', 'app'], - ['resolve', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'net', 'app'], - ['resolve', ...linux, '--allow-weak', '--project-root', '/opt', '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'], - ['resolve', '--diagnostics', '--catalog', multi, ...linux, '--allow-weak', '--symbol', 'git_prefix=/g', 'arm'], - ['resolve', '--diagnostics', '--catalog', multi, '--platform', 'linux', '--architecture', 'arm64', '--allow-weak', '--symbol', 'git_prefix=/g', 'arm'], - ['resolve', '--diagnostics', '--catalog', multi, '--platform', 'windows', '--architecture', 'arm64', '--allow-weak', '--symbol', 'git_prefix=C:\\g', 'arm'], - // invalid context (malformed_request) - ['resolve', '--platform', 'plan9', 'git'], - ['resolve', '--architecture', 'mips', 'git'], - ['resolve', ...linux, '--allow-weak', '--symbol', 'node_prefix=relative', 'node'], - ['resolve', ...linux, '--symbol', 'unknown_symbol=/x', 'git'], - ['resolve', ...linux, '--symbol', '__proto__=/x', 'git'], - ['resolve', ...linux, '--symbol', 'project_root=/x', 'git'], - ['resolve', ...linux, '--purl', 'not-a-purl', 'npm'], - ['resolve', ...linux, '--purl', 'pkg:npm/npm@%E0%A4%A', 'npm'], - ['resolve', ...linux, './bin/git'], - // usage errors - [], - ['bogus'], - ['info'], - ['resolve', '--platform'], - ['resolve', '--symbol', 'noequals', 'git'], - ['resolve', '--purl', 'pkg:npm/npm'], - ['resolve', '--base-ref', 'HEAD', 'git'], - ['resolve', '--nope'], - ['inspect', 'extra'], - ['validate', 'extra'], - ['validate', '--base-ref'], - ['resolve', '--catalog'], -); - -// --------------------------------------------------------------------------- -// Run and compare -// --------------------------------------------------------------------------- - -function sortKeys(value) { - if (Array.isArray(value)) return value.map(sortKeys); - if (value !== null && typeof value === 'object') { - return Object.fromEntries(Object.keys(value).sort().map(key => [key, sortKeys(value[key])])); - } - return value; -} - -function normalizeText(text) { - let out = text; - for (const [dir, name] of placeholders) { - for (const form of [dir, dir.replaceAll('\\', '/'), JSON.stringify(dir).slice(1, -1)]) { - out = out.split(form).join(name); - } - } - return out; -} - -function normalize(result, argv) { - const status = result.status; - if (status === 2) { - const lines = result.stderr.trim().split(/\r?\n/); - return { - status, - stdout: result.stdout, - stderrUsage: lines[lines.length - 1], - stderrMessage: lines[0].startsWith('policy-catalog: ') ? lines[0] : `(bad prefix) ${lines[0]}`, - }; - } - let json; - try { - json = JSON.parse(result.stdout); - } catch { - return { status, stdout: `(not JSON) ${result.stdout}`, stderr: result.stderr }; - } - if (argv[0] === 'validate' && !argv.includes('--catalog') && typeof json?.catalogDir === 'string') { - json.catalogDir = /[\\/]catalog$/.test(json.catalogDir) ? '/catalog' : `(unexpected) ${json.catalogDir}`; - } - const text = normalizeText(JSON.stringify(sortKeys(json), null, 2)) - .replace(/(could not be read: )[^"]*/g, '$1'); - return { status, stdout: text }; -} - -const languages = Object.keys(clis); -let failures = 0; -let compared = 0; -try { - for (const argv of cases) { - const results = {}; - for (const lang of languages) { - const [cmd, ...pre] = clis[lang]; - const raw = spawnSync(cmd, [...pre, ...argv], { cwd: work, encoding: 'utf8' }); - if (raw.error) throw raw.error; - results[lang] = JSON.stringify(normalize(raw, argv), null, 2); - } - compared += 1; - const reference = results[languages[0]]; - const differing = languages.filter(lang => results[lang] !== reference); - const label = normalizeText(argv.join(' ')) || '(no arguments)'; - if (differing.length === 0) { - console.log(`ok ${label}`); - } else { - failures += 1; - console.log(`FAIL ${label}`); - for (const lang of languages) console.log(` --- ${lang}\n${results[lang].replace(/^/gm, ' ')}`); - } - } -} finally { - rmSync(work, { recursive: true, force: true }); -} -console.log(`\ncross-language check: ${compared} cases x ${languages.length} languages (${languages.join(', ')}), ${compared - failures} identical, ${failures} different`); -process.exit(failures === 0 ? 0 : 1); diff --git a/policy-catalog/scripts/dotnet-functional.mjs b/policy-catalog/scripts/dotnet-functional.mjs deleted file mode 100644 index d9ce20ce3..000000000 --- a/policy-catalog/scripts/dotnet-functional.mjs +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// .NET functional test driver. Tests the package a consumer would install, -// not the source tree: -// -// 1. `dotnet pack` Microsoft.Mxc.PolicyCatalog into a local feed in a -// temporary directory outside the repository (os.tmpdir()). -// 2. Copy the CLI (dotnet/functional/policy-catalog-cli-consumer + the CLI -// Program.cs) and the functional test project -// (dotnet/functional/Microsoft.Mxc.PolicyCatalog.FunctionalTests) into -// temporary consumer directories with a generated nuget.config -// (local feed + nuget.org) and an isolated NUGET_PACKAGES directory, so a -// stale cached package can never be used. -// 3. Build the CLI against the package, then build and run the tests with -// POLICY_CATALOG_CLI_DLL pointing at that CLI build. -// -// node scripts/dotnet-functional.mjs (from policy-catalog/) -// -// Exits nonzero on any failure. Set POLICY_CATALOG_KEEP_TEMP=1 to keep the -// temporary directory for inspection. Third-party packages (xunit.v3) come -// from nuget.org; set POLICY_CATALOG_NUGET_UPSTREAM to a nuget.org mirror URL -// where nuget.org is not directly reachable. -import { spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, relative, isAbsolute } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = fileURLToPath(new URL('../', import.meta.url)); -const dotnetDir = join(root, 'dotnet'); -const version = '0.0.0-prototype'; -const upstream = process.env.POLICY_CATALOG_NUGET_UPSTREAM || 'https://api.nuget.org/v3/index.json'; - -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-dotnet-functional-')); -const outside = relative(root, work); -if (!(outside.startsWith('..') || isAbsolute(outside))) { - console.error(`temporary directory ${work} is inside the repository`); - process.exit(1); -} -const feed = join(work, 'feed'); -const packages = join(work, 'nuget-packages'); -mkdirSync(feed); -mkdirSync(packages); - -const env = { - ...process.env, - NUGET_PACKAGES: packages, - DOTNET_CLI_TELEMETRY_OPTOUT: '1', - DOTNET_NOLOGO: '1', - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1', - // Consumers must not inherit the repository's global.json or Directory.* files. - MSBUILDDISABLENODEREUSE: '1', -}; - -function dotnet(args, cwd, extraEnv = {}) { - console.log(`\n> dotnet ${args.join(' ')} (cwd ${cwd})`); - const result = spawnSync('dotnet', args, { cwd, env: { ...env, ...extraEnv }, stdio: 'inherit' }); - if (result.error) { - throw result.error; - } - return result.status ?? 1; -} - -function must(status, what) { - if (status !== 0) { - throw new Error(`${what} failed with exit code ${status}`); - } -} - -const nugetConfig = ` - - - - - - - - - - - - - - - -`; -// The consumers pin the same SDK policy as the repository (MTP test runner). -const globalJson = `${JSON.stringify({ sdk: { version: '10.0.401', rollForward: 'latestFeature' }, test: { runner: 'Microsoft.Testing.Platform' } }, null, 2)}\n`; - -function consumer(name, template) { - const dir = join(work, name); - cpSync(template, dir, { - recursive: true, - filter: source => !/[\\/](bin|obj|TestResults)([\\/]|$)/.test(relative(template, source)), - }); - writeFileSync(join(dir, 'nuget.config'), nugetConfig); - writeFileSync(join(dir, 'global.json'), globalJson); - // Stop MSBuild from importing Directory.Build.* from parent directories. - writeFileSync(join(dir, 'Directory.Build.props'), '\n'); - writeFileSync(join(dir, 'Directory.Build.targets'), '\n'); - return dir; -} - -let status = 0; -try { - // 1. Pack. - must(dotnet(['pack', join(dotnetDir, 'Microsoft.Mxc.PolicyCatalog', 'Microsoft.Mxc.PolicyCatalog.csproj'), '-c', 'Release', '-o', feed, '-p:ContinuousIntegrationBuild=true'], dotnetDir), 'dotnet pack'); - const nupkgs = readdirSync(feed).filter(f => f.endsWith('.nupkg')); - if (!nupkgs.includes(`Microsoft.Mxc.PolicyCatalog.${version}.nupkg`)) { - throw new Error(`expected Microsoft.Mxc.PolicyCatalog.${version}.nupkg in ${feed}, found ${nupkgs.join(', ')}`); - } - console.log(`\nPacked ${nupkgs.join(', ')} into ${feed}`); - - // 2a. CLI consumer, built against the package. - const cliDir = consumer('cli', join(dotnetDir, 'functional', 'policy-catalog-cli-consumer')); - cpSync(join(dotnetDir, 'Microsoft.Mxc.PolicyCatalog.Cli', 'Program.cs'), join(cliDir, 'Program.cs')); - must(dotnet(['build', '-c', 'Release', '-o', join(cliDir, 'out')], cliDir), 'CLI consumer build'); - const cliDll = join(cliDir, 'out', 'policy-catalog.dll'); - if (!existsSync(cliDll) || !existsSync(join(cliDir, 'out', 'catalog', 'manifest.json'))) { - throw new Error(`CLI consumer build did not produce ${cliDll} with its catalog directory`); - } - - // 2b. Functional tests consumer. - const testDir = consumer('tests', join(dotnetDir, 'functional', 'Microsoft.Mxc.PolicyCatalog.FunctionalTests')); - must(dotnet(['build', '-c', 'Release'], testDir), 'functional test build'); - - // 3. Run. xUnit v3 test projects are executables; MTP prints the counts. - status = dotnet(['test', '--project', join(testDir, 'Microsoft.Mxc.PolicyCatalog.FunctionalTests.csproj'), '-c', 'Release', '--no-build'], testDir, { - POLICY_CATALOG_CLI_DLL: cliDll, - POLICY_CATALOG_REPO_ROOT: root, - }); - console.log(status === 0 ? '\n.NET functional tests passed.' : `\n.NET functional tests FAILED (exit ${status}).`); -} catch (error) { - console.error(`\n${error.message}`); - status = 1; -} finally { - if (process.env.POLICY_CATALOG_KEEP_TEMP === '1') { - console.log(`kept ${work}`); - } else { - rmSync(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); - } -} -process.exit(status); diff --git a/policy-catalog/scripts/generate-vectors.mjs b/policy-catalog/scripts/generate-vectors.mjs deleted file mode 100644 index b6edb921f..000000000 --- a/policy-catalog/scripts/generate-vectors.mjs +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Regenerates conformance/vectors/*.json from the TypeScript reference -// implementation (run `npm run build` first). The Rust and C# bindings test -// their path normalization and canonical JSON against these files, so the -// three languages cannot drift apart silently. -// -// npm run build && node scripts/generate-vectors.mjs -// -// Non-ASCII inputs are written with \u escapes so this file stays ASCII. -import { writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { isAbsolutePath, normalizePath, pathKeySegments } from '../dist/catalog.js'; -import { canonicalJson, canonicalSha256 } from '../dist/canonical-json.js'; - -const out = name => fileURLToPath(new URL(`../conformance/vectors/${name}`, import.meta.url)); - -const windows = [ - 'C:\\Tools', 'c:\\tools\\', 'C:\\a\\.\\b\\..\\c', 'C:/a//b/', 'C:\\', 'C:\\\\', 'C:\\..\\x', - '\\\\server\\share\\x\\', '\\\\server\\share', 'D:\\src', 'C:\\Program Files\\Git\\cmd', - 'C:\\a\\..\\..\\b', 'C:\\x\\y\\.', '/usr/bin', '\\rooted\\', 'C:\\\u00c9t\u00e9', 'C:rel', 'rel\\x', '', -]; -const posix = ['/usr/bin', '/usr//bin/', '/a/./b/../c', '/', '//', '///x', '/Users/dev/app/', '/a/../../b', '/x/y/.', '/Tools', '/..', '/\u00c9t\u00e9', 'rel/x', './x', '']; -const row = (input, platform) => ({ - input, - absolute: isAbsolutePath(input, platform), - normalized: input !== '' && isAbsolutePath(input, platform) ? normalizePath(input, platform) : null, - keySegments: pathKeySegments(input, platform), -}); -writeFileSync(out('paths.json'), `${JSON.stringify({ - $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: windows.map(v => row(v, 'windows')), - linux: posix.map(v => row(v, 'linux')), - macos: posix.map(v => row(v, 'macos')), -}, null, 2)}\n`); - -// Each case is raw JSON text so a parser sees the original tokens. -const texts = [ - '{"b":1,"a":[3,2,{"d":null,"c":true}]}', - '{"\\u00e9":1,"z":2,"Z":3,"\\ue000":4,"\\ud83d\\ude00":5,"":6}', - '["tab\\t","nl\\n","q\\"","bs\\\\","ctl\\u0001\\u001f\\u007f","uni \\u00e9 \\ud83d\\ude00","/","\\u2028"]', - '[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]', - '{"nested":{"k":[],"o":{}}}', - '"plain"', - '42', - 'false', - 'null', -]; -writeFileSync(out('canonical-json.json'), `${JSON.stringify({ - $comment: 'Generated by scripts/generate-vectors.mjs. Canonical JSON: object keys sorted by UTF-16 code units, arrays in order, no whitespace, JSON.stringify string escaping, and ECMAScript Number::toString for every number. sha256 is lower-case hex over the UTF-8 canonical text. Parse `json`, canonicalize, and compare both fields.', - cases: texts.map(json => ({ json, canonical: canonicalJson(JSON.parse(json)), sha256: canonicalSha256(JSON.parse(json)) })), -}, null, 2)}\n`); -console.log('wrote conformance/vectors/paths.json and conformance/vectors/canonical-json.json'); diff --git a/policy-catalog/scripts/package-smoke.mjs b/policy-catalog/scripts/package-smoke.mjs deleted file mode 100644 index 513e89cd5..000000000 --- a/policy-catalog/scripts/package-smoke.mjs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Package-level integration smoke test (design §12 "Integration"): packs the -// library, installs the tarball into an empty temporary project with no -// network access and no MXC SDK or executor, and exercises the public API. -import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const packageDir = fileURLToPath(new URL('..', import.meta.url)); -// Run npm through its JS entry point so no shell is involved on any platform. -const npmCli = process.env.npm_execpath; -if (!npmCli) { - throw new Error('run this script through `npm run smoke:package`'); -} -const run = (args, cwd) => execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8' }); -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-smoke-')); -try { - const packed = JSON.parse(run(['pack', '--json', '--pack-destination', work], packageDir)); - const tarball = join(work, packed[0].filename); - const files = packed[0].files.map(f => f.path); - // Exact package contents are gated by check-pack.mjs; this checks the install. - if (!files.includes('catalog/manifest.json')) throw new Error('package is missing the bundled catalog'); - const consumer = join(work, 'consumer'); - mkdirSync(consumer); - writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: 'consumer', private: true, type: 'module' })); - run(['install', '--offline', '--no-audit', '--no-fund', tarball], consumer); - const manifest = JSON.parse(readFileSync(join(packageDir, 'catalog', 'manifest.json'), 'utf8')); - writeFileSync(join(consumer, 'smoke.mjs'), ` - import { getCatalogInfo, listCatalogEntries, resolveSandboxPolicy, resolveSandboxPolicyWithDiagnostics, PolicyCatalogError } from '@mxc-prototype/policy-catalog'; - import { execFileSync } from 'node:child_process'; - import assert from 'node:assert/strict'; - assert.equal(getCatalogInfo().catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); - assert.ok(listCatalogEntries().length > 0); - // Weak identity requires opt-in, so a bare name yields no policy. - assert.equal(resolveSandboxPolicy('git', { platform: 'linux', architecture: 'x64' }), undefined); - const ctx = { platform: 'linux', architecture: 'x64', allowWeakIdentityFallback: true, - projectRoot: '/p', symbols: { git_prefix: '/usr/bin', node_prefix: '/n', npm_prefix: '/n', npm_cache: '/c' } }; - assert.deepEqual(resolveSandboxPolicy(['git', 'npm'], ctx).filesystem.readwritePaths, ['/p', '/c']); - const r = resolveSandboxPolicyWithDiagnostics('git', ctx); - assert.equal(r.diagnostics.tools[0].matches[0].entryId, 'tool:git'); - assert.throws(() => resolveSandboxPolicy('git', { catalogRevision: '1999-01-01.1' }), PolicyCatalogError); - // The packaged CLI runs from the installed tree. - const inspected = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'inspect'], { encoding: 'utf8' })); - assert.equal(inspected.info.catalogRevision, ${JSON.stringify(manifest.defaultRevision)}); - // On POSIX, npm installs the bin as a symlink; running through it must still work. - if (process.platform !== 'win32') { - const viaBin = JSON.parse(execFileSync(process.execPath, ['node_modules/.bin/policy-catalog', 'inspect'], { encoding: 'utf8' })); - assert.deepEqual(viaBin, inspected); - } - // validate checks the installed catalog's integrity and contract. - const validated = JSON.parse(execFileSync(process.execPath, ['node_modules/@mxc-prototype/policy-catalog/dist/cli.js', 'validate'], { encoding: 'utf8' })); - assert.equal(validated.ok, true); - console.log('package smoke ok'); - `); - process.stdout.write(execFileSync(process.execPath, ['smoke.mjs'], { cwd: consumer, encoding: 'utf8' })); -} finally { - rmSync(work, { recursive: true, force: true }); -} diff --git a/policy-catalog/scripts/rust-functional.mjs b/policy-catalog/scripts/rust-functional.mjs deleted file mode 100644 index 97beacfcf..000000000 --- a/policy-catalog/scripts/rust-functional.mjs +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Functional tests of the PACKAGED Rust crate (rust/). Cross-platform driver: -// -// 1. copies ../catalog into rust/catalog (rust/scripts/sync-catalog.mjs) -// 2. `cargo package` in rust/ (verifies the packaged crate builds in isolation) -// 3. extracts target/package/mxc-policy-catalog-*.crate into a fresh temp -// directory outside the repository (os.tmpdir()) -// 4. builds the packaged `policy-catalog` binary from the extracted crate -// 5. creates a consumer crate from rust/functional/ (Cargo.toml.in, src/, -// tests/) that depends on the extracted crate by `path`, and runs its tests -// -// Every build uses a CARGO_TARGET_DIR inside the temp directory, so nothing -// collides with rust/target. Prints test counts; exits nonzero on failure. -// -// node scripts/rust-functional.mjs [--allow-dirty] [--keep] -import { execFileSync, spawnSync } from 'node:child_process'; -import { copyFileSync, cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, relative, isAbsolute } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const allowDirty = process.argv.includes('--allow-dirty'); -const keep = process.argv.includes('--keep'); -const crateDir = fileURLToPath(new URL('../rust/', import.meta.url)); -const repoDir = fileURLToPath(new URL('../', import.meta.url)); -const exe = process.platform === 'win32' ? '.exe' : ''; -// On Windows, use the inbox bsdtar: a GNU tar earlier on PATH (Git Bash, as on -// GitHub-hosted runners) reads `C:/...` as a remote host and cannot gunzip. -const tarCmd = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; - -function run(cmd, args, options = {}) { - console.log(`> ${cmd} ${args.join(' ')}${options.cwd ? ` (in ${options.cwd})` : ''}`); - const result = spawnSync(cmd, args, { stdio: 'inherit', ...options }); - if (result.status !== 0) { - throw new Error(`${cmd} ${args.join(' ')} failed with exit code ${result.status ?? result.signal}`); - } -} - -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-rust-functional-')); -const rel = relative(repoDir, work); -if (!(rel.startsWith('..') || isAbsolute(rel))) { - throw new Error(`temp directory ${work} is inside the repository`); -} - -let exitCode = 1; -try { - run(process.execPath, [join(crateDir, 'scripts', 'sync-catalog.mjs')]); - run('cargo', ['package', ...(allowDirty ? ['--allow-dirty'] : [])], { cwd: crateDir }); - - const packageDir = join(crateDir, 'target', 'package'); - const crates = readdirSync(packageDir) - .filter(name => /^mxc-policy-catalog-.*\.crate$/.test(name)) - .map(name => join(packageDir, name)) - .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); - if (crates.length === 0) { - throw new Error(`no .crate in ${packageDir}`); - } - const crate = crates[0]; - console.log(`Packaged crate: ${crate}`); - - // A .crate is a gzip'd tarball with one top-level `-/` directory. - const extractRoot = join(work, 'extracted'); - rmSync(extractRoot, { recursive: true, force: true }); - execFileSync(tarCmd, ['-xzf', crate, '-C', work], { stdio: 'inherit', cwd: work }); - const extracted = readdirSync(work) - .filter(name => name.startsWith('mxc-policy-catalog-') && statSync(join(work, name)).isDirectory()) - .map(name => join(work, name))[0]; - if (!extracted) { - throw new Error('the .crate did not contain a mxc-policy-catalog-* directory'); - } - console.log(`Extracted to: ${extracted}`); - - // Build the packaged binary from the extracted crate. - const crateTarget = join(work, 'target-crate'); - run('cargo', ['build', '--locked', '--bin', 'policy-catalog'], { - cwd: extracted, - env: { ...process.env, CARGO_TARGET_DIR: crateTarget }, - }); - const bin = join(crateTarget, 'debug', `policy-catalog${exe}`); - - // Consumer crate from the template. - const consumer = join(work, 'consumer'); - const template = join(crateDir, 'functional'); - cpSync(join(template, 'src'), join(consumer, 'src'), { recursive: true }); - cpSync(join(template, 'tests'), join(consumer, 'tests'), { recursive: true }); - const manifest = readFileSync(join(template, 'Cargo.toml.in'), 'utf8').replaceAll('@CRATE_PATH@', extracted.replaceAll('\\', '/')); - writeFileSync(join(consumer, 'Cargo.toml'), manifest); - // Pin dependency versions to the packaged crate's lock file and toolchain. - copyFileSync(join(extracted, 'Cargo.lock'), join(consumer, 'Cargo.lock')); - copyFileSync(join(extracted, 'rust-toolchain.toml'), join(consumer, 'rust-toolchain.toml')); - - const testWork = join(work, 'test-work'); - const result = spawnSync('cargo', ['test', '--', '--test-threads=4'], { - cwd: consumer, - encoding: 'utf8', - env: { - ...process.env, - CARGO_TARGET_DIR: join(work, 'target-consumer'), - POLICY_CATALOG_BIN: bin, - POLICY_CATALOG_WORK: testWork, - }, - }); - process.stdout.write(result.stdout ?? ''); - process.stderr.write(result.stderr ?? ''); - let passed = 0; - let failed = 0; - for (const match of (result.stdout ?? '').matchAll(/test result: \w+\. (\d+) passed; (\d+) failed/g)) { - passed += Number(match[1]); - failed += Number(match[2]); - } - console.log(`\nrust functional (packaged crate): ${passed} passed, ${failed} failed`); - exitCode = result.status === 0 && failed === 0 && passed > 0 ? 0 : 1; -} catch (error) { - console.error(`rust-functional: ${error.message}`); - exitCode = 1; -} finally { - if (keep) { - console.log(`Kept ${work}`); - } else { - rmSync(work, { recursive: true, force: true }); - } -} -process.exitCode = exitCode; diff --git a/policy-catalog/scripts/validate-catalog.mjs b/policy-catalog/scripts/validate-catalog.mjs deleted file mode 100644 index 928138eb9..000000000 --- a/policy-catalog/scripts/validate-catalog.mjs +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Contribution/CI validation pipeline for the catalog (design §7, §12 "Data"). -// -// 1. JSON Schema conformance of contract, manifest, and every revision. -// 2. Integrity: every revision matches its published canonical SHA-256. -// 3. Semantic validation shared with the resolver: registered -// SandboxPolicy versions, entry-ID uniqueness, per-entry identity -// predicates, selectors, -// dependency closure and cycle freedom, symbol validity, no literal or -// user-specific paths, no wildcard grants, unsupported-field rejection, -// backend neutrality, and v1 composition limits. -// 4. Entry-revision monotonicity across consecutive published revisions. -// 5. Immutability of already-published revisions against a base git ref -// (--base-ref= or POLICY_CATALOG_BASE_REF, e.g. origin/main), when -// provided. Shares its implementation with `policy-catalog validate`. -// 6. Deterministic resolution: every entry resolves identically twice for -// every platform/architecture selector, and the whole-catalog lookup is -// independent of input order and of catalog file order. -// 7. Package inclusion: the bundled default revision equals the repository -// manifest default. -// 8. Every entry has a conformance fixture case. -import { readFileSync, readdirSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { join, relative } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { CatalogStore, PolicyCatalog, loadCatalogDirectory, bundledCatalogStore } from '../dist/index.js'; -import { - ARCHITECTURES, - PLATFORMS, - canonicalSha256, - checkStoreHistory, - selectVariant, - checkAgainstBaseRef, -} from '../dist/tooling.js'; - -const require = createRequire(import.meta.url); -const Ajv2020 = require('ajv/dist/2020'); - -const root = fileURLToPath(new URL('../', import.meta.url)); -const catalogDir = join(root, 'catalog'); -const schemaDir = join(root, 'schema'); -const readJson = file => JSON.parse(readFileSync(file, 'utf8')); -const errors = []; -const step = (name, fn) => { - try { - const found = fn() ?? []; - errors.push(...found.map(message => `${name}: ${message}`)); - console.log(`${found.length === 0 ? 'ok ' : 'FAIL'} ${name}`); - } catch (error) { - errors.push(`${name}: ${error.message}`); - console.log(`FAIL ${name}`); - } -}; - -const ajv = new Ajv2020({ allErrors: true, strict: true }); -const catalogSchema = ajv.compile(readJson(join(schemaDir, 'catalog.v1.schema.json'))); -const manifestSchema = ajv.compile(readJson(join(schemaDir, 'manifest.v1.schema.json'))); -const describe = validate => (validate.errors ?? []).map(e => `${e.instancePath || '/'} ${e.message}`); - -const manifest = readJson(join(catalogDir, 'manifest.json')); - -step('manifest schema', () => (manifestSchema(manifest) ? [] : describe(manifestSchema))); - -step('revision schema', () => { - const found = []; - const listed = new Set(manifest.revisions.map(r => r.file)); - for (const name of readdirSync(join(catalogDir, 'revisions'))) { - const file = `revisions/${name}`; - if (!listed.has(file)) { - found.push(`${file} is not listed in manifest.json`); - continue; - } - if (!catalogSchema(readJson(join(catalogDir, file)))) { - found.push(...describe(catalogSchema).map(m => `${file} ${m}`)); - } - } - return found; -}); - -const store = loadCatalogDirectory(pathToFileURL(`${catalogDir}/`)); - -step('integrity, contract, and entry-revision history', () => checkStoreHistory(store)); - -step('published revision immutability', () => { - // Same check as `policy-catalog validate --base-ref `. An unknown ref is - // an error, never a silent pass. - const baseRef = process.argv.find(arg => arg.startsWith('--base-ref='))?.slice('--base-ref='.length) - ?? process.env.POLICY_CATALOG_BASE_REF; - if (!baseRef) { - console.log(' (skipped: pass --base-ref= or set POLICY_CATALOG_BASE_REF to compare against a base ref)'); - return []; - } - const { comparedRevisions, errors } = checkAgainstBaseRef(catalogDir, manifest, baseRef); - console.log(` (base ref ${baseRef}: ${comparedRevisions} published revision(s) compared)`); - return errors; -}); - -step('deterministic resolution', () => { - const found = []; - const catalog = new PolicyCatalog(store); - const symbols = Object.fromEntries( - Object.entries(store.contract.symbols).filter(([, d]) => d.source !== 'context').map(([name]) => [name, name]), - ); - for (const revisionId of store.availableRevisions) { - for (const entry of store.revision(revisionId).entries) { - const name = entry.identity.find(p => p.kind === 'invocation-name')?.names[0]; - const purl = entry.identity.find(p => p.kind === 'purl')?.value; - for (const platform of PLATFORMS) { - for (const architecture of ARCHITECTURES) { - if (!selectVariant(entry, platform, architecture)) continue; - const base = platform === 'windows' ? 'C:\\ci' : '/ci'; - const sep = platform === 'windows' ? '\\' : '/'; - const ctx = { - platform, - architecture, - catalogRevision: revisionId, - allowWeakIdentityFallback: true, - projectRoot: `${base}${sep}project`, - symbols: Object.fromEntries(Object.keys(symbols).map(s => [s, `${base}${sep}${s}`])), - }; - const tool = { invocationName: name ?? 'unused', ...(purl ? { packageUrl: purl } : {}) }; - const first = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); - const second = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); - if (first.policy === undefined) { - found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} produced no policy: ${first.diagnostics.warnings.join('; ')}`); - } else if (JSON.stringify(first) !== JSON.stringify(second)) { - found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} did not resolve deterministically`); - } else if (!first.diagnostics.tools[0].matches.some(match => match.entryId === entry.entryId)) { - found.push(`${revisionId} ${entry.entryId} ${platform}/${architecture} was not matched by its own identity`); - } - } - } - } - } - return found; -}); - -step('package inclusion', () => { - // The package ships `catalog/` itself; the bundled loader must see exactly - // the revisions this pipeline validated. - const bundled = bundledCatalogStore(); - return bundled.defaultRevision === store.defaultRevision - && JSON.stringify(bundled.availableRevisions) === JSON.stringify(store.availableRevisions) - ? [] - : ['bundled catalog does not match the repository catalog']; -}); - -step('order independence', () => { - // Catalog file order and input order must not change the composed policy. - const revision = store.revision(); - const reversed = { ...structuredClone(revision), entries: [...structuredClone(revision.entries)].reverse() }; - const digest = canonicalSha256(reversed); - const reversedStore = new CatalogStore({ - contract: store.contract, - manifest: { catalogSchemaVersion: '1', defaultRevision: revision.catalogRevision, revisions: [{ catalogRevision: revision.catalogRevision, file: `revisions/${revision.catalogRevision}.json`, sha256: digest }] }, - readRevision: () => structuredClone(reversed), - }); - const names = revision.entries.map(e => e.identity.find(p => p.kind === 'invocation-name')?.names[0]).filter(Boolean); - const found = []; - for (const platform of PLATFORMS) { - for (const architecture of ARCHITECTURES) { - const sep = platform === 'windows' ? '\\' : '/'; - const base = platform === 'windows' ? 'C:\\ci' : '/ci'; - const ctx = { - platform, - architecture, - allowWeakIdentityFallback: true, - projectRoot: `${base}${sep}project`, - symbols: Object.fromEntries(Object.entries(store.contract.symbols).filter(([, d]) => d.source !== 'context').map(([s]) => [s, `${base}${sep}${s}`])), - }; - const run = (catalogStore, tools) => { - try { - return JSON.stringify(new PolicyCatalog(catalogStore).resolveSandboxPolicy(tools, ctx)); - } catch (error) { - return `error:${error.reason ?? error.message}`; - } - }; - const a = run(store, names); - const b = run(reversedStore, names); - const c = run(store, [...names].reverse()); - if (a !== b) found.push(`${platform}/${architecture}: catalog file order changed the result`); - if (a !== c && !a.startsWith('error:')) { - // Paths keep first-seen order, so only set equality is required across input order. - const set = s => JSON.stringify(Object.fromEntries(Object.entries(JSON.parse(s)?.filesystem ?? {}).map(([k, v]) => [k, [...v].sort()]))); - if (set(a) !== set(c)) found.push(`${platform}/${architecture}: input order changed the composed requirement set`); - } - } - } - return found; -}); - -step('entry fixtures', () => { - const fixtureDir = join(root, 'conformance', 'fixtures'); - const covered = new Set(); - for (const name of readdirSync(fixtureDir)) { - const fixture = readJson(join(fixtureDir, name)); - if (fixture.catalog !== 'bundled') continue; - for (const c of fixture.cases) { - for (const tool of c.expect?.diagnostics?.tools ?? []) { - for (const match of tool.matches) covered.add(match.entryId); - } - } - } - const found = []; - for (const entry of store.revision().entries) { - if (!covered.has(entry.entryId)) { - found.push(`${entry.entryId} has no bundled-catalog conformance case in conformance/fixtures/`); - } - } - return found; -}); - -if (errors.length > 0) { - console.error('\nPolicy catalog validation FAILED:'); - for (const message of errors) console.error(` - ${message}`); - process.exit(1); -} -console.log(`\nPolicy catalog validation passed (${relative(process.cwd(), catalogDir) || '.'}, default revision ${store.defaultRevision}).`); diff --git a/policy-catalog/src/canonical-json.ts b/policy-catalog/src/canonical-json.ts deleted file mode 100644 index 403dfc586..000000000 --- a/policy-catalog/src/canonical-json.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { createHash } from 'node:crypto'; - -/** - * Canonical JSON used for revision integrity digests: object keys sorted by - * UTF-16 code unit order at every level, arrays kept in order, and no - * insignificant whitespace. Formatting-only edits to a revision file therefore - * do not change its digest, while every semantic edit does. - */ -export function canonicalJson(value: unknown): string { - if (value === null || typeof value !== 'object') { - const encoded = JSON.stringify(value); - if (encoded === undefined) { - throw new TypeError('canonicalJson: value is not JSON-serializable'); - } - return encoded; - } - if (Array.isArray(value)) { - return `[${value.map(canonicalJson).join(',')}]`; - } - const record = value as Record; - const keys = Object.keys(record).sort(); - return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; -} - -/** Lower-case hex SHA-256 of the canonical JSON form. */ -export function canonicalSha256(value: unknown): string { - return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); -} diff --git a/policy-catalog/src/catalog.ts b/policy-catalog/src/catalog.ts deleted file mode 100644 index 5aa3e90c0..000000000 --- a/policy-catalog/src/catalog.ts +++ /dev/null @@ -1,756 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import * as path from 'node:path'; -import { PolicyCatalogError } from './errors.js'; -import { isValidVersionRange } from './version-range.js'; -import { parsePurl } from './purl.js'; -import type { - CatalogArchitecture, - CatalogPlatform, - CatalogSandboxPolicy, -} from './types.js'; - -export const CATALOG_SCHEMA_VERSION = '1'; -export const PLATFORMS: readonly CatalogPlatform[] = ['windows', 'linux', 'macos']; -export const ARCHITECTURES: readonly CatalogArchitecture[] = ['x64', 'arm64']; - -/** Fields that the v1 contract can compose across entries (design §4.5). */ -export const COMPOSABLE_FILESYSTEM_FIELDS = ['deniedPaths', 'readonlyPaths', 'readwritePaths'] as const; -export type AccessClass = (typeof COMPOSABLE_FILESYSTEM_FIELDS)[number]; - -export type SymbolSource = 'context' | 'caller' | 'host'; - -export interface CatalogContract { - catalogSchemaVersion: string; - sandboxPolicyVersions: string[]; - symbols: Record; -} - -export type IdentityPredicate = - | { kind: 'purl'; value: string; versionRange?: string } - | { kind: 'invocation-name'; names: string[] }; - -export interface PlatformVariant { - when: { platform: CatalogPlatform; architecture?: CatalogArchitecture }; - dependencies?: Array<{ entryId: string; versionRange?: string }>; - sandboxPolicy: CatalogSandboxPolicy; -} - -export interface CatalogEntry { - entryId: string; - entryRevision: number; - displayName: string; - identity: IdentityPredicate[]; - platformVariants: PlatformVariant[]; - provenance: { method: string; sourceRevision: string }; -} - -export interface CatalogRevision { - catalogSchemaVersion: string; - catalogRevision: string; - entries: CatalogEntry[]; -} - -/** Strength of an identity predicate kind. Order in this map is strongest first. */ -export const IDENTITY_STRENGTH: Record = { - purl: 'strong', - 'invocation-name': 'weak', -}; - -const REVISION_PATTERN = /^(\d{4}-\d{2}-\d{2})\.([1-9]\d*)$/; -const ENTRY_ID_PATTERN = /^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$/; -const SYMBOL_PATTERN = /\$\{([a-z][a-z0-9_]*)\}/g; -const ANCHORED_SYMBOL = /^\$\{([a-z][a-z0-9_]*)\}(?:[\\/]|$)/; - -function fail(message: string): never { - throw new PolicyCatalogError('invalid_catalog', message); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function onlyFields(value: Record, allowed: readonly string[], at: string): void { - for (const key of Object.keys(value)) { - if (!allowed.includes(key)) { - fail(`unsupported field '${at}.${key}'`); - } - } -} - -function nonEmptyString(value: unknown, at: string): string { - if (typeof value !== 'string' || value.length === 0) { - fail(`'${at}' must be a non-empty string`); - } - return value; -} - -function stringArray(value: unknown, at: string, minItems = 0): string[] { - if (!Array.isArray(value) || value.length < minItems) { - fail(`'${at}' must be an array with at least ${minItems} item(s)`); - } - return value.map((item, index) => nonEmptyString(item, `${at}[${index}]`)); -} - -/** Compares two catalog revision identifiers (`YYYY-MM-DD.N`). */ -export function compareCatalogRevisions(left: string, right: string): number { - const a = REVISION_PATTERN.exec(left); - const b = REVISION_PATTERN.exec(right); - if (!a || !b) { - fail(`cannot compare malformed catalog revisions '${left}' and '${right}'`); - } - if (a[1] !== b[1]) { - return a[1] < b[1] ? -1 : 1; - } - return Math.sign(Number(a[2]) - Number(b[2])); -} - -export function isCatalogRevisionId(value: string): boolean { - return REVISION_PATTERN.test(value); -} - -// --------------------------------------------------------------------------- -// Contract -// --------------------------------------------------------------------------- - -export function validateContract(raw: unknown): CatalogContract { - if (!isRecord(raw)) { - fail('contract root must be an object'); - } - onlyFields(raw, ['$comment', 'catalogSchemaVersion', 'sandboxPolicyVersions', 'symbols'], 'contract'); - if (raw.catalogSchemaVersion !== CATALOG_SCHEMA_VERSION) { - fail(`contract.catalogSchemaVersion must be '${CATALOG_SCHEMA_VERSION}'`); - } - const sandboxPolicyVersions = stringArray(raw.sandboxPolicyVersions, 'contract.sandboxPolicyVersions', 1); - if (!isRecord(raw.symbols)) { - fail('contract.symbols must be an object'); - } - const symbols: CatalogContract['symbols'] = {}; - for (const [name, definition] of Object.entries(raw.symbols)) { - if (!/^[a-z][a-z0-9_]*$/.test(name) || !isRecord(definition)) { - fail(`contract.symbols.${name} is malformed`); - } - onlyFields(definition, ['source', 'description'], `contract.symbols.${name}`); - if (definition.source !== 'context' && definition.source !== 'caller' && definition.source !== 'host') { - fail(`contract.symbols.${name}.source is unsupported`); - } - symbols[name] = { - source: definition.source, - description: nonEmptyString(definition.description, `contract.symbols.${name}.description`), - }; - } - return { catalogSchemaVersion: CATALOG_SCHEMA_VERSION, sandboxPolicyVersions, symbols }; -} - -// --------------------------------------------------------------------------- -// Paths and symbols -// --------------------------------------------------------------------------- - -function validateTemplatePath(value: string, at: string, contract: CatalogContract): void { - if (/[*?]/.test(value)) { - fail(`'${at}' contains a wildcard`); - } - if (value.replace(SYMBOL_PATTERN, '').includes('${')) { - fail(`'${at}' contains malformed symbol syntax`); - } - for (const match of value.matchAll(SYMBOL_PATTERN)) { - if (!Object.hasOwn(contract.symbols, match[1])) { - fail(`'${at}' references unknown symbol '${match[1]}'`); - } - } - // Catalog data never ships a literal, machine-specific path (design §4.2): - // every filesystem requirement must be anchored at a declared symbol. - if (!ANCHORED_SYMBOL.test(value)) { - fail(`'${at}' must start with a declared symbol; literal paths are not allowed`); - } - if (value.split(/[\\/]/).some(segment => segment === '..')) { - fail(`'${at}' must not contain '..' segments`); - } -} - -/** Returns the symbols a policy references, in first-seen order. */ -export function policySymbols(policy: CatalogSandboxPolicy): string[] { - const seen = new Set(); - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - for (const value of policy.filesystem?.[field] ?? []) { - for (const match of value.matchAll(SYMBOL_PATTERN)) { - seen.add(match[1]); - } - } - } - return [...seen]; -} - -/** - * The one casing rule for a target platform, shared by invocation-name - * matching and path comparison: Windows and macOS (default APFS/HFS+ volumes) - * resolve names case-insensitively; Linux resolves them exactly. - * - * Catalog validation deliberately does not use this rule. It folds invocation - * names on every platform, so a repeated name is caught even for an entry that - * is only ever served to a case-sensitive host. - */ -export function foldsCase(platform: CatalogPlatform): boolean { - return platform !== 'linux'; -} - -/** Applies {@link foldsCase} to one value (`toLowerCase` when the platform folds case). */ -export function caseKey(value: string, platform: CatalogPlatform): string { - return foldsCase(platform) ? value.toLowerCase() : value; -} - -/** - * Normalizes an absolute resolved path with the platform's path rules (Node - * `path.win32` / `path.posix` `normalize`), then removes trailing separators - * unless the path is only its root. Other language bindings reproduce this - * exactly; `conformance/vectors/paths.json` pins the behavior. - */ -export function normalizePath(value: string, platform: CatalogPlatform): string { - const api = platform === 'windows' ? path.win32 : path.posix; - const normalized = api.normalize(value); - const root = api.parse(normalized).root; - return normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized; -} - -/** Whether `value` is absolute under the platform's path rules (Node `isAbsolute`). */ -export function isAbsolutePath(value: string, platform: CatalogPlatform): boolean { - return (platform === 'windows' ? path.win32 : path.posix).isAbsolute(value); -} - -/** Splits a path into comparable segments using the platform's path and casing rules. */ -export function pathKeySegments(value: string, platform: CatalogPlatform): string[] { - const separators = platform === 'windows' ? /[\\/]+/ : /\/+/; - const segments = value.split(separators).filter((segment, index) => segment !== '.' && (segment !== '' || index === 0)); - const normalized = segments.length > 1 && segments[segments.length - 1] === '' ? segments.slice(0, -1) : segments; - return normalized.map(segment => caseKey(segment, platform)); -} - -function isSameOrNested(left: string[], right: string[]): boolean { - const shorter = left.length <= right.length ? left : right; - const longer = shorter === left ? right : left; - return shorter.every((segment, index) => longer[index] === segment); -} - -/** - * Finds the first equal or ancestor/descendant pair of paths that appear in - * different access classes. Returns a description or `undefined`. - */ -export function findCrossClassOverlap( - classes: Partial>, - platform: CatalogPlatform, -): string | undefined { - const fields = COMPOSABLE_FILESYSTEM_FIELDS.filter(field => (classes[field]?.length ?? 0) > 0); - for (let i = 0; i < fields.length; i += 1) { - for (let j = i + 1; j < fields.length; j += 1) { - for (const left of classes[fields[i]] ?? []) { - for (const right of classes[fields[j]] ?? []) { - if (isSameOrNested(pathKeySegments(left, platform), pathKeySegments(right, platform))) { - return `'${left}' (${fields[i]}) overlaps '${right}' (${fields[j]})`; - } - } - } - } - } - return undefined; -} - -// --------------------------------------------------------------------------- -// Embedded SandboxPolicy (catalog-supported subset) -// --------------------------------------------------------------------------- - -const BACKEND_KEYS = ['containment', 'processContainer', 'appContainer', 'lxc', 'seatbelt', 'wslc', 'hyperlight', 'bwrap']; - -function validateNetworkRules(value: unknown, at: string, denyList: boolean): void { - if (!Array.isArray(value)) { - fail(`'${at}' must be an array`); - } - value.forEach((rule, index) => { - const ruleAt = `${at}[${index}]`; - if (!isRecord(rule)) { - fail(`'${ruleAt}' must be an object`); - } - onlyFields(rule, ['to', 'ports'], ruleAt); - if (rule.to === undefined && !denyList) { - fail(`'${ruleAt}' has no 'to'; wildcard network grants are not allowed`); - } - if (rule.to !== undefined) { - if (!Array.isArray(rule.to) || rule.to.length === 0) { - fail(`'${ruleAt}.to' must be a non-empty array`); - } - rule.to.forEach((peer, peerIndex) => { - const peerAt = `${ruleAt}.to[${peerIndex}]`; - if (!isRecord(peer)) { - fail(`'${peerAt}' must be an object`); - } - onlyFields(peer, ['cidr', 'except'], peerAt); - const cidr = nonEmptyString(peer.cidr, `${peerAt}.cidr`); - if (!denyList && /\/0$/.test(cidr)) { - fail(`'${peerAt}.cidr' is a wildcard network grant`); - } - if (peer.except !== undefined) { - stringArray(peer.except, `${peerAt}.except`); - } - }); - } - if (rule.ports !== undefined) { - if (!Array.isArray(rule.ports) || rule.ports.length === 0) { - fail(`'${ruleAt}.ports' must be a non-empty array`); - } - rule.ports.forEach((port, portIndex) => { - const portAt = `${ruleAt}.ports[${portIndex}]`; - if (!isRecord(port)) { - fail(`'${portAt}' must be an object`); - } - onlyFields(port, ['protocol', 'port', 'endPort'], portAt); - if (port.protocol !== undefined && !['tcp', 'udp', 'icmp', 'any'].includes(port.protocol as string)) { - fail(`'${portAt}.protocol' is unsupported`); - } - for (const key of ['port', 'endPort'] as const) { - const n = port[key]; - if (n !== undefined && (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 65535)) { - fail(`'${portAt}.${key}' must be an integer in 1..65535`); - } - } - if (port.endPort !== undefined && (port.port === undefined || (port.endPort as number) < (port.port as number))) { - fail(`'${portAt}.endPort' requires a lower or equal 'port'`); - } - }); - } - }); -} - -function validateSandboxPolicy(raw: unknown, at: string, contract: CatalogContract): CatalogSandboxPolicy { - if (!isRecord(raw)) { - fail(`'${at}' must be an object`); - } - for (const key of BACKEND_KEYS) { - if (key in raw) { - fail(`'${at}.${key}' names a containment backend; platform variants must stay backend-neutral`); - } - } - onlyFields(raw, ['version', 'filesystem', 'network', 'ui', 'timeoutMs'], at); - const version = nonEmptyString(raw.version, `${at}.version`); - if (!contract.sandboxPolicyVersions.includes(version)) { - fail(`'${at}.version' '${version}' is not a SandboxPolicy version registered in the catalog contract`); - } - if (raw.filesystem !== undefined) { - if (!isRecord(raw.filesystem)) { - fail(`'${at}.filesystem' must be an object`); - } - onlyFields(raw.filesystem, COMPOSABLE_FILESYSTEM_FIELDS, `${at}.filesystem`); - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - if (raw.filesystem[field] !== undefined) { - stringArray(raw.filesystem[field], `${at}.filesystem.${field}`).forEach((value, index) => - validateTemplatePath(value, `${at}.filesystem.${field}[${index}]`, contract)); - } - } - } - if (raw.network !== undefined) { - if (!isRecord(raw.network)) { - fail(`'${at}.network' must be an object`); - } - onlyFields(raw.network, ['egress', 'ingress'], `${at}.network`); - const { egress, ingress } = raw.network; - if (egress !== undefined) { - if (!isRecord(egress)) { - fail(`'${at}.network.egress' must be an object`); - } - onlyFields(egress, ['default', 'allow', 'deny'], `${at}.network.egress`); - if (egress.default !== undefined && egress.default !== 'deny') { - fail(`'${at}.network.egress.default' must be 'deny'; a default-allow grant is a wildcard`); - } - if (egress.allow !== undefined) { - validateNetworkRules(egress.allow, `${at}.network.egress.allow`, false); - } - if (egress.deny !== undefined) { - validateNetworkRules(egress.deny, `${at}.network.egress.deny`, true); - } - } - if (ingress !== undefined) { - if (!isRecord(ingress)) { - fail(`'${at}.network.ingress' must be an object`); - } - onlyFields(ingress, ['default', 'hostLoopback'], `${at}.network.ingress`); - if (ingress.default !== undefined && ingress.default !== 'deny') { - fail(`'${at}.network.ingress.default' must be 'deny'; a default-allow grant is a wildcard`); - } - if (ingress.hostLoopback !== undefined && ingress.hostLoopback !== 'allow' && ingress.hostLoopback !== 'deny') { - fail(`'${at}.network.ingress.hostLoopback' is unsupported`); - } - } - } - if (raw.ui !== undefined) { - if (!isRecord(raw.ui)) { - fail(`'${at}.ui' must be an object`); - } - onlyFields(raw.ui, ['allowWindows', 'clipboard', 'allowInputInjection'], `${at}.ui`); - for (const key of ['allowWindows', 'allowInputInjection'] as const) { - if (raw.ui[key] !== undefined && typeof raw.ui[key] !== 'boolean') { - fail(`'${at}.ui.${key}' must be a boolean`); - } - } - if (raw.ui.clipboard !== undefined && !['none', 'read', 'write', 'all'].includes(raw.ui.clipboard as string)) { - fail(`'${at}.ui.clipboard' is unsupported`); - } - } - if (raw.timeoutMs !== undefined - && (typeof raw.timeoutMs !== 'number' || !Number.isInteger(raw.timeoutMs) || raw.timeoutMs < 1)) { - fail(`'${at}.timeoutMs' must be a positive integer`); - } - return raw as unknown as CatalogSandboxPolicy; -} - -// --------------------------------------------------------------------------- -// Entries -// --------------------------------------------------------------------------- - -function validateIdentity(raw: unknown, at: string): IdentityPredicate[] { - if (!Array.isArray(raw) || raw.length === 0) { - fail(`'${at}' must be a non-empty array`); - } - const predicates = raw.map((item, index): IdentityPredicate => { - const itemAt = `${at}[${index}]`; - if (!isRecord(item)) { - fail(`'${itemAt}' must be an object`); - } - if (item.kind === 'purl') { - onlyFields(item, ['kind', 'value', 'versionRange'], itemAt); - const value = nonEmptyString(item.value, `${itemAt}.value`); - const parsed = parsePurl(value); - if (!parsed) { - fail(`'${itemAt}.value' is not a valid package URL`); - } - if (parsed.version !== undefined) { - fail(`'${itemAt}.value' must not pin a version; use 'versionRange'`); - } - if (item.versionRange !== undefined && !isValidVersionRange(nonEmptyString(item.versionRange, `${itemAt}.versionRange`))) { - fail(`'${itemAt}.versionRange' is not a valid version range`); - } - return { kind: 'purl', value, ...(item.versionRange !== undefined ? { versionRange: item.versionRange as string } : {}) }; - } - if (item.kind === 'invocation-name') { - onlyFields(item, ['kind', 'names'], itemAt); - const names = stringArray(item.names, `${itemAt}.names`, 1); - for (const name of names) { - if (/[\\/]/.test(name)) { - fail(`'${itemAt}.names' entry '${name}' must be a bare invocation name, not a path`); - } - } - return { kind: 'invocation-name', names }; - } - fail(`'${itemAt}.kind' is not a supported identity kind`); - }); - // Matching is additive (design §4.3): predicates of different entries may - // overlap, but one entry must not repeat a predicate, because that would - // make its matched-identity diagnostics ambiguous. - const seen = new Set(); - for (const key of predicates.flatMap(identityKeys)) { - if (seen.has(key)) { - fail(`'${at}' repeats identity '${key}'`); - } - seen.add(key); - } - return predicates; -} - -function validateVariants(raw: unknown, at: string, contract: CatalogContract): PlatformVariant[] { - if (!Array.isArray(raw) || raw.length === 0) { - fail(`'${at}' must be a non-empty array`); - } - const selectors = new Set(); - return raw.map((item, index): PlatformVariant => { - const itemAt = `${at}[${index}]`; - if (!isRecord(item)) { - fail(`'${itemAt}' must be an object`); - } - onlyFields(item, ['when', 'dependencies', 'sandboxPolicy'], itemAt); - if (!isRecord(item.when)) { - fail(`'${itemAt}.when' must be an object`); - } - onlyFields(item.when, ['platform', 'architecture'], `${itemAt}.when`); - const platform = item.when.platform as CatalogPlatform; - if (!PLATFORMS.includes(platform)) { - fail(`'${itemAt}.when.platform' must be one of ${PLATFORMS.join(', ')}`); - } - const architecture = item.when.architecture as CatalogArchitecture | undefined; - if (architecture !== undefined && !ARCHITECTURES.includes(architecture)) { - fail(`'${itemAt}.when.architecture' must be one of ${ARCHITECTURES.join(', ')}`); - } - const selector = `${platform}/${architecture ?? '*'}`; - if (selectors.has(selector)) { - fail(architecture === undefined - ? `'${itemAt}' is a second architecture-neutral variant for '${platform}'` - : `'${itemAt}' duplicates selector '${selector}'`); - } - selectors.add(selector); - let dependencies: PlatformVariant['dependencies']; - if (item.dependencies !== undefined) { - if (!Array.isArray(item.dependencies)) { - fail(`'${itemAt}.dependencies' must be an array`); - } - const seen = new Set(); - dependencies = item.dependencies.map((dependency, depIndex) => { - const depAt = `${itemAt}.dependencies[${depIndex}]`; - if (!isRecord(dependency)) { - fail(`'${depAt}' must be an object`); - } - onlyFields(dependency, ['entryId', 'versionRange'], depAt); - const entryId = nonEmptyString(dependency.entryId, `${depAt}.entryId`); - if (seen.has(entryId)) { - fail(`'${depAt}.entryId' '${entryId}' is listed twice`); - } - seen.add(entryId); - if (dependency.versionRange !== undefined - && !isValidVersionRange(nonEmptyString(dependency.versionRange, `${depAt}.versionRange`))) { - fail(`'${depAt}.versionRange' is not a valid version range`); - } - return { entryId, ...(dependency.versionRange !== undefined ? { versionRange: dependency.versionRange as string } : {}) }; - }); - } - return { - when: { platform, ...(architecture !== undefined ? { architecture } : {}) }, - ...(dependencies !== undefined ? { dependencies } : {}), - sandboxPolicy: validateSandboxPolicy(item.sandboxPolicy, `${itemAt}.sandboxPolicy`, contract), - }; - }); -} - -function validateEntry(raw: unknown, at: string, contract: CatalogContract): CatalogEntry { - if (!isRecord(raw)) { - fail(`'${at}' must be an object`); - } - onlyFields(raw, ['entryId', 'entryRevision', 'displayName', 'identity', 'platformVariants', 'provenance'], at); - const entryId = nonEmptyString(raw.entryId, `${at}.entryId`); - if (!ENTRY_ID_PATTERN.test(entryId)) { - fail(`'${at}.entryId' '${entryId}' must be namespaced, e.g. 'tool:name'`); - } - if (typeof raw.entryRevision !== 'number' || !Number.isInteger(raw.entryRevision) || raw.entryRevision < 1) { - fail(`'${at}.entryRevision' must be a positive integer`); - } - if (!isRecord(raw.provenance)) { - fail(`'${at}.provenance' must be an object`); - } - onlyFields(raw.provenance, ['method', 'sourceRevision'], `${at}.provenance`); - return { - entryId, - entryRevision: raw.entryRevision, - displayName: nonEmptyString(raw.displayName, `${at}.displayName`), - identity: validateIdentity(raw.identity, `${at}.identity`), - platformVariants: validateVariants(raw.platformVariants, `${at}.platformVariants`, contract), - provenance: { - method: nonEmptyString(raw.provenance.method, `${at}.provenance.method`), - sourceRevision: nonEmptyString(raw.provenance.sourceRevision, `${at}.provenance.sourceRevision`), - }, - }; -} - -// --------------------------------------------------------------------------- -// Variant selection and dependency closure (shared by validation and resolver) -// --------------------------------------------------------------------------- - -/** - * Stable comparison keys for one predicate. Invocation names are folded - * case-insensitively so a repeat is caught on every platform. - */ -function identityKeys(predicate: IdentityPredicate): string[] { - return predicate.kind === 'purl' - ? [`purl:${parsePurl(predicate.value)!.key}`] - : predicate.names.map(name => `invocation-name:${name.toLowerCase()}`); -} - -export interface VariantSelection { - variant: PlatformVariant; - exact: boolean; -} - -/** - * Selects the variant for a host (design §4.4): the exact architecture first, - * then the platform's architecture-neutral variant. A different architecture's - * variant is never a fallback. - */ -export function selectVariant( - entry: CatalogEntry, - platform: CatalogPlatform, - architecture: CatalogArchitecture, -): VariantSelection | undefined { - const forPlatform = entry.platformVariants.filter(variant => variant.when.platform === platform); - const exact = forPlatform.find(variant => variant.when.architecture === architecture); - if (exact) { - return { variant: exact, exact: true }; - } - const neutral = forPlatform.find(variant => variant.when.architecture === undefined); - return neutral ? { variant: neutral, exact: false } : undefined; -} - -export interface ClosureNode { - entry: CatalogEntry; - variant: PlatformVariant; - /** False when the architecture-neutral variant was selected as a fallback. */ - exact: boolean; -} - -export type ClosureResult = - | { ok: true; nodes: ClosureNode[] } - | { ok: false; reason: 'cycle' | 'missing-entry' | 'unsupported-dependency'; detail: string }; - -/** - * Deterministic depth-first dependency closure. The root is first; each - * dependency follows in declaration order and appears once. Cycles are - * reported, never silently broken. - */ -export function dependencyClosure( - root: CatalogEntry, - rootSelection: VariantSelection, - byId: ReadonlyMap, - platform: CatalogPlatform, - architecture: CatalogArchitecture, -): ClosureResult { - const nodes: ClosureNode[] = []; - const done = new Set(); - const stack: string[] = []; - let failure: ClosureResult | undefined; - - const visit = (entry: CatalogEntry, selection: VariantSelection): void => { - if (failure) { - return; - } - if (stack.includes(entry.entryId)) { - failure = { ok: false, reason: 'cycle', detail: [...stack, entry.entryId].join(' -> ') }; - return; - } - if (done.has(entry.entryId)) { - return; - } - stack.push(entry.entryId); - nodes.push({ entry, variant: selection.variant, exact: selection.exact }); - for (const dependency of selection.variant.dependencies ?? []) { - const target = byId.get(dependency.entryId); - if (!target) { - failure = { ok: false, reason: 'missing-entry', detail: `${entry.entryId} -> ${dependency.entryId}` }; - return; - } - const selected = selectVariant(target, platform, architecture); - if (!selected) { - failure = { - ok: false, - reason: 'unsupported-dependency', - detail: `${entry.entryId} -> ${dependency.entryId} has no ${platform}/${architecture} variant`, - }; - return; - } - visit(target, selected); - if (failure) { - return; - } - } - stack.pop(); - done.add(entry.entryId); - }; - - visit(root, rootSelection); - return failure ?? { ok: true, nodes }; -} - -/** - * Composition limits for the v1 vocabulary (design §4.5). Applies to any set - * of selected entries: one entry's dependency closure, several entries matched - * by one input, or entries matched by several inputs. Returns a violation - * description, or `undefined` when the set can be composed. - */ -export function compositionViolation(nodes: readonly ClosureNode[]): string | undefined { - const versions = new Set(nodes.map(node => node.variant.sandboxPolicy.version)); - if (versions.size > 1) { - return `mixed sandboxPolicy.version values (${[...versions].sort().join(', ')})`; - } - if (nodes.length < 2) { - return undefined; - } - for (const node of nodes) { - const policy = node.variant.sandboxPolicy; - for (const key of Object.keys(policy)) { - if (key !== 'version' && key !== 'filesystem') { - return `'${node.entry.entryId}' uses '${key}', which has no v1 cross-entry composition rule`; - } - } - } - return undefined; -} - -// --------------------------------------------------------------------------- -// Revision-level validation -// --------------------------------------------------------------------------- - -/** - * Validates one catalog revision against the v1 contract (design §7): shape, - * entry-ID uniqueness, per-entry identity predicates, platform selectors, - * registered policy versions, symbols, unsafe paths, backend neutrality, - * dependency closure and cycle-freedom for every supported - * platform/architecture, and the v1 composition limits within each closure. - * Composition across independently matched entries depends on the caller's - * inputs and is checked by the resolver. - */ -export function validateCatalogRevision(raw: unknown, contract: CatalogContract): CatalogRevision { - if (!isRecord(raw)) { - fail('catalog root must be an object'); - } - onlyFields(raw, ['catalogSchemaVersion', 'catalogRevision', 'entries'], 'catalog'); - if (raw.catalogSchemaVersion !== contract.catalogSchemaVersion) { - fail(`catalog.catalogSchemaVersion must be '${contract.catalogSchemaVersion}'`); - } - const catalogRevision = nonEmptyString(raw.catalogRevision, 'catalog.catalogRevision'); - if (!isCatalogRevisionId(catalogRevision)) { - fail(`catalog.catalogRevision '${catalogRevision}' must match YYYY-MM-DD.N`); - } - if (!Array.isArray(raw.entries)) { - fail('catalog.entries must be an array'); - } - const entries = raw.entries.map((entry, index) => validateEntry(entry, `entries[${index}]`, contract)); - - const byId = new Map(); - for (const entry of entries) { - if (byId.has(entry.entryId)) { - fail(`duplicate entryId '${entry.entryId}'`); - } - byId.set(entry.entryId, entry); - } - - for (const entry of entries) { - for (const variant of entry.platformVariants) { - for (const dependency of variant.dependencies ?? []) { - if (!byId.has(dependency.entryId)) { - fail(`'${entry.entryId}' depends on unknown entry '${dependency.entryId}' in this revision`); - } - if (dependency.entryId === entry.entryId) { - fail(`'${entry.entryId}' depends on itself`); - } - } - } - for (const platform of PLATFORMS) { - for (const architecture of ARCHITECTURES) { - const selected = selectVariant(entry, platform, architecture); - if (!selected) { - continue; - } - const closure = dependencyClosure(entry, selected, byId, platform, architecture); - if (!closure.ok) { - fail(`'${entry.entryId}' on ${platform}/${architecture}: ${closure.reason} (${closure.detail})`); - } - const violation = compositionViolation(closure.nodes); - if (violation) { - fail(`'${entry.entryId}' on ${platform}/${architecture}: ${violation}`); - } - const classes: Partial> = {}; - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - classes[field] = closure.nodes.flatMap(node => node.variant.sandboxPolicy.filesystem?.[field] ?? []); - } - const overlap = findCrossClassOverlap(classes, platform); - if (overlap) { - fail(`'${entry.entryId}' on ${platform}/${architecture}: ${overlap}`); - } - } - } - } - - return { catalogSchemaVersion: contract.catalogSchemaVersion, catalogRevision, entries }; -} diff --git a/policy-catalog/src/cli.ts b/policy-catalog/src/cli.ts deleted file mode 100644 index e65aa05bd..000000000 --- a/policy-catalog/src/cli.ts +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Command-line harness over the public library API. It is intended for -// contributors, CI, and functional tests; it is not a sandbox launcher. -// There are exactly three commands, one per use: -// -// policy-catalog resolve [--catalog DIR] [--diagnostics] [--platform P] -// [--architecture A] [--revision R] [--project-root PATH] -// [--symbol name=value]... [--allow-weak] -// [--purl URL] [--detected-version V] ... -// policy-catalog inspect [--catalog DIR] -// policy-catalog validate [--catalog DIR] [--base-ref REF] -// -// resolve runs resolveSandboxPolicy / resolveSandboxPolicyWithDiagnostics. -// inspect prints getCatalogInfo() and listCatalogEntries() (metadata only). -// validate checks integrity, the catalog contract (including dependency -// cycles), entry-revision history, and, with --base-ref, that every -// revision published at REF is unchanged. -// -// --catalog defaults to the catalog bundled with this package. -// Exit codes: 0 success (including "no policy" for resolve), 1 library -// failure (PolicyCatalogError) or failed validation, 2 usage error. -// Output is JSON on stdout. -import { realpathSync } from 'node:fs'; -import { resolve as resolvePath } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { PolicyCatalogError } from './errors.js'; -import { PolicyCatalog } from './resolver.js'; -import { bundledCatalogStore, loadCatalogDirectory, type CatalogStore } from './store.js'; -import { validateCatalogDirectory } from './validate.js'; -import type { CatalogArchitecture, CatalogPlatform, ResolveContext, ToolCandidate } from './types.js'; - -const USAGE = 'usage: policy-catalog [options]'; - -class UsageError extends Error {} - -function takeValue(args: string[], index: number, flag: string): string { - const value = args[index + 1]; - if (value === undefined || value.startsWith('--')) { - throw new UsageError(`${flag} requires a value`); - } - return value; -} - -function storeFrom(catalogDir: string | undefined): CatalogStore { - return catalogDir === undefined - ? bundledCatalogStore() - : loadCatalogDirectory(pathToFileURL(`${resolvePath(catalogDir)}/`)); -} - -function bundledCatalogDir(): string { - return fileURLToPath(new URL('../catalog/', import.meta.url)); -} - -function print(value: unknown): void { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function rejectRest(args: string[], command: string): void { - if (args.length > 0) { - throw new UsageError(`${command}: unexpected argument '${args[0]}'`); - } -} - -export function main(argv: string[]): number { - const [command, ...rest] = argv; - try { - let catalogDir: string | undefined; - let baseRef: string | undefined; - const args: string[] = []; - for (let i = 0; i < rest.length; i += 1) { - if (rest[i] === '--catalog') { - catalogDir = takeValue(rest, i, '--catalog'); - i += 1; - } else if (rest[i] === '--base-ref' && command === 'validate') { - baseRef = takeValue(rest, i, '--base-ref'); - i += 1; - } else { - args.push(rest[i]); - } - } - switch (command) { - case 'inspect': { - rejectRest(args, command); - const catalog = new PolicyCatalog(storeFrom(catalogDir)); - print({ info: catalog.getCatalogInfo(), entries: catalog.listCatalogEntries() }); - return 0; - } - case 'validate': { - rejectRest(args, command); - const report = validateCatalogDirectory(catalogDir ?? bundledCatalogDir(), baseRef === undefined ? {} : { baseRef }); - print(report); - return report.ok ? 0 : 1; - } - case 'resolve': - return resolveCommand(args, catalogDir); - default: - throw new UsageError(command === undefined ? 'missing command' : `unknown command '${command}'`); - } - } catch (error) { - if (error instanceof UsageError) { - process.stderr.write(`policy-catalog: ${error.message}\n${USAGE}\n`); - return 2; - } - if (error instanceof PolicyCatalogError) { - print({ error: { code: error.code, message: error.message, details: { reason: error.reason } } }); - return 1; - } - throw error; - } -} - -function resolveCommand(args: string[], catalogDir: string | undefined): number { - const ctx: ResolveContext = {}; - // Null prototype: a '__proto__' name must stay an ordinary (rejected) key. - const symbols: Record = Object.create(null); - const tools: Array = []; - let diagnostics = false; - let purl: string | undefined; - let detectedVersion: string | undefined; - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - switch (arg) { - case '--diagnostics': - diagnostics = true; - break; - case '--allow-weak': - ctx.allowWeakIdentityFallback = true; - break; - case '--platform': - ctx.platform = takeValue(args, i++, arg) as CatalogPlatform; - break; - case '--architecture': - ctx.architecture = takeValue(args, i++, arg) as CatalogArchitecture; - break; - case '--revision': - ctx.catalogRevision = takeValue(args, i++, arg); - break; - case '--project-root': - ctx.projectRoot = takeValue(args, i++, arg); - break; - case '--symbol': { - const pair = takeValue(args, i++, arg); - const eq = pair.indexOf('='); - if (eq <= 0) { - throw new UsageError('--symbol expects name=value'); - } - symbols[pair.slice(0, eq)] = pair.slice(eq + 1); - break; - } - case '--purl': - purl = takeValue(args, i++, arg); - break; - case '--detected-version': - detectedVersion = takeValue(args, i++, arg); - break; - default: - if (arg.startsWith('--')) { - throw new UsageError(`unknown option '${arg}'`); - } - // --purl / --detected-version apply to the next tool name only. - tools.push(purl === undefined && detectedVersion === undefined - ? arg - : { invocationName: arg, ...(purl !== undefined ? { packageUrl: purl } : {}), ...(detectedVersion !== undefined ? { detectedVersion } : {}) }); - purl = undefined; - detectedVersion = undefined; - } - } - if (purl !== undefined || detectedVersion !== undefined) { - throw new UsageError('--purl/--detected-version must precede a tool name'); - } - if (Object.keys(symbols).length > 0) { - ctx.symbols = symbols; - } - const catalog = new PolicyCatalog(storeFrom(catalogDir)); - if (diagnostics) { - print(catalog.resolveSandboxPolicyWithDiagnostics(tools, ctx)); - } else { - // `null` is JSON's rendering of "no policy"; it is never an empty policy. - print(catalog.resolveSandboxPolicy(tools, ctx) ?? null); - } - return 0; -} - -// npm installs the bin as a symlink on POSIX, so compare real paths; a plain -// URL comparison would silently do nothing when run through the symlink. -function invokedDirectly(): boolean { - const entry = process.argv[1]; - if (entry === undefined) { - return false; - } - try { - return realpathSync(resolvePath(entry)) === realpathSync(fileURLToPath(import.meta.url)); - } catch { - return false; - } -} - -if (invokedDirectly()) { - process.exitCode = main(process.argv.slice(2)); -} diff --git a/policy-catalog/src/errors.ts b/policy-catalog/src/errors.ts deleted file mode 100644 index fb98d9d8f..000000000 --- a/policy-catalog/src/errors.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Error codes, reused from MXC's closed `MxcError` code set (MXC - * `sdk/node/src/errors.ts`, Rust `MxcErrorCode`) where the meaning fits. - * The library adds no code of its own. Warnings stay plain strings, as they - * do everywhere in MXC. - */ -export type PolicyCatalogErrorCode = - /** Catalog data or the selected composition violates the catalog contract. */ - | 'policy_validation' - /** The caller's ResolveContext or ToolCandidate is structurally invalid. */ - | 'malformed_request' - /** The host platform or native architecture has no catalog selector, or cannot be detected. */ - | 'unsupported_containment' - /** Installed catalog data is unavailable, unreadable, or fails integrity. */ - | 'backend_error'; - -/** - * Stable, language-neutral sub-reason carried in `details.reason`, following - * MXC's convention of putting structured information in `details`. Shared - * conformance fixtures and the cross-language check assert on - * `code` + `details.reason`, so every language binding must preserve both. - * A library failure is never a "no match" result (design §5). - */ -export type PolicyCatalogErrorReason = - /** policy_validation: catalog, manifest, or contract data is invalid (including dependency cycles and unknown symbols). */ - | 'invalid_catalog' - /** policy_validation: selected entries or resolved paths cannot be composed. */ - | 'composition_conflict' - /** malformed_request: invalid ResolveContext or ToolCandidate. */ - | 'invalid_context' - /** unsupported_containment: host platform/architecture has no selector or cannot be detected. */ - | 'unsupported_host' - /** backend_error: bundled data does not match its published digest, or cannot be read. */ - | 'integrity' - /** backend_error: an explicitly requested catalog revision is not installed. */ - | 'revision_unavailable'; - -/** The one code each reason maps to. */ -export const ERROR_CODE_FOR_REASON: Readonly> = { - invalid_catalog: 'policy_validation', - composition_conflict: 'policy_validation', - invalid_context: 'malformed_request', - unsupported_host: 'unsupported_containment', - integrity: 'backend_error', - revision_unavailable: 'backend_error', -}; - -export class PolicyCatalogError extends Error { - readonly code: PolicyCatalogErrorCode; - readonly details: { readonly reason: PolicyCatalogErrorReason }; - - constructor(reason: PolicyCatalogErrorReason, message: string) { - const code = ERROR_CODE_FOR_REASON[reason]; - super(`[${code}] ${message}`); - this.name = 'PolicyCatalogError'; - this.code = code; - this.details = Object.freeze({ reason }); - } - - /** The stable sub-reason; shorthand for `details.reason`. */ - get reason(): PolicyCatalogErrorReason { - return this.details.reason; - } -} diff --git a/policy-catalog/src/history.ts b/policy-catalog/src/history.ts deleted file mode 100644 index 51e963a6d..000000000 --- a/policy-catalog/src/history.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { canonicalJson } from './canonical-json.js'; -import { PolicyCatalogError } from './errors.js'; -import { compareCatalogRevisions, type CatalogEntry, type CatalogRevision } from './catalog.js'; -import type { CatalogStore } from './store.js'; - -function entrySemanticKey(entry: CatalogEntry): string { - const { entryRevision: _ignored, ...rest } = entry; - return canonicalJson(rest); -} - -/** - * Checks entry-revision monotonicity between two consecutive published - * catalog revisions (design §4.1, §10): a semantically changed entry must bump - * `entryRevision`; an unchanged entry must keep it; it never decreases. - */ -export function checkEntryRevisions(previous: CatalogRevision, next: CatalogRevision): string[] { - const errors: string[] = []; - if (compareCatalogRevisions(previous.catalogRevision, next.catalogRevision) >= 0) { - errors.push(`catalog revision '${next.catalogRevision}' must be newer than '${previous.catalogRevision}'`); - } - const before = new Map(previous.entries.map(entry => [entry.entryId, entry])); - for (const entry of next.entries) { - const old = before.get(entry.entryId); - if (!old) { - continue; - } - const changed = entrySemanticKey(old) !== entrySemanticKey(entry); - if (changed && entry.entryRevision <= old.entryRevision) { - errors.push(`${next.catalogRevision}: '${entry.entryId}' changed but entryRevision did not increase (${old.entryRevision} -> ${entry.entryRevision})`); - } else if (!changed && entry.entryRevision !== old.entryRevision) { - errors.push(`${next.catalogRevision}: '${entry.entryId}' is unchanged but entryRevision moved (${old.entryRevision} -> ${entry.entryRevision})`); - } - } - return errors; -} - -/** - * Validates every installed revision (integrity + contract) and the - * entry-revision chain between consecutive revisions. - */ -export function checkStoreHistory(store: CatalogStore): string[] { - const errors: string[] = []; - let previous: CatalogRevision | undefined; - for (const id of store.availableRevisions) { - let current: CatalogRevision; - try { - current = store.revision(id); - } catch (error) { - errors.push(error instanceof PolicyCatalogError ? error.message : String(error)); - previous = undefined; - continue; - } - if (previous) { - errors.push(...checkEntryRevisions(previous, current)); - } - previous = current; - } - return errors; -} - -/** Raw published state used to compare a proposed change against its base. */ -export interface PublishedState { - manifest: { revisions: Array<{ catalogRevision: string; file: string; sha256: string }> }; - /** Raw file text keyed by manifest `file`. */ - files: ReadonlyMap; -} - -/** - * Enforces immutability of published revisions across a proposed change - * (design §10): previously published revisions keep their manifest entry, - * digest, and file bytes, and new revisions are only appended. - */ -export function checkPublishedImmutability(base: PublishedState, proposed: PublishedState): string[] { - const errors: string[] = []; - base.manifest.revisions.forEach((published, index) => { - const now = proposed.manifest.revisions[index]; - if (!now || now.catalogRevision !== published.catalogRevision) { - errors.push(`published revision '${published.catalogRevision}' was removed or reordered in the manifest`); - return; - } - if (now.file !== published.file || now.sha256 !== published.sha256) { - errors.push(`published revision '${published.catalogRevision}' manifest entry was modified`); - } - // Compare canonical content, not raw bytes, so checkout line-ending - // conversion cannot produce a false positive. - const canonical = (text: string | undefined): string | undefined => { - if (text === undefined) { - return undefined; - } - try { - return canonicalJson(JSON.parse(text)); - } catch { - return `invalid:${text}`; - } - }; - const baseContent = canonical(base.files.get(published.file)); - const proposedContent = canonical(proposed.files.get(published.file)); - if (baseContent !== undefined && proposedContent !== baseContent) { - errors.push(`published revision file '${published.file}' was modified; publish a new revision instead`); - } - }); - return errors; -} diff --git a/policy-catalog/src/host.ts b/policy-catalog/src/host.ts deleted file mode 100644 index d0f2e149d..000000000 --- a/policy-catalog/src/host.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { execFileSync } from 'node:child_process'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { PolicyCatalogError } from './errors.js'; -import type { CatalogArchitecture, CatalogPlatform } from './types.js'; - -/** Host facts the resolver uses only when the caller omits them. */ -export interface HostEnvironment { - platform(): CatalogPlatform; - /** - * The device's native system architecture (design §4.4). This is not the - * architecture of the process hosting the library, and not a detected tool - * build. Throw when it cannot be determined; never guess. - */ - nativeArchitecture(): CatalogArchitecture; - /** Approved host-known symbols (`source: "host"` in the contract) for the current host. */ - symbol(name: string): string | undefined; -} - -function hostPlatform(): CatalogPlatform { - switch (os.platform()) { - case 'win32': - return 'windows'; - case 'darwin': - return 'macos'; - case 'linux': - return 'linux'; - default: - throw new PolicyCatalogError('unsupported_host', `host platform '${os.platform()}' has no catalog selector`); - } -} - -/** Maps an OS-reported machine/architecture string to a catalog selector. */ -export function architectureFromMachine(machine: string): CatalogArchitecture | undefined { - switch (machine.trim().toLowerCase()) { - case 'x86_64': - case 'amd64': - case 'x64': - return 'x64'; - case 'arm64': - case 'aarch64': - return 'arm64'; - default: - return undefined; - } -} - -function run(file: string, args: string[]): string { - return execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); -} - -/** - * Reads the native architecture without trusting the current process's view. - * - * - Windows: an x64 process emulated on ARM64 sees `PROCESSOR_ARCHITECTURE=AMD64` - * and an x64 `os.machine()`. The machine-wide value in the registry reports - * the native architecture. - * - macOS: a Rosetta-translated process sees `x86_64` from `uname`. - * `hw.optional.arm64` reports Apple silicon regardless of translation, and - * Intel Macs do not define it. - * - Linux: `uname` reports the kernel's machine type. - * - * Executables are invoked by absolute path so PATH cannot redirect them. - */ -function detectNativeArchitecture(): CatalogArchitecture { - const platform = os.platform(); - let reported: string | undefined; - try { - if (platform === 'win32') { - const reg = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'reg.exe'); - const output = run(reg, [ - 'query', - 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', - '/v', - 'PROCESSOR_ARCHITECTURE', - ]); - reported = /PROCESSOR_ARCHITECTURE\s+REG_SZ\s+(\S+)/i.exec(output)?.[1]; - } else if (platform === 'darwin') { - let appleSilicon = false; - try { - appleSilicon = run('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64']).trim() === '1'; - } catch { - // The key is absent on Intel Macs. - } - reported = appleSilicon ? 'arm64' : os.machine(); - } else { - reported = os.machine(); - } - } catch (error) { - throw new PolicyCatalogError('unsupported_host', `native system architecture could not be determined: ${(error as Error).message}`); - } - const architecture = reported === undefined ? undefined : architectureFromMachine(reported); - if (!architecture) { - throw new PolicyCatalogError('unsupported_host', `native system architecture '${reported ?? 'unknown'}' has no catalog selector`); - } - return architecture; -} - -let nativeArchitectureCache: CatalogArchitecture | undefined; - -/** Default host environment. The native architecture is detected once per process, on first need. */ -export const nodeHostEnvironment: HostEnvironment = { - platform: hostPlatform, - nativeArchitecture() { - nativeArchitectureCache ??= detectNativeArchitecture(); - return nativeArchitectureCache; - }, - symbol(name) { - switch (name) { - case 'user_home': - return os.homedir() || undefined; - case 'temp_dir': - return os.tmpdir() || undefined; - default: - return undefined; - } - }, -}; diff --git a/policy-catalog/src/index.ts b/policy-catalog/src/index.ts deleted file mode 100644 index 0c29a886e..000000000 --- a/policy-catalog/src/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Runtime lookup (design §5.1) and setup/inspection (design §5.2) are -// separate entry points. None of them launches a sandbox, contacts a network -// service, or writes consumer state. -export { - resolveSandboxPolicy, - resolveSandboxPolicyWithDiagnostics, - listCatalogEntries, - getCatalogInfo, - PolicyCatalog, -} from './resolver.js'; - -export { nodeHostEnvironment, type HostEnvironment } from './host.js'; - -export { - CatalogStore, - loadCatalogDirectory, - bundledCatalogStore, - type CatalogSource, - type CatalogManifest, - type ManifestRevision, -} from './store.js'; - -export { - PolicyCatalogError, - ERROR_CODE_FOR_REASON, - type PolicyCatalogErrorCode, - type PolicyCatalogErrorReason, -} from './errors.js'; - -export type { - CatalogArchitecture, - CatalogEntryMetadata, - CatalogIdentityMetadata, - CatalogInfo, - CatalogNetworkRule, - CatalogPlatform, - CatalogSandboxPolicy, - IdentityStrength, - ResolveContext, - SandboxConfigResolution, - ToolCandidate, - ToolInput, -} from './types.js'; diff --git a/policy-catalog/src/purl.ts b/policy-catalog/src/purl.ts deleted file mode 100644 index 8947c7ca8..000000000 --- a/policy-catalog/src/purl.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** Parsed package URL, reduced to the parts identity matching uses. */ -export interface ParsedPurl { - /** `type/[namespace/]name` with the type lower-cased. Version-free. */ - key: string; - version?: string; -} - -/** - * Parses a package URL (`pkg:type/namespace/name@version?qualifiers#subpath`). - * Qualifiers and subpath do not participate in v1 identity matching. - */ -export function parsePurl(value: string): ParsedPurl | undefined { - if (!value.startsWith('pkg:')) { - return undefined; - } - let rest = value.slice('pkg:'.length); - rest = rest.split('#', 1)[0].split('?', 1)[0]; - const lastSlash = rest.lastIndexOf('/'); - const at = rest.lastIndexOf('@'); - let version: string | undefined; - if (at > lastSlash) { - // Malformed percent-encoding makes the package URL invalid; it must not - // escape as a URIError instead of a library result. - try { - version = decodeURIComponent(rest.slice(at + 1)); - } catch { - return undefined; - } - rest = rest.slice(0, at); - } - const segments = rest.split('/'); - if (segments.length < 2 || segments.some(segment => segment.length === 0)) { - return undefined; - } - const [type, ...path] = segments; - if (!/^[a-zA-Z][a-zA-Z0-9.+-]*$/.test(type)) { - return undefined; - } - return { - key: `${type.toLowerCase()}/${path.join('/')}`, - ...(version !== undefined && version.length > 0 ? { version } : {}), - }; -} diff --git a/policy-catalog/src/resolver.ts b/policy-catalog/src/resolver.ts deleted file mode 100644 index b8934932c..000000000 --- a/policy-catalog/src/resolver.ts +++ /dev/null @@ -1,490 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { PolicyCatalogError } from './errors.js'; -import { nodeHostEnvironment, type HostEnvironment } from './host.js'; -import { parsePurl, type ParsedPurl } from './purl.js'; -import { satisfiesVersionRange } from './version-range.js'; -import { - ARCHITECTURES, - COMPOSABLE_FILESYSTEM_FIELDS, - IDENTITY_STRENGTH, - PLATFORMS, - caseKey, - compositionViolation, - dependencyClosure, - findCrossClassOverlap, - isAbsolutePath, - normalizePath, - pathKeySegments, - policySymbols, - selectVariant, - type AccessClass, - type CatalogEntry, - type ClosureNode, - type IdentityPredicate, - type VariantSelection, -} from './catalog.js'; -import { bundledCatalogStore, type CatalogStore } from './store.js'; -import type { - CatalogArchitecture, - CatalogEntryMetadata, - CatalogInfo, - CatalogPlatform, - CatalogSandboxPolicy, - ResolveContext, - SandboxConfigResolution, - ToolCandidate, - ToolInput, -} from './types.js'; - -type Diagnostics = SandboxConfigResolution['diagnostics']; -type ToolRecord = Diagnostics['tools'][number]; -type DependencyRecord = Diagnostics['resolvedDependencies'][number]; - -interface EntryMatch { - entry: CatalogEntry; - selection: VariantSelection; - satisfied: IdentityPredicate[]; -} - -const SYMBOL_PATTERN = /\$\{([a-z][a-z0-9_]*)\}/g; - -function compareStrings(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -function toCandidate(input: ToolInput, index: number): ToolCandidate { - const candidate = typeof input === 'string' ? { invocationName: input } : input; - if (candidate === null || typeof candidate !== 'object') { - throw new PolicyCatalogError('invalid_context', `tool input ${index} must be a string or a ToolCandidate`); - } - if (typeof candidate.invocationName !== 'string' || candidate.invocationName.length === 0) { - throw new PolicyCatalogError('invalid_context', `tool input ${index}: invocationName must be a non-empty string`); - } - if (/[\\/]/.test(candidate.invocationName)) { - throw new PolicyCatalogError('invalid_context', `tool input ${index}: invocationName must be a bare name, not a path`); - } - for (const key of ['packageUrl', 'detectedVersion'] as const) { - if (candidate[key] !== undefined && (typeof candidate[key] !== 'string' || candidate[key].length === 0)) { - throw new PolicyCatalogError('invalid_context', `tool input ${index}: ${key} must be a non-empty string when present`); - } - } - return candidate; -} - -function describeInput(index: number, tool: ToolCandidate): string { - return `input ${index} ('${tool.invocationName}')`; -} - -/** - * Read-only view over one catalog store. Runtime resolution and inspection are - * separate operations; neither mutates catalog data or any consumer state. - */ -export class PolicyCatalog { - private readonly store: CatalogStore; - private readonly host: HostEnvironment; - - constructor(store: CatalogStore, host: HostEnvironment = nodeHostEnvironment) { - this.store = store; - this.host = host; - } - - // ------------------------------------------------------------------------- - // Setup and inspection (design §5.2) - // ------------------------------------------------------------------------- - - getCatalogInfo(): CatalogInfo { - const revision = this.store.revision(); - return { catalogSchemaVersion: revision.catalogSchemaVersion, catalogRevision: revision.catalogRevision }; - } - - /** Metadata for every entry in the installed revision, ordered by `entryId`. Never includes a policy body. */ - listCatalogEntries(): CatalogEntryMetadata[] { - const revision = this.store.revision(); - return [...revision.entries] - .sort((a, b) => compareStrings(a.entryId, b.entryId)) - .map(entry => ({ - catalogRevision: revision.catalogRevision, - entryId: entry.entryId, - entryRevision: entry.entryRevision, - displayName: entry.displayName, - identity: entry.identity.map(predicate => (predicate.kind === 'purl' - ? { kind: 'purl' as const, value: predicate.value, ...(predicate.versionRange !== undefined ? { versionRange: predicate.versionRange } : {}) } - : { kind: 'invocation-name' as const, names: [...predicate.names] })), - platformVariants: entry.platformVariants.map(variant => ({ - platform: variant.when.platform, - ...(variant.when.architecture !== undefined ? { architecture: variant.when.architecture } : {}), - dependencyEntryIds: (variant.dependencies ?? []).map(dependency => dependency.entryId), - sandboxPolicyVersion: variant.sandboxPolicy.version, - })), - provenance: { ...entry.provenance }, - })); - } - - // ------------------------------------------------------------------------- - // Runtime lookup (design §5.1) - // ------------------------------------------------------------------------- - - /** Returns the composed candidate policy, or `undefined` when no policy can be resolved. */ - resolveSandboxPolicy(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): CatalogSandboxPolicy | undefined { - return this.resolve(tools, ctx).policy; - } - - /** - * Resolves one tool or an array of tools in one pass and returns the - * composed candidate policy with attribution and warnings. - * - * `policy` is `undefined` for an empty input, when no input matched, or - * when a selected entry requires an unresolved symbol. It is never an empty - * stand-in policy. Library failures (integrity, unavailable revision, - * invalid context, host detection, composition conflict) throw - * {@link PolicyCatalogError}; they are never reported as absence. - * - * The result is a candidate lower bound, not authorization. Consumers keep - * authorization, ceilings, persistence, composition, approval, and audit. - */ - resolveSandboxPolicyWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext = {}): SandboxConfigResolution { - return this.resolve(tools, ctx); - } - - private resolve(tools: ToolInput | readonly ToolInput[], ctx: ResolveContext): SandboxConfigResolution { - // A single input is exactly a one-element array (design §5.1). - const inputs = (Array.isArray(tools) ? tools : [tools]) as readonly ToolInput[]; - const candidates = inputs.map(toCandidate); - this.validateContext(ctx); - const revision = this.store.revision(ctx.catalogRevision); - const platform = ctx.platform ?? this.host.platform(); - const allowWeak = ctx.allowWeakIdentityFallback === true; - - // The native architecture is resolved lazily: a lookup that never needs - // variant selection must not fail on host detection (design §4.4). - let architecture = ctx.architecture; - const effectiveArchitecture = (): CatalogArchitecture => { - architecture ??= this.host.nativeArchitecture(); - return architecture; - }; - - const warnings: string[] = []; - const toolRecords: ToolRecord[] = []; - const selected = new Map(); - const byId = new Map(revision.entries.map(entry => [entry.entryId, entry])); - // Entry order in the catalog file never selects or excludes a match. - const ordered = [...revision.entries].sort((a, b) => compareStrings(a.entryId, b.entryId)); - - candidates.forEach((tool, inputIndex) => { - const matches = this.matchTool(ordered, tool, inputIndex, platform, allowWeak, effectiveArchitecture, warnings); - toolRecords.push({ - inputIndex, - matches: matches.map(match => ({ - entryId: match.entry.entryId, - entryRevision: match.entry.entryRevision, - matchedIdentities: match.satisfied.map(predicate => ({ kind: predicate.kind, strength: IDENTITY_STRENGTH[predicate.kind] })), - })), - }); - if (matches.length > 1) { - warnings.push(`${describeInput(inputIndex, tool)} matched ${matches.length} entries (${matches.map(m => m.entry.entryId).join(', ')}); all contribute`); - } - for (const match of matches) { - const closure = dependencyClosure(match.entry, match.selection, byId, platform, effectiveArchitecture()); - if (!closure.ok) { - // A validated revision cannot reach this; treat it as corrupt data. - throw new PolicyCatalogError('invalid_catalog', `dependency resolution failed: ${closure.reason} (${closure.detail})`); - } - for (const node of closure.nodes) { - if (!selected.has(node.entry.entryId)) { - selected.set(node.entry.entryId, node); - } - } - } - }); - - const diagnostics: Diagnostics = { - catalogRevision: revision.catalogRevision, - tools: toolRecords, - resolvedDependencies: dependencyRecords([...selected.values()], byId), - warnings, - }; - const nodes = [...selected.values()]; - if (nodes.length === 0) { - return { policy: undefined, diagnostics }; - } - - if (ctx.architecture === undefined) { - warnings.push(`architecture was not specified; variants were selected for the native system architecture '${effectiveArchitecture()}'; the tool's architecture was not verified`); - } - for (const node of nodes) { - if (!node.exact) { - warnings.push(`${node.entry.entryId} uses its architecture-neutral ${platform} variant; no ${effectiveArchitecture()}-specific variant exists`); - } - } - - const violation = compositionViolation(nodes); - if (violation) { - throw new PolicyCatalogError('composition_conflict', `selected entries cannot be composed: ${violation}`); - } - - const symbols = this.resolveSymbols(nodes, ctx, platform, warnings); - if (!symbols) { - return { policy: undefined, diagnostics }; - } - return { policy: composePolicy(nodes, symbols, platform), diagnostics }; - } - - /** - * Collects every eligible entry for one input, ordered by `entryId` - * (design §4.3). Matching is additive: a strong match never suppresses - * another entry's eligible weak match. - */ - private matchTool( - ordered: readonly CatalogEntry[], - tool: ToolCandidate, - inputIndex: number, - platform: CatalogPlatform, - allowWeak: boolean, - architecture: () => CatalogArchitecture, - warnings: string[], - ): EntryMatch[] { - let purl: ParsedPurl | undefined; - if (tool.packageUrl !== undefined) { - purl = parsePurl(tool.packageUrl); - if (!purl) { - throw new PolicyCatalogError('invalid_context', `${describeInput(inputIndex, tool)}: '${tool.packageUrl}' is not a valid package URL`); - } - } - // The same casing rule as path comparison (catalog.ts `foldsCase`). - const invocation = caseKey(tool.invocationName, platform); - - const matches: EntryMatch[] = []; - const skipped: string[] = []; - for (const entry of ordered) { - const satisfied = entry.identity.filter(predicate => (predicate.kind === 'purl' - ? purl !== undefined && parsePurl(predicate.value)?.key === purl.key - : predicate.names.some(name => caseKey(name, platform) === invocation))); - if (satisfied.length === 0) { - continue; - } - const strong = satisfied.some(predicate => IDENTITY_STRENGTH[predicate.kind] === 'strong'); - if (!strong && !allowWeak) { - skipped.push(`${entry.entryId} matched only by invocation name and allowWeakIdentityFallback is not enabled`); - continue; - } - const selection = selectVariant(entry, platform, architecture()); - if (!selection) { - skipped.push(`${entry.entryId} has no variant for ${platform}/${architecture()}`); - continue; - } - for (const predicate of satisfied) { - if (predicate.kind !== 'purl' || predicate.versionRange === undefined) { - continue; - } - const evidence = tool.detectedVersion ?? purl?.version; - if (evidence === undefined) { - continue; - } - const inRange = satisfiesVersionRange(evidence, predicate.versionRange); - if (inRange !== true) { - warnings.push(`${describeInput(inputIndex, tool)}: detected version '${evidence}' ${inRange === false ? 'is outside' : 'could not be compared with'} the reviewed range '${predicate.versionRange}' for ${entry.entryId}`); - } - } - if (!strong) { - warnings.push(`${describeInput(inputIndex, tool)} matched ${entry.entryId} only by invocation name (weak identity)`); - } - matches.push({ entry, selection, satisfied }); - } - if (matches.length === 0) { - warnings.push(`${describeInput(inputIndex, tool)} matched no eligible catalog entry${skipped.length > 0 ? `: ${skipped.join('; ')}` : ''}`); - } - return matches; - } - - private validateContext(ctx: ResolveContext): void { - if (ctx === null || typeof ctx !== 'object') { - throw new PolicyCatalogError('invalid_context', 'ResolveContext must be an object'); - } - if (ctx.platform !== undefined && !PLATFORMS.includes(ctx.platform)) { - throw new PolicyCatalogError('invalid_context', `ResolveContext.platform '${String(ctx.platform)}' is unsupported`); - } - if (ctx.architecture !== undefined && !ARCHITECTURES.includes(ctx.architecture)) { - throw new PolicyCatalogError('invalid_context', `ResolveContext.architecture '${String(ctx.architecture)}' is unsupported`); - } - if (ctx.projectRoot !== undefined && (typeof ctx.projectRoot !== 'string' || ctx.projectRoot.length === 0)) { - throw new PolicyCatalogError('invalid_context', 'ResolveContext.projectRoot must be a non-empty string when present'); - } - const contract = this.store.contract; - for (const [name, value] of Object.entries(ctx.symbols ?? {})) { - const definition = Object.hasOwn(contract.symbols, name) ? contract.symbols[name] : undefined; - if (!definition) { - throw new PolicyCatalogError('invalid_context', `ResolveContext.symbols.${name} is not a catalog symbol`); - } - if (definition.source === 'context') { - throw new PolicyCatalogError('invalid_context', `symbol '${name}' is supplied through ResolveContext.projectRoot, not symbols`); - } - if (typeof value !== 'string') { - throw new PolicyCatalogError('invalid_context', `ResolveContext.symbols.${name} must be a string`); - } - } - } - - /** - * Resolves every symbol the selected entries need. Returns `undefined` - * (with warnings) when any required symbol is unresolved: a selected - * requirement is never silently dropped to produce a partial policy. - */ - private resolveSymbols( - nodes: readonly ClosureNode[], - ctx: ResolveContext, - platform: CatalogPlatform, - warnings: string[], - ): Map | undefined { - const contract = this.store.contract; - const values = new Map(); - const missing = new Map(); - for (const node of nodes) { - for (const name of policySymbols(node.variant.sandboxPolicy)) { - if (values.has(name)) { - continue; - } - const definition = contract.symbols[name]; - let value: string | undefined; - if (definition.source === 'context') { - value = ctx.projectRoot; - } else { - value = ctx.symbols !== undefined && Object.hasOwn(ctx.symbols, name) ? ctx.symbols[name] : undefined; - // Host-derived symbols describe the current host only; they are - // never derived for an explicitly different target platform. - if (value === undefined && definition.source === 'host' && platform === this.host.platform()) { - value = this.host.symbol(name); - } - } - if (value === undefined) { - missing.set(name, [...(missing.get(name) ?? []), node.entry.entryId]); - continue; - } - if (value.includes('${') || !isAbsolutePath(value, platform)) { - throw new PolicyCatalogError('invalid_context', `symbol '${name}' must resolve to an absolute ${platform} path`); - } - values.set(name, value); - } - } - if (missing.size > 0) { - for (const [name, entryIds] of [...missing].sort(([a], [b]) => compareStrings(a, b))) { - const hint = contract.symbols[name].source === 'context' ? 'ResolveContext.projectRoot' : `ResolveContext.symbols.${name}`; - warnings.push(`required symbol '${name}' (needed by ${entryIds.join(', ')}) is unresolved; supply ${hint}; no policy was returned`); - } - return undefined; - } - return values; - } -} - -/** - * Distinct dependency edges among the selected entries, ordered by entryId, - * entryRevision, then requiredVersionRange (absent first) (design §5.1). - */ -function dependencyRecords(nodes: readonly ClosureNode[], byId: ReadonlyMap): DependencyRecord[] { - const records = new Map(); - for (const node of nodes) { - for (const dependency of node.variant.dependencies ?? []) { - const target = byId.get(dependency.entryId)!; - const key = JSON.stringify([target.entryId, target.entryRevision, dependency.versionRange ?? null]); - records.set(key, { - entryId: target.entryId, - entryRevision: target.entryRevision, - ...(dependency.versionRange !== undefined ? { requiredVersionRange: dependency.versionRange } : {}), - }); - } - } - return [...records.values()].sort((a, b) => compareStrings(a.entryId, b.entryId) - || a.entryRevision - b.entryRevision - || (a.requiredVersionRange === undefined ? (b.requiredVersionRange === undefined ? 0 : -1) - : b.requiredVersionRange === undefined ? 1 : compareStrings(a.requiredVersionRange, b.requiredVersionRange))); -} - -/** Composes the v1 vocabulary (design §4.5). Callers have already checked `compositionViolation`. */ -function composePolicy( - nodes: readonly ClosureNode[], - symbols: ReadonlyMap, - platform: CatalogPlatform, -): CatalogSandboxPolicy { - const classes: Partial> = {}; - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - const seen = new Set(); - const out: string[] = []; - for (const node of nodes) { - for (const template of node.variant.sandboxPolicy.filesystem?.[field] ?? []) { - const resolved = normalizePath(template.replace(SYMBOL_PATTERN, (_match, name: string) => symbols.get(name)!), platform); - const key = pathKeySegments(resolved, platform).join('\u0000'); - if (!seen.has(key)) { - seen.add(key); - out.push(resolved); - } - } - } - if (out.length > 0) { - classes[field] = out; - } - } - const overlap = findCrossClassOverlap(classes, platform); - if (overlap) { - // Symbol values can make distinct templates collide. The resolver never - // chooses an access class implicitly. - throw new PolicyCatalogError('composition_conflict', `resolved paths overlap across access classes: ${overlap}`); - } - - const root = nodes[0].variant.sandboxPolicy; - const result: CatalogSandboxPolicy = { version: root.version }; - if (nodes.some(node => node.variant.sandboxPolicy.filesystem !== undefined)) { - result.filesystem = {}; - for (const field of COMPOSABLE_FILESYSTEM_FIELDS) { - if (classes[field]) { - result.filesystem[field] = classes[field]; - } - } - } - // compositionViolation guarantees these fields exist only when exactly one - // entry is selected, so they are copied, never merged. - if (root.network !== undefined) { - result.network = structuredClone(root.network); - } - if (root.ui !== undefined) { - result.ui = structuredClone(root.ui); - } - if (root.timeoutMs !== undefined) { - result.timeoutMs = root.timeoutMs; - } - return result; -} - -let defaultCatalog: PolicyCatalog | undefined; - -function bundled(): PolicyCatalog { - defaultCatalog ??= new PolicyCatalog(bundledCatalogStore()); - return defaultCatalog; -} - -/** Composed candidate policy for one tool, from the bundled catalog. */ -export function resolveSandboxPolicy(tool: ToolInput, ctx?: ResolveContext): CatalogSandboxPolicy | undefined; -/** Composed candidate policy for several tools, from the bundled catalog. */ -export function resolveSandboxPolicy(tools: readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined; -export function resolveSandboxPolicy(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): CatalogSandboxPolicy | undefined { - return bundled().resolveSandboxPolicy(tools, ctx); -} - -/** Composed candidate policy plus attribution for one tool. See {@link PolicyCatalog.resolveSandboxPolicyWithDiagnostics}. */ -export function resolveSandboxPolicyWithDiagnostics(tool: ToolInput, ctx?: ResolveContext): SandboxConfigResolution; -/** Composed candidate policy plus attribution for several tools. */ -export function resolveSandboxPolicyWithDiagnostics(tools: readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution; -export function resolveSandboxPolicyWithDiagnostics(tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution { - return bundled().resolveSandboxPolicyWithDiagnostics(tools, ctx); -} - -/** Lists bundled catalog entry metadata. See {@link PolicyCatalog.listCatalogEntries}. */ -export function listCatalogEntries(): CatalogEntryMetadata[] { - return bundled().listCatalogEntries(); -} - -/** Reports the bundled catalog schema version and revision. */ -export function getCatalogInfo(): CatalogInfo { - return bundled().getCatalogInfo(); -} diff --git a/policy-catalog/src/store.ts b/policy-catalog/src/store.ts deleted file mode 100644 index dfaddf3aa..000000000 --- a/policy-catalog/src/store.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { readFileSync } from 'node:fs'; -import { canonicalSha256 } from './canonical-json.js'; -import { PolicyCatalogError } from './errors.js'; -import { - CATALOG_SCHEMA_VERSION, - compareCatalogRevisions, - isCatalogRevisionId, - validateCatalogRevision, - validateContract, - type CatalogContract, - type CatalogRevision, -} from './catalog.js'; - -/** One published revision as listed in `catalog/manifest.json`. */ -export interface ManifestRevision { - catalogRevision: string; - file: string; - sha256: string; -} - -export interface CatalogManifest { - catalogSchemaVersion: string; - defaultRevision: string; - revisions: ManifestRevision[]; -} - -/** In-memory source for a catalog store. Used by the file loader and by fixtures. */ -export interface CatalogSource { - contract: unknown; - manifest: unknown; - /** Reads the raw JSON of a manifest `file` entry. */ - readRevision(file: string): unknown; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function invalid(message: string): never { - throw new PolicyCatalogError('invalid_catalog', message); -} - -function deepFreeze(value: T): T { - if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { - Object.freeze(value); - for (const child of Object.values(value as Record)) { - deepFreeze(child); - } - } - return value; -} - -export function validateManifest(raw: unknown): CatalogManifest { - if (!isRecord(raw)) { - invalid('manifest root must be an object'); - } - for (const key of Object.keys(raw)) { - if (!['catalogSchemaVersion', 'defaultRevision', 'revisions'].includes(key)) { - invalid(`unsupported field 'manifest.${key}'`); - } - } - if (raw.catalogSchemaVersion !== CATALOG_SCHEMA_VERSION) { - invalid(`manifest.catalogSchemaVersion must be '${CATALOG_SCHEMA_VERSION}'`); - } - if (!Array.isArray(raw.revisions) || raw.revisions.length === 0) { - invalid('manifest.revisions must be a non-empty array'); - } - const revisions = raw.revisions.map((item, index): ManifestRevision => { - const at = `manifest.revisions[${index}]`; - if (!isRecord(item)) { - invalid(`'${at}' must be an object`); - } - for (const key of Object.keys(item)) { - if (!['catalogRevision', 'file', 'sha256'].includes(key)) { - invalid(`unsupported field '${at}.${key}'`); - } - } - const { catalogRevision, file, sha256 } = item; - if (typeof catalogRevision !== 'string' || !isCatalogRevisionId(catalogRevision)) { - invalid(`'${at}.catalogRevision' must match YYYY-MM-DD.N`); - } - if (file !== `revisions/${catalogRevision}.json`) { - invalid(`'${at}.file' must be 'revisions/${catalogRevision}.json'`); - } - if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256)) { - invalid(`'${at}.sha256' must be a lower-case hex SHA-256 digest`); - } - return { catalogRevision, file, sha256 }; - }); - // Published revisions are append-only and strictly increasing. - for (let index = 1; index < revisions.length; index += 1) { - if (compareCatalogRevisions(revisions[index - 1].catalogRevision, revisions[index].catalogRevision) >= 0) { - invalid(`manifest.revisions must be strictly increasing ('${revisions[index].catalogRevision}')`); - } - } - const defaultRevision = raw.defaultRevision; - if (typeof defaultRevision !== 'string' || !revisions.some(r => r.catalogRevision === defaultRevision)) { - invalid('manifest.defaultRevision must name a listed revision'); - } - return { catalogSchemaVersion: CATALOG_SCHEMA_VERSION, defaultRevision, revisions }; -} - -/** - * Read-only access to the locally installed, integrity-validated catalog - * revisions. Revisions are loaded lazily, verified against the manifest - * digest, validated against the contract, and deep-frozen. - */ -export class CatalogStore { - readonly contract: CatalogContract; - readonly manifest: CatalogManifest; - private readonly source: CatalogSource; - private readonly loaded = new Map(); - - constructor(source: CatalogSource) { - this.source = source; - this.contract = deepFreeze(validateContract(source.contract)); - this.manifest = deepFreeze(validateManifest(source.manifest)); - } - - get defaultRevision(): string { - return this.manifest.defaultRevision; - } - - get availableRevisions(): string[] { - return this.manifest.revisions.map(revision => revision.catalogRevision); - } - - /** - * Returns the requested revision, or the installed default when omitted. - * An explicitly requested revision that is not installed is an error; it is - * never substituted with a different revision. - */ - revision(catalogRevision?: string): CatalogRevision { - const id = catalogRevision ?? this.manifest.defaultRevision; - const cached = this.loaded.get(id); - if (cached) { - return cached; - } - const listed = this.manifest.revisions.find(revision => revision.catalogRevision === id); - if (!listed) { - throw new PolicyCatalogError('revision_unavailable', `catalog revision '${id}' is not installed`); - } - let raw: unknown; - try { - raw = this.source.readRevision(listed.file); - } catch (error) { - throw new PolicyCatalogError('integrity', `catalog revision '${id}' could not be read: ${(error as Error).message}`); - } - const digest = canonicalSha256(raw); - if (digest !== listed.sha256) { - throw new PolicyCatalogError( - 'integrity', - `catalog revision '${id}' digest ${digest} does not match the published digest ${listed.sha256}`, - ); - } - const revision = validateCatalogRevision(raw, this.contract); - if (revision.catalogRevision !== id) { - throw new PolicyCatalogError('integrity', `file '${listed.file}' declares revision '${revision.catalogRevision}', expected '${id}'`); - } - deepFreeze(revision); - this.loaded.set(id, revision); - return revision; - } -} - -function readJson(url: URL): unknown { - return JSON.parse(readFileSync(url, 'utf8')); -} - -/** Loads a store from a catalog directory (`contract.v1.json`, `manifest.json`, `revisions/`). */ -export function loadCatalogDirectory(directory: URL): CatalogStore { - const base = directory.href.endsWith('/') ? directory : new URL(`${directory.href}/`); - let contract: unknown; - let manifest: unknown; - try { - contract = readJson(new URL('contract.v1.json', base)); - manifest = readJson(new URL('manifest.json', base)); - } catch (error) { - throw new PolicyCatalogError('integrity', `catalog data could not be read: ${(error as Error).message}`); - } - return new CatalogStore({ - contract, - manifest, - readRevision: file => readJson(new URL(file, base)), - }); -} - -let bundled: CatalogStore | undefined; - -/** - * The catalog bundled with this package: `/catalog/`, resolved - * relative to the compiled `dist/` output. The package ships the same - * directory the repository validates, so there is no copy step. - */ -export function bundledCatalogStore(): CatalogStore { - bundled ??= loadCatalogDirectory(new URL('../catalog/', import.meta.url)); - return bundled; -} diff --git a/policy-catalog/src/tooling.ts b/policy-catalog/src/tooling.ts deleted file mode 100644 index d23840b81..000000000 --- a/policy-catalog/src/tooling.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Contribution and CI tooling (design §7). Not part of the runtime lookup API; -// exported under the `./tooling` subpath so validation scripts and tests can -// share the resolver's exact rules. -export { canonicalJson, canonicalSha256 } from './canonical-json.js'; -export { - validateCatalogRevision, - validateContract, - compareCatalogRevisions, - selectVariant, - normalizePath, - isAbsolutePath, - pathKeySegments, - foldsCase, - PLATFORMS, - ARCHITECTURES, - type CatalogContract, - type CatalogEntry, - type CatalogRevision, - type IdentityPredicate, - type PlatformVariant, -} from './catalog.js'; -export { validateManifest } from './store.js'; -export { - checkEntryRevisions, - checkStoreHistory, - checkPublishedImmutability, - type PublishedState, -} from './history.js'; -export { - validateCatalogDirectory, - checkAgainstBaseRef, - readPublishedStateAtRef, - type CatalogValidationReport, -} from './validate.js'; -export { isValidVersionRange, satisfiesVersionRange } from './version-range.js'; -export { architectureFromMachine } from './host.js'; -export { parsePurl } from './purl.js'; diff --git a/policy-catalog/src/types.ts b/policy-catalog/src/types.ts deleted file mode 100644 index 2852cddde..000000000 --- a/policy-catalog/src/types.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** Catalog platform selector values (design §4.4). */ -export type CatalogPlatform = 'windows' | 'linux' | 'macos'; - -/** Catalog architecture selector values (design §4.4). */ -export type CatalogArchitecture = 'x64' | 'arm64'; - -/** - * The catalog-supported subset of MXC's `SandboxPolicy` authoring contract. - * - * This library deliberately does not import the MXC SDK. The shape is - * structurally compatible with the MXC SDK `SandboxPolicy` type, so a consumer - * can pass a reviewed, authorized result to `createConfigFromPolicy()`. - * Backend-specific keys are not part of the catalog vocabulary. - */ -export interface CatalogSandboxPolicy { - version: string; - filesystem?: { - deniedPaths?: string[]; - readonlyPaths?: string[]; - readwritePaths?: string[]; - }; - network?: { - egress?: { - default?: 'allow' | 'deny'; - allow?: CatalogNetworkRule[]; - deny?: CatalogNetworkRule[]; - }; - ingress?: { - default?: 'allow' | 'deny'; - hostLoopback?: 'allow' | 'deny'; - }; - }; - ui?: { - allowWindows?: boolean; - clipboard?: 'none' | 'read' | 'write' | 'all'; - allowInputInjection?: boolean; - }; - timeoutMs?: number; -} - -export interface CatalogNetworkRule { - to?: Array<{ cidr: string; except?: string[] }>; - ports?: Array<{ protocol?: 'tcp' | 'udp' | 'icmp' | 'any'; port?: number; endPort?: number }>; -} - -/** Runtime lookup input (design §5.1). */ -export interface ToolCandidate { - invocationName: string; - packageUrl?: string; - detectedVersion?: string; -} - -/** A string is shorthand for `{ invocationName: tool }` (design §5.1). */ -export type ToolInput = string | ToolCandidate; - -/** Runtime lookup context, shared by every input in one lookup (design §5.1). */ -export interface ResolveContext { - projectRoot?: string; - symbols?: Record; - platform?: CatalogPlatform; - architecture?: CatalogArchitecture; - catalogRevision?: string; - allowWeakIdentityFallback?: boolean; -} - -/** Strength of a matched identity predicate. */ -export type IdentityStrength = 'strong' | 'weak'; - -/** Result of `resolveSandboxPolicyWithDiagnostics` (design §5.1). */ -export interface SandboxConfigResolution { - policy: CatalogSandboxPolicy | undefined; - diagnostics: { - catalogRevision: string; - tools: Array<{ - inputIndex: number; - matches: Array<{ - entryId: string; - entryRevision: number; - matchedIdentities: Array<{ - kind: string; - strength: IdentityStrength; - }>; - }>; - }>; - resolvedDependencies: Array<{ - entryId: string; - entryRevision: number; - requiredVersionRange?: string; - }>; - warnings: string[]; - }; -} - -/** Inspection metadata (design §5.2). */ -export type CatalogIdentityMetadata = - | { kind: 'purl'; value: string; versionRange?: string } - | { kind: 'invocation-name'; names: string[] }; - -/** Inspection metadata (design §5.2). It never exposes a policy body. */ -export interface CatalogEntryMetadata { - catalogRevision: string; - entryId: string; - entryRevision: number; - displayName: string; - identity: CatalogIdentityMetadata[]; - platformVariants: Array<{ - platform: CatalogPlatform; - architecture?: CatalogArchitecture; - dependencyEntryIds: string[]; - sandboxPolicyVersion: string; - }>; - provenance: { - method: string; - sourceRevision: string; - }; -} - -/** Result of `getCatalogInfo()` (design §5.2). */ -export interface CatalogInfo { - catalogSchemaVersion: string; - catalogRevision: string; -} diff --git a/policy-catalog/src/validate.ts b/policy-catalog/src/validate.ts deleted file mode 100644 index 48ffe61c2..000000000 --- a/policy-catalog/src/validate.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Catalog-directory validation shared by `policy-catalog validate` and the -// contribution pipeline (scripts/validate-catalog.mjs). Tooling only: the -// runtime lookup API never imports this module or runs git. -import { execFileSync } from 'node:child_process'; -import { readFileSync, realpathSync } from 'node:fs'; -import { isAbsolute, relative, resolve as resolvePath } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { PolicyCatalogError } from './errors.js'; -import { checkPublishedImmutability, checkStoreHistory, type PublishedState } from './history.js'; -import { loadCatalogDirectory, type CatalogStore } from './store.js'; - -export interface CatalogValidationReport { - /** True only when every requested check ran and passed. */ - ok: boolean; - catalogDir: string; - defaultRevision?: string; - revisions?: string[]; - /** Present only when a base ref was requested. */ - baseRef?: { - ref: string; - /** Published revisions at the base ref that were compared; 0 when the base has no catalog. */ - comparedRevisions: number; - }; - errors: string[]; -} - -function git(args: string[], cwd: string): string { - return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); -} - -function gitSucceeds(args: string[], cwd: string): boolean { - try { - git(args, cwd); - return true; - } catch { - return false; - } -} - -/** - * Reads the published catalog state at `baseRef` for the git repository that - * contains `catalogDir`. Returns `undefined` when the base ref has no catalog - * at that path (nothing is published yet). Throws a `validation` error when - * the check cannot be performed, so a typo in the ref or a directory outside - * git never passes silently. - */ -export function readPublishedStateAtRef(catalogDir: string, baseRef: string): PublishedState | undefined { - // git receives the ref as an argument; one that starts with '-' would be - // parsed as an option, so it is rejected rather than passed through. - if (baseRef.length === 0 || baseRef.startsWith('-') || /[\s\0]/.test(baseRef)) { - throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${baseRef}' is not a valid git ref`); - } - // .native expands Windows 8.3 short names (e.g. RUNNER~1 in CI temp dirs), - // so the path compares correctly with git's long-form top-level path. - const dir = realpathSync.native(catalogDir); - let top: string; - try { - top = realpathSync.native(git(['rev-parse', '--show-toplevel'], dir).trim()); - } catch { - throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${catalogDir}' is not inside a git work tree`); - } - if (!gitSucceeds(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`], top)) { - throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${baseRef}' does not name a commit`); - } - const prefix = relative(top, dir).replaceAll('\\', '/'); - if (prefix.startsWith('..') || isAbsolute(prefix)) { - throw new PolicyCatalogError('invalid_catalog', `base-ref check: '${catalogDir}' is outside its git work tree`); - } - const at = (file: string): string => `${baseRef}:${prefix === '' ? '' : `${prefix}/`}${file}`; - if (!gitSucceeds(['cat-file', '-e', at('manifest.json')], top)) { - return undefined; - } - const manifest = JSON.parse(git(['show', at('manifest.json')], top)) as PublishedState['manifest']; - const files = new Map(); - for (const revision of manifest.revisions ?? []) { - if (gitSucceeds(['cat-file', '-e', at(revision.file)], top)) { - files.set(revision.file, git(['show', at(revision.file)], top)); - } - } - return { manifest, files }; -} - -/** - * Compares a catalog directory's manifest and revision files with the - * revisions published at `baseRef` (design 10): published revisions keep - * their manifest entry, digest, and content; new revisions are only appended. - * Returns errors instead of throwing, including when the check cannot run. - */ -export function checkAgainstBaseRef( - catalogDir: string, - manifest: PublishedState['manifest'], - baseRef: string, -): { comparedRevisions: number; errors: string[] } { - const dir = resolvePath(catalogDir); - try { - const base = readPublishedStateAtRef(dir, baseRef); - if (!base) { - return { comparedRevisions: 0, errors: [] }; - } - const files = new Map(manifest.revisions.map(r => { - let text: string; - try { - text = readFileSync(resolvePath(dir, r.file), 'utf8'); - } catch { - // Reported as a modification: a published file may not disappear. - text = ''; - } - return [r.file, text] as const; - })); - return { - comparedRevisions: base.manifest.revisions.length, - errors: checkPublishedImmutability(base, { manifest, files }).map(message => `[immutability] ${message}`), - }; - } catch (error) { - return { comparedRevisions: 0, errors: [describeError(error)] }; - } -} - -function describeError(error: unknown): string { - return error instanceof PolicyCatalogError ? error.message : `[policy_validation] ${(error as Error).message}`; -} - -/** - * Validates a catalog directory: manifest and contract validity, per-revision - * integrity against the published canonical digest, the full catalog contract - * (including dependency closure and cycle freedom), entry-revision history, - * and, when `baseRef` is given, immutability of every revision published at - * that ref. Never throws for catalog problems; they are reported in `errors`. - */ -export function validateCatalogDirectory(catalogDir: string, options: { baseRef?: string } = {}): CatalogValidationReport { - const dir = resolvePath(catalogDir); - // Field order is fixed so the JSON report is stable and readable. - const report: CatalogValidationReport = { ok: false, catalogDir: dir, defaultRevision: undefined, revisions: undefined, baseRef: undefined, errors: [] }; - let store: CatalogStore; - try { - store = loadCatalogDirectory(pathToFileURL(`${dir}/`)); - } catch (error) { - report.errors.push(describeError(error)); - return report; - } - report.defaultRevision = store.defaultRevision; - report.revisions = store.availableRevisions; - report.errors.push(...checkStoreHistory(store)); - if (options.baseRef !== undefined) { - const result = checkAgainstBaseRef(dir, store.manifest, options.baseRef); - report.baseRef = { ref: options.baseRef, comparedRevisions: result.comparedRevisions }; - report.errors.push(...result.errors); - } - report.ok = report.errors.length === 0; - return report; -} diff --git a/policy-catalog/src/version-range.ts b/policy-catalog/src/version-range.ts deleted file mode 100644 index 4f00425b0..000000000 --- a/policy-catalog/src/version-range.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Minimal, dependency-free version-range support for catalog v1. - * - * Grammar (a deliberate subset of npm semver ranges, identical in every - * language binding): - * - * range := comparatorSet ( "||" comparatorSet )* - * comparatorSet := comparator ( " " comparator )* - * comparator := op? version - * op := ">=" | "<=" | ">" | "<" | "=" - * version := N ( "." N ( "." N )? )? - * - * Missing minor/patch components are zero-filled. A comparator with no - * operator is a prefix match (`22` means `>=22.0.0 <23.0.0`). Prerelease and - * build metadata on evidence versions are ignored. - */ - -type Triple = [number, number, number]; -type Op = '>=' | '<=' | '>' | '<' | '=' | 'prefix'; -interface Comparator { - op: Op; - version: Triple; - parts: number; -} - -const COMPARATOR = /^(>=|<=|>|<|=)?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/; - -function parseComparator(token: string): Comparator | undefined { - const match = COMPARATOR.exec(token); - if (!match) { - return undefined; - } - const parts = match[4] !== undefined ? 3 : match[3] !== undefined ? 2 : 1; - return { - op: (match[1] as Op | undefined) ?? 'prefix', - version: [Number(match[2]), Number(match[3] ?? 0), Number(match[4] ?? 0)], - parts, - }; -} - -function parseRange(range: string): Comparator[][] | undefined { - if (range.trim().length === 0) { - return undefined; - } - const sets: Comparator[][] = []; - for (const alternative of range.split('||')) { - const tokens = alternative.trim().split(/\s+/).filter(Boolean); - if (tokens.length === 0) { - return undefined; - } - const comparators: Comparator[] = []; - for (const token of tokens) { - const comparator = parseComparator(token); - if (!comparator) { - return undefined; - } - comparators.push(comparator); - } - sets.push(comparators); - } - return sets; -} - -/** Returns true when `range` uses the supported v1 range grammar. */ -export function isValidVersionRange(range: string): boolean { - return parseRange(range) !== undefined; -} - -/** Parses tool version evidence such as `v22.3.1` or `10.9.0-rc.1`. */ -export function parseVersionEvidence(value: string): Triple | undefined { - const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/.exec(value.trim()); - if (!match) { - return undefined; - } - return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; -} - -function compare(left: Triple, right: Triple): number { - for (let index = 0; index < 3; index += 1) { - if (left[index] !== right[index]) { - return left[index] < right[index] ? -1 : 1; - } - } - return 0; -} - -function satisfiesComparator(version: Triple, comparator: Comparator): boolean { - const order = compare(version, comparator.version); - switch (comparator.op) { - case '>=': - return order >= 0; - case '<=': - return order <= 0; - case '>': - return order > 0; - case '<': - return order < 0; - case '=': - return order === 0; - case 'prefix': - return version.slice(0, comparator.parts).every((part, index) => part === comparator.version[index]); - } -} - -/** - * Evaluates `version` against `range`. Returns `undefined` when either value - * cannot be evaluated; callers treat that as "not verified", never a match. - */ -export function satisfiesVersionRange(version: string, range: string): boolean | undefined { - const parsedVersion = parseVersionEvidence(version); - const parsedRange = parseRange(range); - if (!parsedVersion || !parsedRange) { - return undefined; - } - return parsedRange.some(set => set.every(comparator => satisfiesComparator(parsedVersion, comparator))); -} diff --git a/policy-catalog/tests/functional/cli.test.ts b/policy-catalog/tests/functional/cli.test.ts deleted file mode 100644 index 8474d656e..000000000 --- a/policy-catalog/tests/functional/cli.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Functional tests for the installed `policy-catalog` CLI: the three commands -// (resolve, inspect, validate), exit codes, JSON output, and the bundled -// catalog shipped in the tarball. -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { ARCHITECTURES, PLATFORMS, PLATFORM_CONTEXT, cli, cliViaBin, fullContext, installedManifest } from './helpers.js'; - -describe('installed CLI: inspect and validate', () => { - it('inspect reports catalog info and metadata-only entries sorted by entryId', () => { - const result = cli('inspect'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json.info, { catalogSchemaVersion: '1', catalogRevision: installedManifest.defaultRevision }); - const ids = result.json.entries.map((e: any) => e.entryId); - assert.deepEqual(ids, ['tool:git', 'tool:node', 'tool:npm']); - assert.ok(!result.stdout.includes('readwritePaths'), 'inspect must never return a policy body'); - assert.ok(!result.stdout.includes('${'), 'inspect must never return unresolved policy templates'); - }); - - it('validate checks integrity, contract, and history of every installed revision', () => { - const result = cli('validate'); - assert.equal(result.status, 0, result.stdout); - assert.equal(result.json.ok, true); - assert.deepEqual(result.json.errors, []); - assert.equal(result.json.defaultRevision, installedManifest.defaultRevision); - assert.deepEqual(result.json.revisions, installedManifest.revisions.map((r: any) => r.catalogRevision)); - assert.equal(result.json.baseRef, undefined); - }); - - it('the npm-installed bin shim runs the same CLI', () => { - const result = cliViaBin('inspect'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json, cli('inspect').json); - }); - - it('removed command names are usage errors, not aliases', () => { - for (const command of ['info', 'list', 'verify']) { - const result = cli(command); - assert.equal(result.status, 2, command); - assert.match(result.stderr, /unknown command/); - assert.match(result.stderr, /usage: policy-catalog /); - } - }); - - it('usage errors exit 2 without JSON on stdout', () => { - const cases = [ - [], - ['bogus'], - ['resolve', '--platform'], - ['resolve', '--symbol', 'noequals', 'git'], - ['resolve', '--purl', 'pkg:npm/npm'], - ['resolve', '--base-ref', 'HEAD', 'git'], - ['inspect', 'extra'], - ['validate', 'extra'], - ['validate', '--base-ref'], - ['resolve', '--catalog'], - ]; - for (const args of cases) { - const result = cli(...args); - assert.equal(result.status, 2, args.join(' ')); - assert.equal(result.json, undefined, args.join(' ')); - assert.match(result.stderr, /usage: policy-catalog/); - } - }); -}); - -describe('installed CLI: resolve against the bundled catalog', () => { - for (const platform of PLATFORMS) { - for (const architecture of ARCHITECTURES) { - it(`resolves the full tool set on ${platform}/${architecture} deterministically`, () => { - const c = PLATFORM_CONTEXT[platform]; - const first = cli('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); - const second = cli('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git', 'npm', 'node'); - assert.equal(first.status, 0, first.stderr); - assert.deepEqual(first.json, second.json); - const { policy, diagnostics } = first.json; - assert.equal(policy.version, '0.9.0-alpha'); - assert.deepEqual(policy.filesystem.readwritePaths, [c.root, c.cache]); - assert.deepEqual(policy.filesystem.readonlyPaths, [c.prefix]); - assert.equal(policy.network, undefined, 'v1 never composes network'); - assert.deepEqual(diagnostics.tools.map((t: any) => t.matches.map((m: any) => m.entryId)), [['tool:git'], ['tool:npm'], ['tool:node']]); - assert.deepEqual(diagnostics.resolvedDependencies, [{ entryId: 'tool:node', entryRevision: 1 }]); - }); - } - } - - it('policy-only output equals the diagnostics policy', () => { - const ctx = fullContext('linux', 'x64'); - const plain = cli('resolve', ...ctx, 'npm'); - const diag = cli('resolve', '--diagnostics', ...ctx, 'npm'); - assert.equal(plain.status, 0, plain.stderr); - assert.deepEqual(plain.json, diag.json.policy); - }); - - it('an unresolved required symbol yields null with an actionable warning', () => { - const result = cli('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/usr/bin', 'git'); - assert.equal(result.status, 0); - assert.equal(result.json.policy, undefined); - assert.match(result.json.diagnostics.warnings.join('\n'), /required symbol 'project_root'.*supply ResolveContext\.projectRoot/); - }); - - it('empty input produces no policy, never an empty policy', () => { - assert.equal(cli('resolve', '--platform', 'linux', '--architecture', 'x64').json, null); - const empty = cli('resolve', '--diagnostics', '--platform', 'linux', '--architecture', 'x64'); - assert.equal(empty.status, 0); - assert.deepEqual(empty.json, { diagnostics: { catalogRevision: installedManifest.defaultRevision, tools: [], resolvedDependencies: [], warnings: [] } }); - }); - - it('invalid caller input is a malformed_request (invalid_context) library failure (exit 1)', () => { - const cases: string[][] = [ - ['resolve', '--platform', 'plan9', 'git'], - ['resolve', '--architecture', 'mips', 'git'], - ['resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'node_prefix=relative', 'node'], - ['resolve', '--platform', 'linux', '--architecture', 'x64', '--symbol', 'unknown_symbol=/x', 'git'], - ['resolve', '--platform', 'linux', '--architecture', 'x64', '--purl', 'not-a-purl', 'npm'], - ['resolve', '--platform', 'linux', '--architecture', 'x64', './bin/git'], - ]; - for (const args of cases) { - const result = cli(...args); - assert.equal(result.status, 1, `${args.join(' ')} -> ${result.stderr}`); - assert.equal(result.json.error.details.reason, 'invalid_context', args.join(' ')); - } - }); -}); diff --git a/policy-catalog/tests/functional/helpers.ts b/policy-catalog/tests/functional/helpers.ts deleted file mode 100644 index a122b08b0..000000000 --- a/policy-catalog/tests/functional/helpers.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Shared helpers for the functional suite. Everything here resolves the -// library, the CLI, and the bundled catalog from the package that -// run-tests.js packed with `npm pack` and installed into a temporary consumer -// project outside the repository. Nothing reads the repository's dist/ or -// catalog/: the suite tests what a consumer would install. -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { copyFileSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; -import { isAbsolute, join, relative } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -// Type-only imports are erased at compile time; the runtime modules below are -// loaded from the installed package. -import type * as Library from '@mxc-prototype/policy-catalog'; -import type * as Tooling from '@mxc-prototype/policy-catalog/tooling'; - -const consumerDir = process.env.POLICY_CATALOG_CONSUMER_DIR; -if (!consumerDir) { - throw new Error('POLICY_CATALOG_CONSUMER_DIR is not set; run the functional suite through tests/functional/run-tests.js'); -} - -/** The consumer project the tarball was installed into. */ -export const consumer = consumerDir; -/** The installed package directory inside the consumer's node_modules. */ -export const packageDir = join(consumer, 'node_modules', '@mxc-prototype', 'policy-catalog'); -const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); - -// Guard: the package under test must not be the source tree. -// tests/functional/dist/helpers.js -> package source root -const sourceRoot = realpathSync.native(fileURLToPath(new URL('../../../', import.meta.url))); -const installedRoot = realpathSync.native(packageDir); -const fromSource = relative(sourceRoot, installedRoot); -assert.ok(fromSource.startsWith('..') || isAbsolute(fromSource), `installed package ${installedRoot} is inside the source tree ${sourceRoot}`); - -/** The installed CLI entry point, as declared by the installed package's `bin`. */ -export const cliPath = join(packageDir, pkg.bin['policy-catalog']); -/** The catalog directory shipped in the installed package. */ -export const installedCatalogDir = join(packageDir, 'catalog'); -export const installedManifest = JSON.parse(readFileSync(join(installedCatalogDir, 'manifest.json'), 'utf8')); - -/** The installed library, loaded through the installed package's `exports`. */ -export const lib: typeof Library = await import(pathToFileURL(join(packageDir, pkg.exports['.'].import)).href); -/** The installed tooling subpath (digests for synthetic catalogs). */ -export const tooling: typeof Tooling = await import(pathToFileURL(join(packageDir, pkg.exports['./tooling'].import)).href); - -export interface CliResult { - status: number | null; - json: any; - stdout: string; - stderr: string; -} - -function parse(stdout: string): any { - try { - return stdout.trim() === '' ? undefined : JSON.parse(stdout); - } catch { - return stdout; - } -} - -/** Runs the installed CLI in a separate process from the consumer directory. */ -export function cli(...args: string[]): CliResult { - const result = spawnSync(process.execPath, [cliPath, ...args], { cwd: consumer, encoding: 'utf8' }); - return { status: result.status, json: parse(result.stdout), stdout: result.stdout, stderr: result.stderr }; -} - -/** Runs the CLI through the npm-installed bin shim (`node_modules/.bin`). */ -export function cliViaBin(...args: string[]): CliResult { - const bin = join(consumer, 'node_modules', '.bin', 'policy-catalog'); - const result = process.platform === 'win32' - // .cmd shims need a shell on Windows; arguments here are fixed literals. - ? spawnSync(`"${bin}.cmd" ${args.join(' ')}`, { cwd: consumer, encoding: 'utf8', shell: true }) - : spawnSync(bin, args, { cwd: consumer, encoding: 'utf8' }); - return { status: result.status, json: parse(result.stdout), stdout: result.stdout, stderr: result.stderr }; -} - -export const PLATFORM_CONTEXT = { - windows: { root: 'C:\\work\\app', prefix: 'C:\\tools', cache: 'C:\\cache\\npm' }, - linux: { root: '/work/app', prefix: '/opt/tools', cache: '/var/cache/npm' }, - macos: { root: '/Users/dev/app', prefix: '/opt/homebrew/bin', cache: '/Users/dev/.npm' }, -} as const; -export type Platform = keyof typeof PLATFORM_CONTEXT; -export const PLATFORMS = Object.keys(PLATFORM_CONTEXT) as Platform[]; -export const ARCHITECTURES = ['x64', 'arm64'] as const; - -/** Resolve flags with every bundled-catalog symbol supplied for `platform`. */ -export function symbolArgs(platform: Platform): string[] { - const c = PLATFORM_CONTEXT[platform]; - return [ - '--project-root', c.root, - '--symbol', `git_prefix=${c.prefix}`, - '--symbol', `node_prefix=${c.prefix}`, - '--symbol', `npm_prefix=${c.prefix}`, - '--symbol', `npm_cache=${c.cache}`, - ]; -} - -export function fullContext(platform: Platform, architecture: string): string[] { - return ['--platform', platform, '--architecture', architecture, '--allow-weak', ...symbolArgs(platform)]; -} - -/** Minimal valid entry for synthetic catalogs. */ -export function entry(entryId: string, overrides: Record = {}): any { - const name = entryId.split(':')[1]; - return { - entryId, - entryRevision: 1, - displayName: name, - identity: [{ kind: 'invocation-name', names: [name] }], - platformVariants: [ - { - when: { platform: 'linux' }, - sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${name}`] } }, - }, - ], - provenance: { method: 'functional-test', sourceRevision: 'functional-test' }, - ...overrides, - }; -} - -export function revision(entries: any[], catalogRevision = '2000-01-01.1'): any { - return { catalogSchemaVersion: '1', catalogRevision, entries }; -} - -/** - * Writes a complete catalog directory (contract from the installed package, - * manifest with correct canonical digests, revision files) so the only - * defect in it is the one a test introduces on purpose. - */ -export function writeCatalog(dir: string, revisions: any[], options: { defaultRevision?: string } = {}): string { - mkdirSync(join(dir, 'revisions'), { recursive: true }); - copyFileSync(join(installedCatalogDir, 'contract.v1.json'), join(dir, 'contract.v1.json')); - const manifest = { - catalogSchemaVersion: '1', - defaultRevision: options.defaultRevision ?? revisions[revisions.length - 1].catalogRevision, - revisions: revisions.map(r => { - const file = `revisions/${r.catalogRevision}.json`; - writeFileSync(join(dir, file), `${JSON.stringify(r, null, 2)}\n`); - return { catalogRevision: r.catalogRevision, file, sha256: tooling.canonicalSha256(r) }; - }), - }; - writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - return dir; -} - -export function warningsOf(result: CliResult): string { - return (result.json?.diagnostics?.warnings ?? []).join('\n'); -} diff --git a/policy-catalog/tests/functional/integrity.test.ts b/policy-catalog/tests/functional/integrity.test.ts deleted file mode 100644 index bad340910..000000000 --- a/policy-catalog/tests/functional/integrity.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Functional tests for catalog integrity and contract enforcement through the -// installed CLI: digest tampering, missing files, invalid manifests, -// dependency cycles, and the --base-ref immutability check. Every catalog -// here is a copy or a synthetic directory in a temporary folder; the -// installed package is never modified. -import { describe, it, before, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { cli, entry, fullContext, installedCatalogDir, installedManifest, revision, writeCatalog } from './helpers.js'; - -let work: string; -before(() => { - work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-integrity-')); -}); -after(() => { - rmSync(work, { recursive: true, force: true }); -}); - -const copyInstalled = (): string => { - const dir = mkdtempSync(join(work, 'catalog-')); - cpSync(installedCatalogDir, dir, { recursive: true }); - return dir; -}; -const latestFile = (dir: string) => join(dir, installedManifest.revisions.at(-1).file); - -describe('integrity of a copied catalog', () => { - it('an untouched copy validates and resolves like the bundled catalog', () => { - const dir = copyInstalled(); - assert.equal(cli('validate', '--catalog', dir).status, 0); - const ctx = fullContext('linux', 'x64'); - assert.deepEqual(cli('resolve', '--catalog', dir, ...ctx, 'git').json, cli('resolve', ...ctx, 'git').json); - }); - - it('formatting-only edits keep the canonical digest valid', () => { - const dir = copyInstalled(); - const file = latestFile(dir); - writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, 'utf8'))).replaceAll(',', ',\r\n')); - const result = cli('validate', '--catalog', dir); - assert.equal(result.status, 0, result.stdout); - }); - - it('a semantic edit to a published revision fails integrity in validate, resolve, and inspect', () => { - const dir = copyInstalled(); - const file = latestFile(dir); - const data = JSON.parse(readFileSync(file, 'utf8')); - // Widen a requirement: the kind of tamper integrity checking exists to stop. - data.entries[0].platformVariants[0].sandboxPolicy.filesystem.readwritePaths.push('${user_home}'); - writeFileSync(file, JSON.stringify(data, null, 2)); - - const validate = cli('validate', '--catalog', dir); - assert.equal(validate.status, 1, validate.stderr); - assert.equal(validate.json?.ok, false, `validate produced no JSON report; stderr: ${validate.stderr}`); - assert.match(validate.json.errors.join('\n'), /\[backend_error\] catalog revision '[^']+' digest [0-9a-f]{64} does not match the published digest/); - - const resolve = cli('resolve', '--catalog', dir, ...fullContext('windows', 'x64'), 'git'); - assert.equal(resolve.status, 1, resolve.stderr); - assert.equal(resolve.json.error.details.reason, 'integrity'); - assert.equal(cli('inspect', '--catalog', dir).json.error.details.reason, 'integrity'); - }); - - it('a missing revision file is an integrity failure', () => { - const dir = copyInstalled(); - rmSync(latestFile(dir)); - const result = cli('inspect', '--catalog', dir); - assert.equal(result.status, 1); - assert.equal(result.json.error.details.reason, 'integrity'); - assert.match(cli('validate', '--catalog', dir).json.errors.join('\n'), /\[backend_error\].*could not be read/); - }); - - it('a manifest whose default names an unlisted revision is a validation failure', () => { - const dir = copyInstalled(); - writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ ...installedManifest, defaultRevision: '2099-01-01.1' })); - const result = cli('inspect', '--catalog', dir); - assert.equal(result.status, 1); - assert.equal(result.json.error.details.reason, 'invalid_catalog'); - const validate = cli('validate', '--catalog', dir); - assert.equal(validate.status, 1); - assert.match(validate.json.errors.join('\n'), /\[policy_validation\] manifest\.defaultRevision must name a listed revision/); - }); -}); - -describe('dependency cycles', () => { - const dependsOn = (target: string) => ({ - platformVariants: [{ - when: { platform: 'linux' }, - dependencies: [{ entryId: target }], - sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: [`\${git_prefix}/${target.split(':')[1]}-dep`] } }, - }], - }); - - it('validate rejects a correctly digested catalog whose entries form a cycle', () => { - // Digests are correct, so the only defect is the cycle a -> b -> c -> a. - const dir = writeCatalog(mkdtempSync(join(work, 'cycle-')), [revision([ - entry('tool:a', dependsOn('tool:b')), - entry('tool:b', dependsOn('tool:c')), - entry('tool:c', dependsOn('tool:a')), - ])]); - const result = cli('validate', '--catalog', dir); - assert.equal(result.status, 1, result.stderr); - assert.equal(result.json?.ok, false, `validate produced no JSON report; stderr: ${result.stderr}`); - assert.match(result.json.errors.join('\n'), /\[policy_validation\] 'tool:a' on linux\/x64: cycle \(tool:a -> tool:b -> tool:c -> tool:a\)/); - }); - - it('resolve never returns a policy from a cyclic catalog', () => { - const dir = writeCatalog(mkdtempSync(join(work, 'cycle-')), [revision([ - entry('tool:a', dependsOn('tool:b')), - entry('tool:b', dependsOn('tool:a')), - ])]); - const result = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'); - assert.equal(result.status, 1, result.stderr); - assert.equal(result.json?.error?.details?.reason, 'invalid_catalog', `stderr: ${result.stderr}`); - assert.match(result.json.error.message, /cycle \(tool:a -> tool:b -> tool:a\)/); - }); - - it('a diamond (shared dependency, no cycle) is valid and contributes the shared entry once', () => { - const dir = writeCatalog(mkdtempSync(join(work, 'diamond-')), [revision([ - entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:b' }, { entryId: 'tool:c' }], sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/a'] } } }] }), - entry('tool:b', dependsOn('tool:d')), - entry('tool:c', dependsOn('tool:d')), - entry('tool:d'), - ])]); - assert.equal(cli('validate', '--catalog', dir).status, 0); - const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json.policy.filesystem.readonlyPaths, ['/opt/a', '/opt/d-dep', '/opt/d']); - assert.deepEqual(result.json.diagnostics.resolvedDependencies.map((d: any) => d.entryId), ['tool:b', 'tool:c', 'tool:d']); - }); -}); - -describe('validate --base-ref (published-revision immutability)', () => { - const git = (cwd: string, ...args: string[]) => - execFileSync('git', ['-c', 'user.name=functional-test', '-c', 'user.email=functional-test@invalid', '-c', 'core.autocrlf=false', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - - const publishedRepo = (): { repo: string; dir: string } => { - const repo = mkdtempSync(join(work, 'repo-')); - git(repo, 'init', '-q'); - const dir = join(repo, 'catalog'); - writeCatalog(dir, [revision([entry('tool:a')], '2000-01-01.1')]); - git(repo, 'add', '-A'); - git(repo, 'commit', '-q', '-m', 'publish 2000-01-01.1'); - return { repo, dir }; - }; - - it('an unchanged or appended catalog passes against the base ref', () => { - const { dir } = publishedRepo(); - const unchanged = cli('validate', '--catalog', dir, '--base-ref', 'HEAD'); - assert.equal(unchanged.status, 0, unchanged.stdout); - assert.deepEqual(unchanged.json.baseRef, { ref: 'HEAD', comparedRevisions: 1 }); - // Append a new revision, as the contribution flow requires. - writeCatalog(dir, [ - revision([entry('tool:a')], '2000-01-01.1'), - revision([entry('tool:a'), entry('tool:b')], '2000-01-02.1'), - ]); - const appended = cli('validate', '--catalog', dir, '--base-ref', 'HEAD'); - assert.equal(appended.status, 0, appended.stdout); - }); - - it('rewriting a published revision (with a recomputed digest) fails against the base ref', () => { - const { dir } = publishedRepo(); - // Consistent digests, so only the immutability check can catch it. - writeCatalog(dir, [revision([entry('tool:a', { entryRevision: 2, displayName: 'changed' })], '2000-01-01.1')]); - assert.equal(cli('validate', '--catalog', dir).status, 0, 'without --base-ref the rewrite is internally consistent'); - const result = cli('validate', '--catalog', dir, '--base-ref', 'HEAD'); - assert.equal(result.status, 1, result.stdout); - const errors = result.json.errors.join('\n'); - assert.match(errors, /\[immutability\] published revision '2000-01-01\.1' manifest entry was modified/); - assert.match(errors, /\[immutability\] published revision file 'revisions\/2000-01-01\.1\.json' was modified/); - }); - - it('an unknown or option-like base ref, or a directory outside git, is an error, never a silent pass', () => { - const { dir } = publishedRepo(); - const badRef = cli('validate', '--catalog', dir, '--base-ref', 'no-such-ref'); - assert.equal(badRef.status, 1); - assert.match(badRef.json.errors.join('\n'), /base-ref check: 'no-such-ref' does not name a commit/); - const optionLike = cli('validate', '--catalog', dir, '--base-ref', '-h'); - assert.equal(optionLike.status, 1); - assert.match(optionLike.json.errors.join('\n'), /base-ref check: '-h' is not a valid git ref/); - const outside = writeCatalog(mkdtempSync(join(work, 'nogit-')), [revision([entry('tool:a')])]); - const noGit = cli('validate', '--catalog', outside, '--base-ref', 'HEAD'); - assert.equal(noGit.status, 1); - assert.match(noGit.json.errors.join('\n'), /is not inside a git work tree/); - }); - - it('a base ref with no catalog at that path has nothing published yet', () => { - const { repo, dir } = publishedRepo(); - // A commit whose tree is empty: git mktree with no input writes the empty tree. - const emptyTree = git(repo, 'mktree').trim(); - const orphan = git(repo, 'commit-tree', emptyTree, '-m', 'empty').trim(); - const result = cli('validate', '--catalog', dir, '--base-ref', orphan); - assert.equal(result.status, 0, result.stdout); - assert.deepEqual(result.json.baseRef, { ref: orphan, comparedRevisions: 0 }); - }); -}); diff --git a/policy-catalog/tests/functional/resolution.test.ts b/policy-catalog/tests/functional/resolution.test.ts deleted file mode 100644 index 26eb0e63a..000000000 --- a/policy-catalog/tests/functional/resolution.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Functional coverage of the resolution behaviors the design calls out -// (design 4.3, 4.4, 4.5, 5.1): additive multi-entry matching, weak-identity -// opt-in, unknown tools, composition conflicts, unavailable revisions, -// neutral-variant fallback, and the architecture-not-verified warning. Each -// case runs through the installed CLI and, where the injected host matters, -// through the installed library. -import { describe, it, before, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - ARCHITECTURES, - PLATFORMS, - PLATFORM_CONTEXT, - cli, - entry, - fullContext, - lib, - revision, - symbolArgs, - warningsOf, - writeCatalog, - type Platform, -} from './helpers.js'; - -const NOT_VERIFIED = /architecture was not specified; variants were selected for the native system architecture '(x64|arm64)'; the tool's architecture was not verified/; - -let work: string; -before(() => { - work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-resolution-')); -}); -after(() => { - rmSync(work, { recursive: true, force: true }); -}); - -describe('one input matching several entries', () => { - // 'app' is claimed by two entries: tool:app (purl + name) and tool:app-plugin (name only). - const catalog = () => writeCatalog(mkdtempSync(join(work, 'multi-')), [revision([ - entry('tool:app', { - identity: [{ kind: 'purl', value: 'pkg:npm/app' }, { kind: 'invocation-name', names: ['app'] }], - }), - entry('tool:app-plugin', { - identity: [{ kind: 'invocation-name', names: ['app'] }], - }), - ])]); - - it('composes every eligible match and warns, naming the input and the entries', () => { - const dir = catalog(); - const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', - '--allow-weak', '--symbol', 'git_prefix=/opt', 'app'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json.diagnostics.tools, [{ - inputIndex: 0, - matches: [ - { entryId: 'tool:app', entryRevision: 1, matchedIdentities: [{ kind: 'invocation-name', strength: 'weak' }] }, - { entryId: 'tool:app-plugin', entryRevision: 1, matchedIdentities: [{ kind: 'invocation-name', strength: 'weak' }] }, - ], - }]); - assert.match(warningsOf(result), /input 0 \('app'\) matched 2 entries \(tool:app, tool:app-plugin\); all contribute/); - assert.deepEqual(result.json.policy.filesystem.readonlyPaths, ['/opt/app', '/opt/app-plugin']); - }); - - it('a strong match does not suppress the other entry\'s eligible weak match', () => { - const dir = catalog(); - const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', - '--allow-weak', '--symbol', 'git_prefix=/opt', '--purl', 'pkg:npm/app@1.0.0', 'app'); - assert.equal(result.status, 0, result.stderr); - const [app, plugin] = result.json.diagnostics.tools[0].matches; - assert.deepEqual(app.matchedIdentities.map((i: any) => i.strength), ['strong', 'weak']); - assert.equal(plugin.entryId, 'tool:app-plugin'); - assert.match(warningsOf(result), /matched 2 entries/); - }); - - it('without the weak opt-in only the strongly identified entry contributes and no multi-match warning is issued', () => { - const dir = catalog(); - const result = cli('resolve', '--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', - '--symbol', 'git_prefix=/opt', '--purl', 'pkg:npm/app', 'app'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json.diagnostics.tools[0].matches.map((m: any) => m.entryId), ['tool:app']); - assert.doesNotMatch(warningsOf(result), /matched 2 entries/); - assert.deepEqual(result.json.policy.filesystem.readonlyPaths, ['/opt/app']); - }); -}); - -describe('weak-identity opt-in', () => { - const base = ['--diagnostics', '--platform', 'linux', '--architecture', 'x64', ...symbolArgs('linux')]; - - it('off (default): an invocation-name-only match yields no policy and says why', () => { - const result = cli('resolve', ...base, 'git'); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.json.policy, undefined); - assert.deepEqual(result.json.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); - assert.match(warningsOf(result), /input 0 \('git'\) matched no eligible catalog entry: tool:git matched only by invocation name and allowWeakIdentityFallback is not enabled/); - assert.equal(cli('resolve', '--platform', 'linux', '--architecture', 'x64', ...symbolArgs('linux'), 'git').json, null); - }); - - it('on: the same match contributes and is flagged as weak identity', () => { - const result = cli('resolve', ...base, '--allow-weak', 'git'); - assert.equal(result.status, 0, result.stderr); - assert.notEqual(result.json.policy, undefined); - assert.deepEqual(result.json.diagnostics.tools[0].matches[0].matchedIdentities, [{ kind: 'invocation-name', strength: 'weak' }]); - assert.match(warningsOf(result), /input 0 \('git'\) matched tool:git only by invocation name \(weak identity\)/); - }); - - it('a strong package-URL match does not need the opt-in and is not flagged as weak', () => { - const result = cli('resolve', ...base, '--purl', 'pkg:npm/npm@10.9.0', 'npm'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json.diagnostics.tools[0].matches[0].matchedIdentities.map((i: any) => i.strength), ['strong', 'weak']); - assert.notEqual(result.json.policy, undefined); - assert.doesNotMatch(warningsOf(result), /weak identity/); - }); - - it('string shorthand and object input obey the same option in the installed library', () => { - const catalog = new lib.PolicyCatalog(lib.bundledCatalogStore()); - const ctx = { platform: 'linux' as const, architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/usr/bin' } }; - assert.equal(catalog.resolveSandboxPolicy('git', ctx), undefined); - assert.equal(catalog.resolveSandboxPolicy({ invocationName: 'git' }, ctx), undefined); - const on = { ...ctx, allowWeakIdentityFallback: true }; - assert.deepEqual(catalog.resolveSandboxPolicy('git', on), catalog.resolveSandboxPolicy({ invocationName: 'git' }, on)); - assert.notEqual(catalog.resolveSandboxPolicy('git', on), undefined); - }); -}); - -describe('unknown tools', () => { - it('an unknown tool yields no policy, never an empty policy, with a per-input warning', () => { - const result = cli('resolve', '--diagnostics', ...fullContext('linux', 'x64'), 'cargo'); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.json.policy, undefined); - assert.deepEqual(result.json.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); - assert.match(warningsOf(result), /input 0 \('cargo'\) matched no eligible catalog entry$/m); - assert.equal(cli('resolve', ...fullContext('linux', 'x64'), 'cargo').json, null); - }); - - it('known and unknown inputs compose the known requirements and report the unknown one', () => { - const mixed = cli('resolve', '--diagnostics', ...fullContext('linux', 'x64'), 'cargo', 'git'); - const known = cli('resolve', '--diagnostics', ...fullContext('linux', 'x64'), 'git'); - assert.equal(mixed.status, 0, mixed.stderr); - assert.deepEqual(mixed.json.policy, known.json.policy); - assert.deepEqual(mixed.json.diagnostics.tools.map((t: any) => [t.inputIndex, t.matches.length]), [[0, 0], [1, 1]]); - assert.match(warningsOf(mixed), /input 0 \('cargo'\) matched no eligible catalog entry/); - }); -}); - -describe('composition conflicts', () => { - it('overlapping resolved paths across access classes are a policy_validation (composition_conflict) failure, not a choice', () => { - // project_root=/opt (read-write for git) contains node_prefix=/opt/node (read-only for node). - const result = cli('resolve', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--project-root', '/opt', - '--symbol', 'git_prefix=/usr/bin', '--symbol', 'node_prefix=/opt/node', 'git', 'node'); - assert.equal(result.status, 1, result.stderr); - assert.equal(result.json.error.details.reason, 'composition_conflict'); - assert.match(result.json.error.message, /overlap across access classes/); - }); - - it('two independently matched entries that would need a network merge are rejected', () => { - const dir = writeCatalog(mkdtempSync(join(work, 'compose-')), [revision([ - entry('tool:a'), - entry('tool:b', { - platformVariants: [{ - when: { platform: 'linux' }, - sandboxPolicy: { version: '0.9.0-alpha', network: { egress: { default: 'deny' } } }, - }], - }), - ])]); - // Each entry alone is valid; the conflict exists only for this input set. - assert.equal(cli('validate', '--catalog', dir).status, 0); - const alone = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', 'b'); - assert.equal(alone.status, 0, alone.stderr); - assert.deepEqual(alone.json.network, { egress: { default: 'deny' } }); - const both = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a', 'b'); - assert.equal(both.status, 1, both.stderr); - assert.equal(both.json.error.details.reason, 'composition_conflict'); - assert.match(both.json.error.message, /'tool:b' uses 'network', which has no v1 cross-entry composition rule/); - }); - - it('mixed sandboxPolicy versions across selected entries are rejected', () => { - const dir = writeCatalog(mkdtempSync(join(work, 'versions-')), [revision([ - entry('tool:a'), - entry('tool:b', { - platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.8.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/b'] } } }], - }), - ])]); - const both = cli('resolve', '--catalog', dir, '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a', 'b'); - assert.equal(both.status, 1, both.stderr); - assert.equal(both.json.error.details.reason, 'composition_conflict'); - assert.match(both.json.error.message, /mixed sandboxPolicy\.version values/); - }); -}); - -describe('catalog revisions', () => { - it('an unavailable explicit revision is a backend_error (revision_unavailable) failure, never a substitution', () => { - const result = cli('resolve', '--revision', '1999-01-01.1', ...fullContext('linux', 'x64'), 'git'); - assert.equal(result.status, 1, result.stderr); - assert.equal(result.json.error.details.reason, 'revision_unavailable'); - assert.match(result.json.error.message, /'1999-01-01\.1' is not installed/); - assert.throws( - () => lib.resolveSandboxPolicy('git', { catalogRevision: '1999-01-01.1', platform: 'linux', architecture: 'x64' }), - (error: any) => error instanceof lib.PolicyCatalogError && error.reason === 'revision_unavailable', - ); - }); - - it('an older installed revision stays addressable after a newer default is published', () => { - const r1 = revision([entry('tool:a')], '2000-01-01.1'); - const r2 = revision([entry('tool:a', { - entryRevision: 2, - platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/v2'] } } }], - })], '2000-01-02.1'); - const dir = writeCatalog(mkdtempSync(join(work, 'revisions-')), [r1, r2]); - assert.equal(cli('validate', '--catalog', dir).status, 0); - const args = ['--catalog', dir, '--diagnostics', '--platform', 'linux', '--architecture', 'x64', '--allow-weak', '--symbol', 'git_prefix=/opt', 'a']; - const latest = cli('resolve', ...args); - const older = cli('resolve', '--revision', '2000-01-01.1', ...args); - assert.equal(latest.json.diagnostics.catalogRevision, '2000-01-02.1'); - assert.deepEqual(latest.json.policy.filesystem.readonlyPaths, ['/opt/v2']); - assert.equal(older.json.diagnostics.catalogRevision, '2000-01-01.1'); - assert.deepEqual(older.json.policy.filesystem.readonlyPaths, ['/opt/a']); - const missing = cli('resolve', '--revision', '2000-01-03.1', ...args); - assert.equal(missing.json.error.details.reason, 'revision_unavailable'); - }); -}); - -describe('platform and architecture selection', () => { - for (const platform of PLATFORMS) { - for (const architecture of ARCHITECTURES) { - it(`${platform}/${architecture}: explicit architecture warns about neutral fallback but not about verification`, () => { - const result = cli('resolve', '--diagnostics', ...fullContext(platform, architecture), 'git'); - assert.equal(result.status, 0, result.stderr); - assert.match(warningsOf(result), new RegExp(`tool:git uses its architecture-neutral ${platform} variant; no ${architecture}-specific variant exists`)); - assert.doesNotMatch(warningsOf(result), NOT_VERIFIED); - }); - - it(`${platform}/${architecture}: omitted architecture uses the injected native architecture and warns that the tool's was not verified`, () => { - // The installed library with a host whose native architecture is fixed, - // so every combination is exercised on any CI machine. - const host = { platform: (): Platform => platform, nativeArchitecture: () => architecture, symbol: () => undefined }; - const catalog = new lib.PolicyCatalog(lib.bundledCatalogStore(), host); - const c = PLATFORM_CONTEXT[platform]; - const ctx = { allowWeakIdentityFallback: true, projectRoot: c.root, symbols: { git_prefix: c.prefix } }; - const omitted = catalog.resolveSandboxPolicyWithDiagnostics('git', ctx); - const warnings = omitted.diagnostics.warnings.join('\n'); - assert.match(warnings, NOT_VERIFIED); - assert.match(warnings, new RegExp(`native system architecture '${architecture}'`)); - assert.match(warnings, new RegExp(`no ${architecture}-specific variant exists`)); - const explicit = catalog.resolveSandboxPolicyWithDiagnostics('git', { ...ctx, architecture, platform }); - assert.doesNotMatch(explicit.diagnostics.warnings.join('\n'), NOT_VERIFIED); - assert.deepEqual(explicit.policy, omitted.policy); - }); - } - - it(`${platform}: the installed CLI on this host warns when architecture is omitted`, () => { - const result = cli('resolve', '--diagnostics', '--platform', platform, '--allow-weak', ...symbolArgs(platform), 'git'); - assert.equal(result.status, 0, result.stderr); - assert.match(warningsOf(result), NOT_VERIFIED); - }); - } - - it('no warning about architecture is issued when nothing was selected', () => { - const result = cli('resolve', '--diagnostics', '--platform', 'linux', 'cargo'); - assert.equal(result.status, 0, result.stderr); - assert.doesNotMatch(warningsOf(result), /architecture/); - }); - - it('an exact-architecture variant wins and emits no neutral-fallback warning; another architecture is never a fallback', () => { - const dir = writeCatalog(mkdtempSync(join(work, 'arch-')), [revision([ - entry('tool:a', { - platformVariants: [ - { when: { platform: 'windows', architecture: 'arm64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}\\arm64'] } } }, - { when: { platform: 'windows', architecture: 'x64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}\\x64'] } } }, - { when: { platform: 'linux', architecture: 'x64' }, sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readonlyPaths: ['${git_prefix}/x64'] } } }, - ], - }), - ])]); - const common = ['--catalog', dir, '--diagnostics', '--allow-weak']; - for (const architecture of ARCHITECTURES) { - const result = cli('resolve', ...common, '--platform', 'windows', '--architecture', architecture, '--symbol', 'git_prefix=C:\\t', 'a'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.json.policy.filesystem.readonlyPaths, [`C:\\t\\${architecture}`]); - assert.doesNotMatch(warningsOf(result), /architecture-neutral/); - } - const noArm = cli('resolve', ...common, '--platform', 'linux', '--architecture', 'arm64', '--symbol', 'git_prefix=/t', 'a'); - assert.equal(noArm.status, 0, noArm.stderr); - assert.equal(noArm.json.policy, undefined); - assert.match(warningsOf(noArm), /tool:a has no variant for linux\/arm64/); - }); -}); diff --git a/policy-catalog/tests/functional/run-tests.js b/policy-catalog/tests/functional/run-tests.js deleted file mode 100644 index 688808c8a..000000000 --- a/policy-catalog/tests/functional/run-tests.js +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Functional test runner. Tests the package a consumer would install, not the -// source tree: -// -// 1. `npm pack` the package (its dist/ must already be built). -// 2. Install the tarball, offline, into a fresh consumer project in a -// temporary directory outside the repository. -// 3. Run the compiled *.test.js files in this directory's dist/ with -// POLICY_CATALOG_CONSUMER_DIR pointing at that consumer. The tests load -// the library and CLI only from the consumer's node_modules. -// -// The final step mirrors MXC's sdk/node/tests/integration/run-tests.js -// (`node --test --test-reporter spec --test-force-exit`). -// -// npm run test:functional -import { readdirSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const dist = fileURLToPath(new URL('./dist/', import.meta.url)); -const packageDir = fileURLToPath(new URL('../../', import.meta.url)); - -const files = existsSync(dist) - ? readdirSync(dist, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.test.js')) - .map(entry => join(dist, entry.name)) - .sort() - : []; -if (!files.length) { - console.error(`No test files found in ${dist}`); - process.exit(1); -} - -// Run npm through its JS entry point so no shell is involved on any platform. -const npmCli = process.env.npm_execpath; -if (!npmCli) { - console.error('Run the functional suite through `npm run test:functional` so npm can pack and install the package.'); - process.exit(2); -} -const npm = (args, cwd) => execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); - -const work = mkdtempSync(join(tmpdir(), 'policy-catalog-functional-')); -let status = 0; -try { - const [packed] = JSON.parse(npm(['pack', '--json', '--pack-destination', work], packageDir)); - const tarball = join(work, packed.filename); - const consumer = join(work, 'consumer'); - mkdirSync(consumer); - writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({ name: 'policy-catalog-functional-consumer', private: true, type: 'module' }, null, 2)}\n`); - // The package has no runtime dependencies, so the install needs no network. - npm(['install', '--offline', '--no-audit', '--no-fund', '--ignore-scripts', tarball], consumer); - console.log(`Installed ${packed.name}@${packed.version} (${packed.entryCount} files) from ${packed.filename} into ${consumer}\n`); - - try { - execFileSync(process.execPath, ['--test', '--test-reporter', 'spec', '--test-force-exit', ...files], { - stdio: 'inherit', - env: { ...process.env, POLICY_CATALOG_CONSUMER_DIR: consumer }, - }); - } catch (error) { - status = typeof error.status === 'number' && error.status !== 0 ? error.status : 1; - } -} finally { - rmSync(work, { recursive: true, force: true }); -} -process.exit(status); diff --git a/policy-catalog/tests/functional/tsconfig.json b/policy-catalog/tests/functional/tsconfig.json deleted file mode 100644 index 5cd65d3a0..000000000 --- a/policy-catalog/tests/functional/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "noUnusedLocals": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": false, - "types": ["node"] - }, - "include": ["*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/policy-catalog/tests/unit/conformance.test.ts b/policy-catalog/tests/unit/conformance.test.ts deleted file mode 100644 index 767c3153d..000000000 --- a/policy-catalog/tests/unit/conformance.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Runs the language-neutral conformance fixtures (conformance/fixtures/*.json). -// Other language bindings must run the same files with the same expectations. -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { PolicyCatalogError } from '@mxc-prototype/policy-catalog'; -import { bundledCatalog, catalogFor, fixedHost, fixtureFiles, fixturesDir, readJson } from './helpers.js'; - -function failure(fn: () => unknown): { code: string; reason: string } | undefined { - try { - fn(); - } catch (error) { - if (error instanceof PolicyCatalogError) { - return { code: error.code, reason: error.details.reason }; - } - throw error; - } - return undefined; -} - -function toExpected(expect: any): any { - return { ...expect, policy: expect.policy ?? undefined }; -} - -for (const name of fixtureFiles()) { - const fixture = readJson(new URL(name, fixturesDir)); - describe(`conformance: ${name}`, () => { - for (const testCase of fixture.cases) { - it(testCase.name, () => { - const host = fixedHost(testCase.host?.platform ?? 'linux', testCase.host?.nativeArchitecture ?? 'x64'); - const catalog = fixture.catalog === 'bundled' ? bundledCatalog(host) : catalogFor(fixture.catalog, host); - if (testCase.expectError !== undefined) { - assert.deepEqual(failure(() => catalog.resolveSandboxPolicyWithDiagnostics(testCase.tools, testCase.context)), testCase.expectError); - assert.deepEqual(failure(() => catalog.resolveSandboxPolicy(testCase.tools, testCase.context)), testCase.expectError); - return; - } - const expected = toExpected(testCase.expect); - assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics(testCase.tools, testCase.context), expected); - // The policy-only operation returns the same policy from the same logic. - assert.deepEqual(catalog.resolveSandboxPolicy(testCase.tools, testCase.context), expected.policy); - // A single input is exactly a one-element array. - if (!Array.isArray(testCase.tools)) { - assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics([testCase.tools], testCase.context), expected); - } - }); - } - }); -} diff --git a/policy-catalog/tests/unit/helpers.ts b/policy-catalog/tests/unit/helpers.ts deleted file mode 100644 index 724339e12..000000000 --- a/policy-catalog/tests/unit/helpers.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import assert from 'node:assert/strict'; -import { readFileSync, readdirSync } from 'node:fs'; -import { - CatalogStore, - ERROR_CODE_FOR_REASON, - PolicyCatalog, - PolicyCatalogError, - bundledCatalogStore, - type HostEnvironment, -} from '@mxc-prototype/policy-catalog'; -import { canonicalSha256 } from '@mxc-prototype/policy-catalog/tooling'; - -export const fixturesDir = new URL('../../../conformance/fixtures/', import.meta.url); -export const catalogDir = new URL('../../../catalog/', import.meta.url); - -export function readJson(url: URL): any { - return JSON.parse(readFileSync(url, 'utf8')); -} - -export const contract = readJson(new URL('contract.v1.json', catalogDir)); - -/** Host with fixed facts, so tests never depend on the machine running them. */ -export function fixedHost( - platform: 'windows' | 'linux' | 'macos' = 'linux', - architecture: 'x64' | 'arm64' = 'x64', - symbols: Record = {}, -): HostEnvironment { - return { - platform: () => platform, - nativeArchitecture: () => architecture, - symbol: name => symbols[name], - }; -} - -/** Builds an in-memory store whose manifest publishes the given revisions. */ -export function storeFor(revisions: any[], options: { defaultRevision?: string; digests?: Record } = {}): CatalogStore { - const files = new Map(); - const manifest = { - catalogSchemaVersion: '1', - defaultRevision: options.defaultRevision ?? revisions[revisions.length - 1].catalogRevision, - revisions: revisions.map(revision => { - const file = `revisions/${revision.catalogRevision}.json`; - files.set(file, revision); - return { - catalogRevision: revision.catalogRevision, - file, - sha256: options.digests?.[revision.catalogRevision] ?? canonicalSha256(revision), - }; - }), - }; - return new CatalogStore({ - contract, - manifest, - readRevision: file => structuredClone(files.get(file)), - }); -} - -export function catalogFor(revision: any, host: HostEnvironment = fixedHost()): PolicyCatalog { - return new PolicyCatalog(storeFor([revision]), host); -} - -export function bundledCatalog(host: HostEnvironment = fixedHost()): PolicyCatalog { - return new PolicyCatalog(bundledCatalogStore(), host); -} - -export function fixtureFiles(): string[] { - return readdirSync(fixturesDir).filter(name => name.endsWith('.json')).sort(); -} - -/** The failure's stable `details.reason`, after checking that `code` is the reason's MXC code. */ -export function errorReason(fn: () => unknown): string | undefined { - try { - fn(); - } catch (error) { - if (error instanceof PolicyCatalogError) { - assert.equal(error.code, ERROR_CODE_FOR_REASON[error.reason]); - return error.reason; - } - throw error; - } - return undefined; -} - -/** Minimal valid revision with one entry, for mutation in validation tests. */ -export function revisionWith(entries: any[], catalogRevision = '2000-01-01.1'): any { - return { catalogSchemaVersion: '1', catalogRevision, entries }; -} - -export function entry(entryId: string, overrides: Record = {}): any { - const name = entryId.split(':')[1]; - return { - entryId, - entryRevision: 1, - displayName: name, - identity: [{ kind: 'invocation-name', names: [name] }], - platformVariants: [ - { - when: { platform: 'linux' }, - sandboxPolicy: { version: '0.9.0-alpha', filesystem: { readwritePaths: ['${project_root}'] } }, - }, - ], - provenance: { method: 'test', sourceRevision: 'test' }, - ...overrides, - }; -} diff --git a/policy-catalog/tests/unit/resolver.test.ts b/policy-catalog/tests/unit/resolver.test.ts deleted file mode 100644 index b6fd79d85..000000000 --- a/policy-catalog/tests/unit/resolver.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { - PolicyCatalog, - getCatalogInfo, - resolveSandboxPolicy, - resolveSandboxPolicyWithDiagnostics, - listCatalogEntries, -} from '@mxc-prototype/policy-catalog'; -import { architectureFromMachine } from '@mxc-prototype/policy-catalog/tooling'; -import { bundledCatalog, catalogFor, entry, errorReason, fixedHost, revisionWith, storeFor } from './helpers.js'; - -const v = '0.9.0-alpha'; -const weak = { allowWeakIdentityFallback: true } as const; - -describe('runtime lookup: defaults (design §1)', () => { - const archCatalog = (host = fixedHost('windows', 'arm64')) => catalogFor(revisionWith([ - entry('tool:a', { - identity: [{ kind: 'purl', value: 'pkg:npm/a' }, { kind: 'invocation-name', names: ['a'] }], - platformVariants: [ - { when: { platform: 'windows', architecture: 'x64' }, sandboxPolicy: { version: v, timeoutMs: 1 } }, - { when: { platform: 'windows', architecture: 'arm64' }, sandboxPolicy: { version: v, timeoutMs: 2 } }, - ], - }), - ]), host); - - it('omitted context: host platform, native architecture, installed revision, no weak fallback', () => { - const catalog = archCatalog(); - assert.equal(catalog.resolveSandboxPolicy('a'), undefined, 'weak identity requires opt-in'); - const result = catalog.resolveSandboxPolicyWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }); - assert.equal(result.policy?.timeoutMs, 2, 'native arm64 selected'); - assert.equal(result.diagnostics.catalogRevision, '2000-01-01.1'); - assert.match(result.diagnostics.warnings.join('\n'), /native system architecture 'arm64'; the tool's architecture was not verified/); - }); - - it('explicit architecture takes precedence and suppresses the host-default warning', () => { - const result = archCatalog().resolveSandboxPolicyWithDiagnostics({ invocationName: 'a', packageUrl: 'pkg:npm/a' }, { architecture: 'x64' }); - assert.equal(result.policy?.timeoutMs, 1); - assert.deepEqual(result.diagnostics.warnings, []); - }); - - it('a different architecture variant is never a fallback', () => { - const catalog = catalogFor(revisionWith([ - entry('tool:a', { platformVariants: [{ when: { platform: 'linux', architecture: 'arm64' }, sandboxPolicy: { version: v } }] }), - ])); - const result = catalog.resolveSandboxPolicyWithDiagnostics('a', { ...weak, architecture: 'x64' }); - assert.equal(result.policy, undefined); - assert.match(result.diagnostics.warnings[0], /tool:a has no variant for linux\/x64/); - }); - - it('host architecture detection failure is a library error, not a guessed selection', () => { - const host = { ...fixedHost(), nativeArchitecture: (): never => { throw new Error('unknown machine'); } }; - const catalog = new PolicyCatalog(storeFor([revisionWith([entry('tool:t')])]), host); - assert.throws(() => catalog.resolveSandboxPolicy('t', { ...weak, projectRoot: '/p' }), /unknown machine/); - // Detection is not needed when nothing matches, so it is not attempted. - assert.equal(catalog.resolveSandboxPolicy('nothing', weak), undefined); - // An explicit architecture never consults the host. - assert.deepEqual(catalog.resolveSandboxPolicy('t', { ...weak, architecture: 'x64', projectRoot: '/p' })?.filesystem, { readwritePaths: ['/p'] }); - }); - - it('maps OS machine names to selectors and rejects unknown ones', () => { - assert.equal(architectureFromMachine('AMD64'), 'x64'); - assert.equal(architectureFromMachine('x86_64'), 'x64'); - assert.equal(architectureFromMachine('ARM64'), 'arm64'); - assert.equal(architectureFromMachine('aarch64'), 'arm64'); - assert.equal(architectureFromMachine('riscv64'), undefined); - assert.equal(architectureFromMachine('x86'), undefined); - }); - - it('never fabricates projectRoot or caller symbols', () => { - const result = bundledCatalog().resolveSandboxPolicyWithDiagnostics('git', { ...weak, architecture: 'x64' }); - assert.equal(result.policy, undefined); - assert.match(result.diagnostics.warnings.join('\n'), /required symbol 'git_prefix'.*required symbol 'project_root'/s); - }); - - it('derives host symbols only for the current host platform, and caller values override them', () => { - const revision = revisionWith([ - entry('tool:t', { - platformVariants: [ - { when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, - { when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${user_home}/.cfg'] } } }, - ], - }), - ]); - const catalog = catalogFor(revision, fixedHost('linux', 'x64', { user_home: '/home/me' })); - assert.deepEqual(catalog.resolveSandboxPolicy('t', weak)?.filesystem, { readonlyPaths: ['/home/me/.cfg'] }); - assert.equal(catalog.resolveSandboxPolicy('t', { ...weak, platform: 'macos', architecture: 'arm64' }), undefined); - assert.deepEqual(catalog.resolveSandboxPolicy('t', { ...weak, symbols: { user_home: '/srv/u' } })?.filesystem, { readonlyPaths: ['/srv/u/.cfg'] }); - }); -}); - -describe('runtime lookup: inputs and failures (design §5.1)', () => { - it('string shorthand, object input, and a one-element array are equivalent', () => { - const catalog = bundledCatalog(); - const ctx = { ...weak, architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/g' } }; - const a = catalog.resolveSandboxPolicyWithDiagnostics('git', ctx); - assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics({ invocationName: 'git' }, ctx), a); - assert.deepEqual(catalog.resolveSandboxPolicyWithDiagnostics(['git'], ctx), a); - assert.equal(a.diagnostics.tools[0].inputIndex, 0); - }); - - it('string shorthand obeys the weak-identity option exactly like an object input', () => { - const catalog = bundledCatalog(); - const ctx = { architecture: 'x64' as const, projectRoot: '/p', symbols: { git_prefix: '/g' } }; - assert.equal(catalog.resolveSandboxPolicy('git', ctx), undefined); - assert.equal(catalog.resolveSandboxPolicy({ invocationName: 'git' }, ctx), undefined); - }); - - it('rejects invalid context and inputs as library failures, not absence', () => { - const catalog = bundledCatalog(); - const cases: Array<[unknown, unknown]> = [ - ['', {}], - ['/usr/bin/git', {}], - [{ invocationName: 'npm', packageUrl: 'npm' }, {}], - [{ invocationName: 'npm', detectedVersion: '' }, {}], - [[42], {}], - ['git', { platform: 'plan9' }], - ['git', { architecture: 'x86' }], - ['git', { symbols: { nope: '/x' } }], - ['git', { symbols: { project_root: '/x' } }], - ['git', { projectRoot: '' }], - ]; - for (const [tools, ctx] of cases) { - assert.equal(errorReason(() => catalog.resolveSandboxPolicy(tools as any, ctx as any)), 'invalid_context', JSON.stringify([tools, ctx])); - } - }); - - it('is deterministic and returns caller-owned copies', () => { - const catalog = bundledCatalog(); - const ctx = { architecture: 'x64' as const, projectRoot: '/p', symbols: { npm_prefix: '/n', npm_cache: '/c', node_prefix: '/n' } }; - const tool = { invocationName: 'npm', packageUrl: 'pkg:npm/npm' }; - const first = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); - first.policy!.filesystem!.readwritePaths!.push('/mutated'); - first.diagnostics.warnings.push('mutated'); - first.diagnostics.tools[0].matches.length = 0; - const second = catalog.resolveSandboxPolicyWithDiagnostics(tool, ctx); - assert.deepEqual(second.policy?.filesystem, { readonlyPaths: ['/n'], readwritePaths: ['/p', '/c'] }); - assert.equal(second.diagnostics.tools[0].matches.length, 1); - assert.ok(!second.diagnostics.warnings.includes('mutated')); - }); - - it('de-duplicates normalized paths within an access class using platform path rules', () => { - const catalog = catalogFor(revisionWith([ - entry('tool:w', { - platformVariants: [{ when: { platform: 'windows' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}\\'] } } }], - }), - ])); - const policy = catalog.resolveSandboxPolicy('w', { platform: 'windows', architecture: 'x64', ...weak, symbols: { git_prefix: 'C:\\Tools', node_prefix: 'c:\\tools' } }); - assert.deepEqual(policy?.filesystem, { readonlyPaths: ['C:\\Tools'] }); - // Linux paths are case-sensitive, so the same values stay distinct there. - const linux = catalogFor(revisionWith([ - entry('tool:l', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}/'] } } }] }), - ])); - assert.deepEqual(linux.resolveSandboxPolicy('l', { ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } })?.filesystem, { readonlyPaths: ['/Tools', '/tools'] }); - }); - - it('macOS folds path case like Windows (one casing rule per OS)', () => { - const mac = catalogFor(revisionWith([ - entry('tool:m', { platformVariants: [{ when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}', '${node_prefix}/'] } } }] }), - ])); - const ctx = { platform: 'macos', architecture: 'arm64', ...weak, symbols: { git_prefix: '/Tools', node_prefix: '/tools' } } as const; - assert.deepEqual(mac.resolveSandboxPolicy('m', ctx)?.filesystem, { readonlyPaths: ['/Tools'] }); - // Overlap across access classes is also detected case-insensitively on macOS. - const overlap = catalogFor(revisionWith([ - entry('tool:m', { platformVariants: [{ when: { platform: 'macos' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}'], readwritePaths: ['${project_root}'] } } }] }), - ])); - assert.equal(errorReason(() => overlap.resolveSandboxPolicy('m', { ...ctx, projectRoot: '/tools/work' })), 'composition_conflict'); - // The same values on Linux are distinct paths, so there is no overlap. - const linux = catalogFor(revisionWith([ - entry('tool:m', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${git_prefix}'], readwritePaths: ['${project_root}'] } } }] }), - ])); - assert.deepEqual(linux.resolveSandboxPolicy('m', { ...weak, platform: 'linux', architecture: 'x64', projectRoot: '/tools/work', symbols: { git_prefix: '/Tools' } })?.filesystem, - { readonlyPaths: ['/Tools'], readwritePaths: ['/tools/work'] }); - }); - - it('invocation names: Linux is exact (gh is not GH); Windows and macOS fold case', () => { - const catalog = catalogFor(revisionWith([ - entry('tool:gh', { platformVariants: ['linux', 'macos', 'windows'].map(platform => ({ when: { platform }, sandboxPolicy: { version: v } })) }), - ])); - const on = (platform: 'linux' | 'macos' | 'windows', name: string) => - catalog.resolveSandboxPolicyWithDiagnostics(name, { ...weak, platform, architecture: 'x64' }).diagnostics.tools[0].matches.map(m => m.entryId); - assert.deepEqual(on('linux', 'gh'), ['tool:gh']); - assert.deepEqual(on('linux', 'GH'), []); - assert.deepEqual(on('macos', 'GH'), ['tool:gh']); - assert.deepEqual(on('windows', 'Gh'), ['tool:gh']); - }); - - it('dependency chain resolution composes each entry once, in deterministic order', () => { - const variant = (deps: string[], path: string) => ({ - when: { platform: 'linux' }, - ...(deps.length ? { dependencies: deps.map(entryId => ({ entryId })) } : {}), - sandboxPolicy: { version: v, filesystem: { readonlyPaths: [`\${project_root}/${path}`] } }, - }); - const catalog = catalogFor(revisionWith([ - entry('tool:top', { platformVariants: [variant(['tool:left', 'tool:right'], 'top')] }), - entry('tool:left', { platformVariants: [variant(['tool:leaf'], 'left')] }), - entry('tool:right', { platformVariants: [variant(['tool:leaf'], 'right')] }), - entry('tool:leaf', { platformVariants: [variant([], 'leaf')] }), - ])); - const result = catalog.resolveSandboxPolicyWithDiagnostics('top', { ...weak, projectRoot: '/r' }); - assert.deepEqual(result.diagnostics.resolvedDependencies.map(d => d.entryId), ['tool:leaf', 'tool:left', 'tool:right']); - assert.deepEqual(result.policy?.filesystem?.readonlyPaths, ['/r/top', '/r/left', '/r/leaf', '/r/right']); - }); - - it('dependency diagnostics retain distinct required ranges, sorted', () => { - const catalog = catalogFor(revisionWith([ - entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:c', versionRange: '>=2' }], sandboxPolicy: { version: v } }] }), - entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, dependencies: [{ entryId: 'tool:c' }], sandboxPolicy: { version: v } }] }), - entry('tool:c', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), - ])); - const result = catalog.resolveSandboxPolicyWithDiagnostics(['a', 'b', 'a'], weak); - assert.deepEqual(result.diagnostics.resolvedDependencies, [ - { entryId: 'tool:c', entryRevision: 1 }, - { entryId: 'tool:c', entryRevision: 1, requiredVersionRange: '>=2' }, - ]); - assert.deepEqual(result.policy, { version: v }); - }); - - it('catalog file order does not change matching, attribution, or composition', () => { - const entries = [ - entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['x'] }], platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${project_root}/b'] } } }] }), - entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['x'] }], platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v, filesystem: { readonlyPaths: ['${project_root}/a'] } } }] }), - ]; - const forward = catalogFor(revisionWith(entries)).resolveSandboxPolicyWithDiagnostics('x', { ...weak, projectRoot: '/r' }); - const backward = catalogFor(revisionWith([...entries].reverse())).resolveSandboxPolicyWithDiagnostics('x', { ...weak, projectRoot: '/r' }); - assert.deepEqual(forward, backward); - assert.deepEqual(forward.diagnostics.tools[0].matches.map(m => m.entryId), ['tool:a', 'tool:b']); - assert.deepEqual(forward.policy?.filesystem?.readonlyPaths, ['/r/a', '/r/b']); - }); - - it('rejects mixed policy versions across inputs at lookup time', () => { - const catalog = catalogFor(revisionWith([ - entry('tool:a', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: '0.8.0-alpha' } }] }), - entry('tool:b', { platformVariants: [{ when: { platform: 'linux' }, sandboxPolicy: { version: v } }] }), - ])); - assert.equal(errorReason(() => catalog.resolveSandboxPolicy(['a', 'b'], weak)), 'composition_conflict'); - // Each tool on its own is fine. - assert.deepEqual(catalog.resolveSandboxPolicy('a', weak), { version: '0.8.0-alpha' }); - }); -}); - -describe('setup and inspection (design §5.2)', () => { - it('reports the bundled revision, which matches the manifest default', () => { - assert.deepEqual(getCatalogInfo(), { catalogSchemaVersion: '1', catalogRevision: '2026-09-29.1' }); - }); - - it('lists metadata ordered by entryId, without any policy body', () => { - const entries = listCatalogEntries(); - assert.deepEqual(entries.map(e => e.entryId), ['tool:git', 'tool:node', 'tool:npm']); - const npm = entries.find(e => e.entryId === 'tool:npm')!; - assert.deepEqual(npm.platformVariants[0], { platform: 'windows', dependencyEntryIds: ['tool:node'], sandboxPolicyVersion: v }); - const text = JSON.stringify(entries); - assert.ok(!text.includes('Paths')); - assert.ok(!text.includes('${')); - assert.ok(!text.includes('sandboxPolicy"')); - }); - - it('module-level functions use the bundled catalog', () => { - const ctx = { ...weak, platform: 'linux' as const, architecture: 'x64' as const }; - assert.equal(resolveSandboxPolicy('definitely-unknown-tool', ctx), undefined); - const result = resolveSandboxPolicyWithDiagnostics(['definitely-unknown-tool'], ctx); - assert.equal(result.policy, undefined); - assert.deepEqual(result.diagnostics.tools, [{ inputIndex: 0, matches: [] }]); - }); -}); diff --git a/policy-catalog/tests/unit/run-tests.js b/policy-catalog/tests/unit/run-tests.js deleted file mode 100644 index 657f689e0..000000000 --- a/policy-catalog/tests/unit/run-tests.js +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Unit test runner. Mirrors MXC's sdk/node/tests/integration/run-tests.js: -// runs the compiled *.test.js files in this directory's dist/ with the spec -// reporter, and fails when there is nothing to run. -// -// npm run test:unit -import { readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const dist = fileURLToPath(new URL('./dist/', import.meta.url)); -const files = existsSync(dist) - ? readdirSync(dist, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.test.js')) - .map(entry => join(dist, entry.name)) - .sort() - : []; - -if (!files.length) { - console.error(`No test files found in ${dist}`); - process.exit(1); -} - -try { - execFileSync(process.execPath, ['--test', '--test-reporter', 'spec', '--test-force-exit', ...files], { stdio: 'inherit' }); -} catch (error) { - process.exit(typeof error.status === 'number' && error.status !== 0 ? error.status : 1); -} diff --git a/policy-catalog/tests/unit/store.test.ts b/policy-catalog/tests/unit/store.test.ts deleted file mode 100644 index 368388872..000000000 --- a/policy-catalog/tests/unit/store.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { PolicyCatalog, bundledCatalogStore } from '@mxc-prototype/policy-catalog'; -import { - canonicalJson, - canonicalSha256, - checkEntryRevisions, - checkPublishedImmutability, - checkStoreHistory, - validateContract, - validateCatalogRevision, -} from '@mxc-prototype/policy-catalog/tooling'; -import { contract, entry, errorReason, fixedHost, revisionWith, storeFor } from './helpers.js'; - -const parsedContract = validateContract(contract); - -describe('integrity', () => { - it('bundled catalog verifies against its published digest', () => { - const store = bundledCatalogStore(); - assert.deepEqual(checkStoreHistory(store), []); - assert.equal(store.revision().catalogRevision, store.defaultRevision); - }); - - it('canonical digest ignores formatting and key order but not content', () => { - assert.equal(canonicalJson({ b: 1, a: [2, { d: 3, c: 4 }] }), '{"a":[2,{"c":4,"d":3}],"b":1}'); - assert.equal(canonicalSha256({ a: 1, b: 2 }), canonicalSha256({ b: 2, a: 1 })); - assert.notEqual(canonicalSha256({ a: 1 }), canonicalSha256({ a: 2 })); - }); - - it('a tampered revision fails with an integrity error, never a no-match', () => { - const revision = revisionWith([entry('tool:a')]); - const store = storeFor([revision], { digests: { '2000-01-01.1': '0'.repeat(64) } }); - assert.equal(errorReason(() => store.revision()), 'integrity'); - const catalog = new PolicyCatalog(store, fixedHost()); - assert.equal(errorReason(() => catalog.resolveSandboxPolicy('a', { allowWeakIdentityFallback: true })), 'integrity'); - assert.equal(errorReason(() => catalog.resolveSandboxPolicyWithDiagnostics('a', { allowWeakIdentityFallback: true })), 'integrity'); - assert.equal(errorReason(() => catalog.listCatalogEntries()), 'integrity'); - assert.equal(errorReason(() => catalog.getCatalogInfo()), 'integrity'); - }); - - it('a revision file that declares a different revision id fails integrity', () => { - const revision = revisionWith([entry('tool:a')], '2000-01-02.1'); - const digest = canonicalSha256(revision); - const store = storeFor([{ ...revision, catalogRevision: '2000-01-01.1' }], { digests: { '2000-01-01.1': digest } }); - // The digest was computed for other content, so this is caught as tampering first. - assert.equal(errorReason(() => store.revision()), 'integrity'); - }); - - it('invalid manifest data is a validation error', () => { - const revision = revisionWith([entry('tool:a')]); - assert.equal(errorReason(() => storeFor([revision], { defaultRevision: '2001-01-01.1' })), 'invalid_catalog'); - assert.equal(errorReason(() => storeFor([revisionWith([entry('tool:a')], '2000-01-02.1'), revision])), 'invalid_catalog'); - }); -}); - -describe('versioning and immutable revisions', () => { - const r1 = revisionWith([entry('tool:a'), entry('tool:b')], '2000-01-01.1'); - - it('selects an explicit installed revision and never substitutes an unavailable one', () => { - const r2 = revisionWith([entry('tool:a', { entryRevision: 2, displayName: 'renamed' }), entry('tool:b')], '2000-01-02.1'); - const catalog = new PolicyCatalog(storeFor([r1, r2]), fixedHost()); - const ctx = { allowWeakIdentityFallback: true, projectRoot: '/p' }; - const revisionOf = (extra = {}) => { - const result = catalog.resolveSandboxPolicyWithDiagnostics('a', { ...ctx, ...extra }); - return [result.diagnostics.catalogRevision, result.diagnostics.tools[0].matches[0]?.entryRevision]; - }; - assert.deepEqual(revisionOf(), ['2000-01-02.1', 2]); - assert.deepEqual(revisionOf({ catalogRevision: '2000-01-01.1' }), ['2000-01-01.1', 1]); - assert.equal(errorReason(() => catalog.resolveSandboxPolicy('a', { ...ctx, catalogRevision: '2000-01-03.1' })), 'revision_unavailable'); - }); - - it('loaded revisions are deeply frozen (read-only)', () => { - const store = storeFor([r1]); - const loaded = store.revision(); - assert.ok(Object.isFrozen(loaded.entries[0].platformVariants[0].sandboxPolicy)); - assert.throws(() => { (loaded.entries as any[]).push({}); }, TypeError); - }); - - it('requires entryRevision to increase exactly when an entry changes', () => { - const changedNoBump = revisionWith([entry('tool:a', { displayName: 'x' }), entry('tool:b')], '2000-01-02.1'); - const bumpedNoChange = revisionWith([entry('tool:a'), entry('tool:b', { entryRevision: 2 })], '2000-01-02.1'); - const ok = revisionWith([entry('tool:a', { displayName: 'x', entryRevision: 2 }), entry('tool:b')], '2000-01-02.1'); - const [a, b, c] = [changedNoBump, bumpedNoChange, ok].map(r => validateCatalogRevision(r, parsedContract)); - const base = validateCatalogRevision(r1, parsedContract); - assert.match(checkEntryRevisions(base, a).join('\n'), /'tool:a' changed but entryRevision did not increase/); - assert.match(checkEntryRevisions(base, b).join('\n'), /'tool:b' is unchanged but entryRevision moved/); - assert.deepEqual(checkEntryRevisions(base, c), []); - assert.match(checkEntryRevisions(c, base).join('\n'), /must be newer/); - }); - - it('rejects edits to, or removal of, an already-published revision', () => { - const published = { catalogRevision: '2000-01-01.1', file: 'revisions/2000-01-01.1.json', sha256: 'a'.repeat(64) }; - const base = { manifest: { revisions: [published] }, files: new Map([[published.file, '{"x":1}\n']]) }; - const appended = { - manifest: { revisions: [published, { catalogRevision: '2000-01-02.1', file: 'revisions/2000-01-02.1.json', sha256: 'b'.repeat(64) }] }, - files: new Map([[published.file, '{"x":1}\n']]), - }; - assert.deepEqual(checkPublishedImmutability(base, appended), []); - const edited = { manifest: { revisions: [published] }, files: new Map([[published.file, '{"x":2}\n']]) }; - assert.match(checkPublishedImmutability(base, edited).join('\n'), /was modified; publish a new revision/); - const redigested = { manifest: { revisions: [{ ...published, sha256: 'c'.repeat(64) }] }, files: base.files }; - assert.match(checkPublishedImmutability(base, redigested).join('\n'), /manifest entry was modified/); - const removed = { manifest: { revisions: [] }, files: new Map() }; - assert.match(checkPublishedImmutability(base, removed).join('\n'), /removed or reordered/); - }); -}); diff --git a/policy-catalog/tests/unit/tsconfig.json b/policy-catalog/tests/unit/tsconfig.json deleted file mode 100644 index 5cd65d3a0..000000000 --- a/policy-catalog/tests/unit/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "noUnusedLocals": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": false, - "types": ["node"] - }, - "include": ["*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/policy-catalog/tests/unit/validation.test.ts b/policy-catalog/tests/unit/validation.test.ts deleted file mode 100644 index 809a30db3..000000000 --- a/policy-catalog/tests/unit/validation.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { validateCatalogRevision, validateContract } from '@mxc-prototype/policy-catalog/tooling'; -import { contract, entry, revisionWith } from './helpers.js'; - -const parsedContract = validateContract(contract); -const validate = (entries: any[]) => () => validateCatalogRevision(revisionWith(entries), parsedContract); -const linux = (sandboxPolicy: any, extra: Record = {}) => ({ when: { platform: 'linux' }, ...extra, sandboxPolicy }); -const v = '0.9.0-alpha'; - -describe('catalog validation (contribution/CI rules)', () => { - it('accepts a minimal valid revision', () => { - assert.doesNotThrow(validate([entry('tool:a')])); - }); - - it('rejects dependency cycles, including indirect ones', () => { - const dep = (id: string) => [linux({ version: v }, { dependencies: [{ entryId: id }] })]; - assert.throws(validate([ - entry('tool:a', { platformVariants: dep('tool:b') }), - entry('tool:b', { platformVariants: dep('tool:c') }), - entry('tool:c', { platformVariants: dep('tool:a') }), - ]), /cycle \(tool:a -> tool:b -> tool:c -> tool:a\)/); - assert.throws(validate([entry('tool:a', { platformVariants: dep('tool:a') })]), /depends on itself/); - }); - - it('rejects dependencies outside the same catalog revision', () => { - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v }, { dependencies: [{ entryId: 'tool:missing' }] })] })]), /unknown entry 'tool:missing'/); - }); - - it('rejects syntactically invalid dependency version ranges but never evaluates them', () => { - assert.throws(validate([ - entry('tool:a', { platformVariants: [linux({ version: v }, { dependencies: [{ entryId: 'tool:b', versionRange: '^1.x' }] })] }), - entry('tool:b'), - ]), /versionRange/); - }); - - it('rejects duplicate exact selectors and a second architecture-neutral variant', () => { - const variant = (architecture?: string) => ({ when: { platform: 'linux', ...(architecture ? { architecture } : {}) }, sandboxPolicy: { version: v } }); - assert.throws(validate([entry('tool:a', { platformVariants: [variant('x64'), variant('x64')] })]), /duplicates selector/); - assert.throws(validate([entry('tool:a', { platformVariants: [variant(), variant()] })]), /second architecture-neutral/); - assert.doesNotThrow(validate([entry('tool:a', { platformVariants: [variant(), variant('x64'), variant('arm64')] })])); - assert.throws(validate([entry('tool:a', { platformVariants: [{ when: { platform: 'linux', architecture: 'riscv' }, sandboxPolicy: { version: v } }] })]), /architecture/); - }); - - it('rejects variants that name a containment backend', () => { - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, processContainer: {} })] })]), /containment backend/); - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, containment: 'lxc' })] })]), /containment backend/); - }); - - it('requires exact registered SandboxPolicy versions', () => { - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: '0.9.0' })] })]), /not a SandboxPolicy version registered/); - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: '0.10.0-alpha' })] })]), /not a SandboxPolicy version registered/); - }); - - it('rejects unsupported fields at every level', () => { - assert.throws(validate([entry('tool:a', { extra: 1 })]), /unsupported field/); - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, filesystem: { clearPolicyOnExit: true } })] })]), /unsupported field/); - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, network: { allowOutbound: true } })] })]), /unsupported field/); - assert.throws(validate([entry('tool:a', { platformVariants: [linux({ version: v, telemetry: {} })] })]), /unsupported field/); - }); - - it('rejects literal, user-specific, wildcard, traversal, and unknown-symbol paths', () => { - const path = (p: string) => validate([entry('tool:a', { platformVariants: [linux({ version: v, filesystem: { readonlyPaths: [p] } })] })]); - assert.throws(path('/home/alice/.npm'), /literal paths are not allowed/); - assert.throws(path('C:\\Users\\alice'), /literal paths are not allowed/); - assert.throws(path('/usr/lib'), /literal paths are not allowed/); - assert.throws(path('${project_root}/*'), /wildcard/); - assert.throws(path('${project_root}/../x'), /'\.\.'/); - assert.throws(path('${unknown_thing}'), /unknown symbol/); - assert.throws(path('${project_root'), /malformed symbol/); - assert.doesNotThrow(path('${project_root}/node_modules')); - }); - - it('rejects wildcard network grants', () => { - const net = (network: any) => validate([entry('tool:a', { platformVariants: [linux({ version: v, network })] })]); - assert.throws(net({ egress: { default: 'allow' } }), /default-allow/); - assert.throws(net({ ingress: { default: 'allow' } }), /default-allow/); - assert.throws(net({ egress: { allow: [{ ports: [{ port: 443 }] }] } }), /wildcard network grant/); - assert.throws(net({ egress: { allow: [{ to: [{ cidr: '0.0.0.0/0' }] }] } }), /wildcard network grant/); - assert.doesNotThrow(net({ egress: { default: 'deny', allow: [{ to: [{ cidr: '192.0.2.0/24' }], ports: [{ protocol: 'tcp', port: 443 }] }] } })); - }); - - it('allows overlapping identities across entries (additive matching) but not repeats within one entry', () => { - // Design §4.3: equal-strength matches to different entries both contribute. - assert.doesNotThrow(validate([entry('tool:a'), entry('tool:b', { identity: [{ kind: 'invocation-name', names: ['A'] }] })])); - assert.doesNotThrow(validate([ - entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/x' }] }), - entry('tool:b', { identity: [{ kind: 'purl', value: 'pkg:NPM/x' }] }), - ])); - // Predicate order within an entry carries no precedence. - assert.doesNotThrow(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['a'] }, { kind: 'purl', value: 'pkg:npm/a' }] })])); - assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['a', 'A'] }] })]), /repeats identity/); - assert.throws(validate([entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/a' }, { kind: 'purl', value: 'pkg:npm/a' }] })]), /repeats identity/); - assert.throws(validate([entry('tool:a', { identity: [{ kind: 'purl', value: 'pkg:npm/a@1.0.0' }] })]), /must not pin a version/); - assert.throws(validate([entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['bin/a'] }] })]), /bare invocation name/); - assert.throws(validate([entry('tool:a', { identity: [{ kind: 'sha256', value: 'x' }] })]), /not a supported identity kind/); - assert.throws(validate([entry('tool:a'), entry('tool:a', { identity: [{ kind: 'invocation-name', names: ['z'] }] })]), /duplicate entryId/); - assert.throws(validate([entry('tool:a', { entryId: 'noNamespace' })]), /namespaced/); - }); - - describe('v1 composition vocabulary', () => { - const withDep = (policy: any, depPolicy: any) => validate([ - entry('tool:a', { platformVariants: [linux(policy, { dependencies: [{ entryId: 'tool:b' }] })] }), - entry('tool:b', { platformVariants: [linux(depPolicy)] }), - ]); - - it('composes filesystem path classes and same-class duplicates', () => { - assert.doesNotThrow(withDep( - { version: v, filesystem: { readwritePaths: ['${project_root}'], readonlyPaths: ['${node_prefix}'] } }, - { version: v, filesystem: { readonlyPaths: ['${node_prefix}', '${git_prefix}'], deniedPaths: ['${user_home}/.ssh'] } }, - )); - }); - - it('rejects cross-class equal or ancestor/descendant paths', () => { - assert.throws(withDep( - { version: v, filesystem: { readwritePaths: ['${project_root}'] } }, - { version: v, filesystem: { readonlyPaths: ['${project_root}'] } }, - ), /overlaps/); - assert.throws(withDep( - { version: v, filesystem: { readwritePaths: ['${project_root}'] } }, - { version: v, filesystem: { deniedPaths: ['${project_root}/secrets'] } }, - ), /overlaps/); - }); - - it('rejects mixed sandboxPolicy versions', () => { - assert.throws(withDep({ version: v }, { version: '0.8.0-alpha' }), /mixed sandboxPolicy\.version/); - }); - - it('rejects network and other fields without a cross-entry rule, but allows them on standalone entries', () => { - const network = { egress: { default: 'deny' } }; - assert.throws(withDep({ version: v, network }, { version: v }), /'network'/); - assert.throws(withDep({ version: v }, { version: v, timeoutMs: 5 }), /'timeoutMs'/); - assert.throws(withDep({ version: v }, { version: v, ui: { clipboard: 'read' } }), /'ui'/); - assert.doesNotThrow(validate([entry('tool:a', { platformVariants: [linux({ version: v, network, ui: { clipboard: 'read' }, timeoutMs: 5 })] })])); - }); - }); -}); diff --git a/policy-catalog/tests/unit/vectors.test.ts b/policy-catalog/tests/unit/vectors.test.ts deleted file mode 100644 index 4e34c00bd..000000000 --- a/policy-catalog/tests/unit/vectors.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// The shared vectors in conformance/vectors/ are generated from this -// implementation (scripts/generate-vectors.mjs) and consumed by the Rust and -// C# bindings. This suite fails when the TypeScript behavior drifts from the -// committed vectors, so a behavior change must regenerate them deliberately. -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { canonicalJson, canonicalSha256, isAbsolutePath, normalizePath, parsePurl, pathKeySegments } from '@mxc-prototype/policy-catalog/tooling'; -import { readJson } from './helpers.js'; - -const vectorsDir = new URL('../../../conformance/vectors/', import.meta.url); - -describe('shared vectors: canonical JSON', () => { - const { cases } = readJson(new URL('canonical-json.json', vectorsDir)); - for (const [index, c] of cases.entries()) { - it(`case ${index}: ${c.json.slice(0, 40)}`, () => { - const value = JSON.parse(c.json); - assert.equal(canonicalJson(value), c.canonical); - assert.equal(canonicalSha256(value), c.sha256); - }); - } -}); - -describe('shared vectors: paths', () => { - const vectors = readJson(new URL('paths.json', vectorsDir)); - for (const platform of ['windows', 'linux', 'macos'] as const) { - it(`${platform}: isAbsolute, normalize, and case-folded key segments`, () => { - assert.ok(vectors[platform].length > 5); - for (const v of vectors[platform]) { - assert.equal(isAbsolutePath(v.input, platform), v.absolute, v.input); - if (v.normalized !== null) { - assert.equal(normalizePath(v.input, platform), v.normalized, v.input); - } - assert.deepEqual(pathKeySegments(v.input, platform), v.keySegments, v.input); - } - }); - } -}); - -describe('package URLs', () => { - it('malformed percent-encoding is an invalid package URL, not an exception', () => { - assert.equal(parsePurl('pkg:npm/npm@%E0%A4%A'), undefined); - assert.deepEqual(parsePurl('pkg:npm/npm@10.9.0'), { key: 'npm/npm', version: '10.9.0' }); - }); -}); diff --git a/policy-catalog/tests/unit/version-range.test.ts b/policy-catalog/tests/unit/version-range.test.ts deleted file mode 100644 index 64c657673..000000000 --- a/policy-catalog/tests/unit/version-range.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { isValidVersionRange, parsePurl, satisfiesVersionRange } from '@mxc-prototype/policy-catalog/tooling'; - -describe('version ranges', () => { - it('accepts the v1 grammar and rejects everything else', () => { - for (const range of ['>=10 <12', '22', '>=1.2.3', '=1.0.0', '<2 || >=4']) { - assert.ok(isValidVersionRange(range), range); - } - for (const range of ['', '^1.2.3', '~1', '1.x', '>= 1', '1 ||', 'latest']) { - assert.ok(!isValidVersionRange(range), range); - } - }); - - it('evaluates evidence versions and reports incomparable evidence', () => { - assert.equal(satisfiesVersionRange('10.9.0', '>=10 <12'), true); - assert.equal(satisfiesVersionRange('v12.0.0', '>=10 <12'), false); - assert.equal(satisfiesVersionRange('22.3.1', '22'), true); - assert.equal(satisfiesVersionRange('23.0.0', '22'), false); - assert.equal(satisfiesVersionRange('3.0.0', '<2 || >=3'), true); - assert.equal(satisfiesVersionRange('10.0.0-rc.1', '>=10'), true); - assert.equal(satisfiesVersionRange('nightly', '>=10'), undefined); - }); -}); - -describe('package URLs', () => { - it('extracts a version-free key and optional version', () => { - assert.deepEqual(parsePurl('pkg:npm/npm@10.9.0'), { key: 'npm/npm', version: '10.9.0' }); - assert.deepEqual(parsePurl('pkg:NPM/%40scope/pkg@1.0.0?x=y#sub'), { key: 'npm/%40scope/pkg', version: '1.0.0' }); - assert.deepEqual(parsePurl('pkg:npm/npm'), { key: 'npm/npm' }); - assert.equal(parsePurl('npm/npm'), undefined); - assert.equal(parsePurl('pkg:npm'), undefined); - }); -}); diff --git a/policy-catalog/tsconfig.json b/policy-catalog/tsconfig.json deleted file mode 100644 index 890a3f63f..000000000 --- a/policy-catalog/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "sourceMap": true, - "types": ["node"] - }, - "include": ["src/**/*.ts"] -} diff --git a/src/core/mxc_policy_store/README.md b/src/core/mxc_policy_store/README.md index 5c516da0a..8ae620852 100644 --- a/src/core/mxc_policy_store/README.md +++ b/src/core/mxc_policy_store/README.md @@ -19,7 +19,7 @@ SHA-256 on first use. Nothing is downloaded. Every MXC SDK uses this crate: |-----|-------------| | Rust (`mxc-sdk`) | `mxc_sdk::policy_store::{resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, get_catalog_info, list_catalog_entries}` | | Node (`@microsoft/mxc-sdk`) | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` through `mxc_ffi` | -| C# (`Microsoft.Mxc.Sdk`) | `PolicyStore.ResolveSandboxPolicy`, `PolicyStore.ResolveSandboxPolicyWithDiagnostics`, `PolicyStore.GetCatalogInfo`, `PolicyStore.ListCatalogEntries` through `mxc_ffi` | +| C# (`Microsoft.Mxc.Sdk`) | `MxcPolicyStore.ResolveSandboxPolicy`, `MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics`, `MxcPolicyStore.GetCatalogInfo`, `MxcPolicyStore.ListCatalogEntries` through `mxc_ffi` | This crate returns its own catalog-shaped policy model. `mxc-sdk` converts it to `mxc_sdk::policy::SandboxPolicy`, and `mxc_ffi` hands the same JSON to the diff --git a/src/core/mxc_policy_store/src/catalog.rs b/src/core/mxc_policy_store/src/catalog.rs index 12fbf3df0..a20851e8e 100644 --- a/src/core/mxc_policy_store/src/catalog.rs +++ b/src/core/mxc_policy_store/src/catalog.rs @@ -2,8 +2,8 @@ // Licensed under the MIT License. //! Contract and revision validation, variant selection, dependency closure, -//! and composition limits (TypeScript `src/catalog.ts`). Error messages are -//! identical to TypeScript. +//! and composition limits. Error messages match the original TypeScript +//! prototype byte for byte, so the frozen conformance vectors still apply. use crate::errors::{invalid_catalog, Result}; use crate::json::{cmp_utf16, js_number_to_string, js_to_string, Json, JsonObject}; diff --git a/src/core/mxc_policy_store/src/errors.rs b/src/core/mxc_policy_store/src/errors.rs index 604d85830..17caf4cda 100644 --- a/src/core/mxc_policy_store/src/errors.rs +++ b/src/core/mxc_policy_store/src/errors.rs @@ -2,8 +2,7 @@ // 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` (TypeScript -//! `src/errors.ts`). Warnings stay plain strings. +//! language-neutral sub-reason carried in `details.reason` by the SDKs. Warnings stay plain strings. use std::fmt; @@ -88,7 +87,7 @@ impl fmt::Display for ErrorReason { } } -/// A library failure. `message()` is `[] `, exactly as in TypeScript. +/// A library failure. `message()` is `[] `, exactly as in the original TypeScript prototype. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PolicyCatalogError { reason: ErrorReason, diff --git a/src/core/mxc_policy_store/src/history.rs b/src/core/mxc_policy_store/src/history.rs index b3c9285ea..855e4f65f 100644 --- a/src/core/mxc_policy_store/src/history.rs +++ b/src/core/mxc_policy_store/src/history.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. //! Entry-revision monotonicity and published-revision immutability -//! (TypeScript `src/history.ts`). use crate::catalog::{ compare_catalog_revisions, entry_semantic_key, revision_number, CatalogRevision, diff --git a/src/core/mxc_policy_store/src/host.rs b/src/core/mxc_policy_store/src/host.rs index 5fee5e706..a81c8115d 100644 --- a/src/core/mxc_policy_store/src/host.rs +++ b/src/core/mxc_policy_store/src/host.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. //! Host platform, native system architecture, and approved host symbols -//! (TypeScript `src/host.ts`). //! //! The native architecture is the device's, not the process's or the //! compile target's: diff --git a/src/core/mxc_policy_store/src/model.rs b/src/core/mxc_policy_store/src/model.rs index ad48e6651..b6b02154b 100644 --- a/src/core/mxc_policy_store/src/model.rs +++ b/src/core/mxc_policy_store/src/model.rs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Public request and result types (design §5), mirroring TypeScript -//! `src/types.ts`. Every result type renders to the exact JSON shape the -//! TypeScript library produces (`to_json`), with absent optional fields +//! Public request and result types (design §5). 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::json::{Json, JsonObject}; @@ -229,7 +229,7 @@ impl, V: Into> FromIterator<(K, V)> for SymbolMap { /// Runtime lookup context (design §5.1). `platform` and `architecture` are /// the caller's raw strings so that an unsupported value is reported as -/// `malformed_request` (`invalid_context`) exactly like TypeScript. +/// `malformed_request` (`invalid_context`) exactly like the original TypeScript prototype. #[derive(Clone, Debug, Default, PartialEq)] pub struct ResolveContext { pub project_root: Option, diff --git a/src/core/mxc_policy_store/src/paths.rs b/src/core/mxc_policy_store/src/paths.rs index 132f17b21..79ff42587 100644 --- a/src/core/mxc_policy_store/src/paths.rs +++ b/src/core/mxc_policy_store/src/paths.rs @@ -3,7 +3,7 @@ //! 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 (TypeScript `normalizePath`, `isAbsolutePath`, +//! 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 @@ -432,7 +432,7 @@ pub fn case_key(value: &str, platform: Platform) -> String { } /// Normalizes a path with the platform's rules, then removes trailing -/// separators unless only the root remains (TypeScript `normalizePath`). +/// 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)); diff --git a/src/core/mxc_policy_store/src/purl.rs b/src/core/mxc_policy_store/src/purl.rs index 670fc5447..91f2115dc 100644 --- a/src/core/mxc_policy_store/src/purl.rs +++ b/src/core/mxc_policy_store/src/purl.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Package URL parsing reduced to identity matching (TypeScript `src/purl.ts`). +//! Package URL parsing reduced to identity matching. /// A parsed package URL. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/core/mxc_policy_store/src/resolver.rs b/src/core/mxc_policy_store/src/resolver.rs index 6e5d8b856..82468fabc 100644 --- a/src/core/mxc_policy_store/src/resolver.rs +++ b/src/core/mxc_policy_store/src/resolver.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Runtime lookup and inspection (TypeScript `src/resolver.ts`). +//! Runtime lookup and inspection. use crate::catalog::{ composition_violation, dependency_closure, entry_index, find_cross_class_overlap, diff --git a/src/core/mxc_policy_store/src/store.rs b/src/core/mxc_policy_store/src/store.rs index 8c8c4e5a6..9d6c6af6c 100644 --- a/src/core/mxc_policy_store/src/store.rs +++ b/src/core/mxc_policy_store/src/store.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. //! Manifest validation and lazy, digest-checked, validated revision loading -//! (TypeScript `src/store.ts`), plus the catalog bundled into this crate. +//! plus the catalog bundled into this crate. use crate::catalog::{ compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, @@ -179,7 +179,7 @@ fn invalid(message: impl AsRef) -> Result { Err(invalid_catalog(message)) } -/// Validates `manifest.json` (TypeScript `validateManifest`). +/// 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"); diff --git a/src/core/mxc_policy_store/src/validate.rs b/src/core/mxc_policy_store/src/validate.rs index 4e69548cd..97cb20df8 100644 --- a/src/core/mxc_policy_store/src/validate.rs +++ b/src/core/mxc_policy_store/src/validate.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. //! Catalog-directory validation and the `--base-ref` published-revision -//! immutability check (TypeScript `src/validate.ts`). Tooling only: the +//! immutability check Tooling only: the //! runtime lookup path never runs git. use crate::errors::{invalid_catalog, PolicyCatalogError}; @@ -31,7 +31,7 @@ pub struct CatalogValidationReport { } impl CatalogValidationReport { - /// The report as `policy-catalog validate` prints it (absent fields omitted). + /// 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)); diff --git a/src/core/mxc_policy_store/src/version_range.rs b/src/core/mxc_policy_store/src/version_range.rs index 3466539ac..170168f39 100644 --- a/src/core/mxc_policy_store/src/version_range.rs +++ b/src/core/mxc_policy_store/src/version_range.rs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Minimal version-range support for catalog v1, identical to TypeScript -//! `src/version-range.ts`. +//! Minimal version-range support for catalog v1, identical to the original +//! TypeScript prototype. //! //! ```text //! range := comparatorSet ( "||" comparatorSet )* diff --git a/src/core/mxc_policy_store/tests/conformance.rs b/src/core/mxc_policy_store/tests/conformance.rs index ef75641b1..840162378 100644 --- a/src/core/mxc_policy_store/tests/conformance.rs +++ b/src/core/mxc_policy_store/tests/conformance.rs @@ -2,8 +2,8 @@ // Licensed under the MIT License. //! Runs every language-neutral conformance fixture in -//! `../conformance/fixtures/*.json` with the same expectations as the -//! TypeScript suite. +//! `../conformance/fixtures/*.json`. The mxc-sdk and mxc_ffi suites replay +//! the same fixtures through their binding entry points. mod common; diff --git a/src/core/mxc_policy_store/tests/library.rs b/src/core/mxc_policy_store/tests/library.rs index 4d3ce789e..e57dcc71b 100644 --- a/src/core/mxc_policy_store/tests/library.rs +++ b/src/core/mxc_policy_store/tests/library.rs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Ports of the TypeScript unit suites (`tests/unit/resolver.test.ts`, -//! `validation.test.ts`, `store.test.ts`). +//! Ports of the original TypeScript prototype's resolver, validation, and +//! store unit suites. mod common; @@ -690,7 +690,7 @@ fn module_level_functions_use_the_bundled_catalog() { } // --------------------------------------------------------------------------- -// Validation (validation.test.ts) +// Validation // --------------------------------------------------------------------------- fn dep(id: &str) -> String { @@ -984,7 +984,7 @@ fn validation_composition_vocabulary() { } // --------------------------------------------------------------------------- -// Store and history (store.test.ts) +// Store and history // --------------------------------------------------------------------------- #[test] diff --git a/src/core/mxc_policy_store/tests/vectors.rs b/src/core/mxc_policy_store/tests/vectors.rs index a87ebca7e..1fae4bf2a 100644 --- a/src/core/mxc_policy_store/tests/vectors.rs +++ b/src/core/mxc_policy_store/tests/vectors.rs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Shared vectors in `../conformance/vectors/` (generated from the -//! TypeScript implementation). +//! Frozen vectors in `../conformance/vectors/`. They were generated from the +//! original TypeScript prototype and are now maintained by hand. mod common; From 61bceffb7e68a6451588f66cb23ded1fff48dc64 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 1 Oct 2026 17:22:23 -0700 Subject: [PATCH 25/50] docs(policy-store): document the SDK-integrated prototype and review outcome Moves the design proposal to docs/policy-store/design.md and updates it for the design review outcome: the store ships inside MXC as new APIs in the existing SDKs (no separate repository, standalone library, or CLI), is not part of MXC 1.0, bundles V1 data statically, returns a best-effort floor, is complementary to Learning Mode, and remains pending API review with the current names. Open questions now cover dropping "Sandbox" from the names, intent-aware lookup, and schema compatibility within MXC 1.x. Folds the prototype architecture and contributing pages into docs/policy-store/README.md, adds prototype sections to the Node, Rust, and C# SDK READMEs and the mxc-sdk crate docs, and records the crate boundary in the Copilot instructions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 + docs/policy-store/README.md | 114 ++++++++ .../docs => docs/policy-store}/design.md | 271 ++++++++---------- policy-catalog/docs/architecture.md | 202 ------------- policy-catalog/docs/contributing.md | 161 ----------- sdk/dotnet/README.md | 42 ++- sdk/node/README.md | 34 +++ src/core/mxc-sdk/README.md | 45 +++ src/core/mxc-sdk/src/lib.rs | 6 + src/core/mxc_policy_store/README.md | 8 +- 10 files changed, 369 insertions(+), 516 deletions(-) create mode 100644 docs/policy-store/README.md rename {policy-catalog/docs => docs/policy-store}/design.md (77%) delete mode 100644 policy-catalog/docs/architecture.md delete mode 100644 policy-catalog/docs/contributing.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e71099c1..131d919a2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,6 +20,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. +- `mxc_policy_store` (prototype, pending API review) is the single policy store implementation and owns the bundled catalog data. `mxc-sdk` re-exports it and `mxc_ffi` exposes it to the Node and C# SDKs; do not reimplement resolution per language or move it into `mxc_engine`. See: @@ -27,6 +28,7 @@ See: - [`docs/versioning.md`](../docs/versioning.md) - [`docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`](../docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md) - [`docs/ci-validation-infrastructure.md`](../docs/ci-validation-infrastructure.md) +- [`docs/policy-store/README.md`](../docs/policy-store/README.md) (prototype policy store) - The relevant backend guide under `docs/` ## Build and validation diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md new file mode 100644 index 000000000..376e3ce68 --- /dev/null +++ b/docs/policy-store/README.md @@ -0,0 +1,114 @@ +# 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 a +candidate **floor** `SandboxPolicy` from a reviewed catalog bundled statically +in the MXC SDKs. A floor is a best-effort starting point, not a guarantee, and +it never grants access by itself: the caller reviews it, composes it with its +own policy and ceilings, and passes the result to the SDK's existing sandbox +APIs. It is complementary to Learning Mode, not a replacement. + +- [design.md](design.md) — the design proposal, updated for the design review + outcome. + +## Where it lives + +| Layer | Location | API | +|---|---|---| +| Resolver and V1 data | `src/core/mxc_policy_store` | Single implementation; owns `catalog/`, `schema/`, and `conformance/` | +| Rust SDK | `src/core/mxc-sdk/src/policy_store.rs` | `mxc_sdk::policy_store::{resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, get_catalog_info, list_catalog_entries}` | +| C ABI | `src/ffi/mxc_ffi/src/policy_store.rs` | `mxc_resolve_sandbox_policy_json`, `mxc_resolve_sandbox_policy_with_diagnostics_json`, `mxc_policy_catalog_info_json`, `mxc_list_policy_catalog_entries_json`, `mxc_policy_store_result_free` | +| Node SDK | `sdk/node/src/policy-store.ts` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` | +| C# SDK | `sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs` | `MxcPolicyStore.ResolveSandboxPolicy`, `ResolveSandboxPolicyWithDiagnostics`, `GetCatalogInfo`, `ListCatalogEntries` | + +There is one resolver. `build.rs` compiles the catalog into the +`mxc_policy_store` crate, so the data ships inside `mxc_ffi` and every SDK +that loads it; nothing is downloaded. The Rust SDK converts the store's policy +into `mxc_sdk::SandboxPolicy`. The Node and C# SDKs send a JSON request +(`{"tools", "context"?}`) through `mxc_ffi` and map the result onto their own +`SandboxPolicy` types. Resolution stays outside `mxc_engine`: it never selects +a backend or launches a sandbox. + +## Resolution pipeline + +One call resolves one input or an array of inputs in a single pass: + +1. **Validate inputs and context.** Invalid input fails as + `malformed_request` (`invalid_context`), never as absence. +2. **Load the revision** — the requested `catalogRevision`, or the bundled + default. A missing revision is `backend_error` (`revision_unavailable`); a + digest mismatch is `backend_error` (`integrity`). +3. **Match each input additively** in `entryId` order. A `purl` match is + strong; an `invocation-name` match is weak and needs + `allowWeakIdentityFallback`. Names and paths fold case on Windows and macOS + and are exact on Linux. +4. **Select a variant.** The exact architecture wins over the + architecture-neutral variant; another architecture is never a fallback. An + omitted architecture uses the device's native system architecture, not + the process architecture. +5. **Close over dependencies**, depth-first, rejecting cycles. +6. **Apply the composition limits**: one `sandboxPolicy.version`, and only + filesystem lists when more than one entry is selected. +7. **Resolve symbols.** `project_root` comes only from `projectRoot`. A + missing required symbol yields no policy plus a warning naming it; the + resolver never returns a partial policy. +8. **Compose.** Paths are substituted, normalized, and de-duplicated per + access class. Overlapping paths in different classes fail as + `policy_validation` (`composition_conflict`). + +Verifying that the executable really carries the strong identity passed in +`packageUrl` is the caller's job. + +## Failures + +Failures reuse MXC's error codes, with a stable sub-reason: `details.reason` +on the Node `MxcError`, `PolicyStoreException.Reason` in C#, and +`PolicyCatalogError::reason()` in Rust. + +| Code | Reason | Meaning | +|---|---|---| +| `policy_validation` | `invalid_catalog` | Catalog data violates the contract, or a resolved field does not fit the SDK `SandboxPolicy`. | +| `policy_validation` | `composition_conflict` | The selected entries cannot be composed under the V1 rules. | +| `malformed_request` | `invalid_context` | The caller's inputs or context are invalid. | +| `unsupported_containment` | `unsupported_host` | The host platform or native architecture cannot be determined. | +| `backend_error` | `integrity` | Catalog data does not match its published digest. | +| `backend_error` | `revision_unavailable` | An explicitly requested revision is not bundled. | + +Absence is not a failure: the resolve call returns no policy. + +## Changing the catalog + +Published revisions are immutable. Never edit a file under +`src/core/mxc_policy_store/catalog/revisions/` that is already listed in +`catalog/manifest.json`; publish a new revision instead. + +1. Copy the latest revision to `catalog/revisions/.json` and set + its `catalogRevision`. +2. Make the change. Bump `entryRevision` for every entry that changes. +3. Append `{ catalogRevision, file, sha256 }` to the manifest and point + `defaultRevision` at it. To get the digest, enter 64 zeros, run the tests + below, and copy the actual digest from the integrity failure. +4. Add or update a case in `conformance/fixtures/bundled-catalog.json`. Every + entry in the default revision needs one. +5. Run the checks (from `src/`): + + ```text + cargo test -p mxc_policy_store + cargo test -p mxc-sdk --test policy_store + cargo test -p mxc_ffi --test policy_store + ``` + + Set `MXC_POLICY_STORE_BASE_REF=origin/main` to also check that no + published revision changed. + +Each new entry or requirement expansion needs identity evidence, supported +version ranges, per-platform evidence, a minimized requirement set (captured +with Learning Mode before the pull request), fixtures, and `provenance`. See +[design §7](design.md#7-contribution-and-review). + +The JSON Schemas in `schema/` describe the catalog shape for editors. They are +not run in CI; the Rust validation in `tests/catalog_validation.rs` covers the +same rules and more. diff --git a/policy-catalog/docs/design.md b/docs/policy-store/design.md similarity index 77% rename from policy-catalog/docs/design.md rename to docs/policy-store/design.md index e33a9e4f0..f0a9c546e 100644 --- a/policy-catalog/docs/design.md +++ b/docs/policy-store/design.md @@ -1,31 +1,30 @@ - # Feature Spec: Known-tool Policy Floors -**Status:** Proposed public preview catalog. This is not an approved, shipped, or -implemented catalog. +**Status:** Prototype, pending API review and sign-off before check-in. The +policy store ships inside MXC as new APIs in the existing MXC SDKs. It is not +part of MXC 1.0; a later MXC SDK release is targeted. The API names in this +document are the current proposal and may change in review (see +[§13](#13-open-questions)). -**IMPORTANT NOTE:** This is a time-limited bridge, not a long-term supported -Microsoft product. Applying a published floor does not guarantee that a tool's -end-to-end workflow will work under process containment. The catalog and its -dedicated repository are expected to be retired when Learning Mode provides -the replacement workflow. +**IMPORTANT NOTE:** A resolved policy is a best-effort floor, not a guarantee. +Applying it does not guarantee that a tool's end-to-end workflow will work +under process containment. The policy store is complementary to Learning Mode, +not a replacement for it (see [§8](#8-relationship-to-learning-mode)). --- @@ -50,8 +49,8 @@ revision, and inspection behavior. It reuses #779's model where possible and calls out differences directly. This document does not restate general MXC sandboxing concepts already covered -by [`docs/sandbox-policy/0.8.0/policy.md`](sandbox-policy/0.8.0/policy.md) or -[`docs/versioning.md`](versioning.md). It covers only what a policy store adds. +by [`docs/sandbox-policy/0.8.0/policy.md`](../sandbox-policy/0.8.0/policy.md) or +[`docs/versioning.md`](../versioning.md). It covers only what a policy store adds. ### Non-goals @@ -69,8 +68,8 @@ by [`docs/sandbox-policy/0.8.0/policy.md`](sandbox-policy/0.8.0/policy.md) or ### MXC feature impact and defaults -This is a standalone catalog and library proposal. Following the feature-impact -checklist in [`docs/authoring-a-new-feature.md`](authoring-a-new-feature.md): +This adds known-tool lookup APIs to the existing MXC SDKs. Following the feature-impact +checklist in [`docs/authoring-a-new-feature.md`](../authoring-a-new-feature.md): - **Policy changes:** None. Catalog entries embed an existing, registered `SandboxPolicy`. @@ -78,14 +77,15 @@ checklist in [`docs/authoring-a-new-feature.md`](authoring-a-new-feature.md): fields or change omission behavior in an existing contract. - **OS and backend changes:** None. Backends continue to validate whether they can enforce the resolved policy. -- **MXC SDK changes:** None. This proposal does not add catalog lookup, types, - or resolver code to the MXC SDKs. -- **Standalone libraries:** The dedicated catalog repository provides its own - library API for TypeScript/JavaScript, Rust, and C#/.NET. See - [§6](#6-intended-repository-and-packaging-boundary). - -The proposed **public preview** designation describes the catalog's support -status. It does not add an MXC schema feature, activate the +- **MXC SDK changes:** Additive. The Node (`@microsoft/mxc-sdk`), Rust + (`mxc-sdk`), and C# (`Microsoft.Mxc.Sdk`) SDKs gain catalog lookup and + inspection APIs that return each SDK's existing `SandboxPolicy` type. Existing + APIs and behavior are unchanged. See + [§6](#6-packaging-inside-the-mxc-sdks). +- **No separate deliverable:** There is no separate repository, standalone + library, or CLI utility. + +The policy store does not add an MXC schema feature, activate the `--experimental` runtime gate, or change executor behavior. Defaults and omission behavior are: @@ -94,11 +94,11 @@ Defaults and omission behavior are: must explicitly enable or invoke it. - Omitted `ResolveContext.platform` uses the current host platform. - Omitted `ResolveContext.architecture` uses the device's native system - architecture, not the architecture of the library's process or a detected + architecture, not the architecture of the calling process or a detected tool build. Explicit caller selection takes precedence. See the selection rules and emulation risk in [§4.4](#44-platform-variants). -- Omitted `ResolveContext.catalogRevision` uses the currently installed - catalog revision. +- Omitted `ResolveContext.catalogRevision` uses the catalog revision bundled + with the SDK. - Omitted `ResolveContext.allowWeakIdentityFallback` is `false`. - Omitted `projectRoot` and `symbols` provide no caller overrides. The resolver may use approved host-known symbols, but it does not invent machine-specific @@ -113,11 +113,10 @@ Defaults and omission behavior are: ## 2. Ownership boundary -The proposed dedicated catalog project owns an integrity-validated, versioned, -read-only data set of known-tool sandbox requirements, its resolver libraries, -and their public APIs. MXC continues to own the existing `SandboxPolicy` -contract, but not the catalog entries, libraries, repository, or publication -lifecycle. +MXC owns an integrity-validated, versioned, read-only data set of known-tool +sandbox requirements, the resolver that reads it, and the SDK APIs that expose +it, alongside the existing `SandboxPolicy` contract. The data is reviewed and +released with the MXC SDKs. The catalog states a candidate minimum that a tool needs. It does not grant access, modify caller state, create a sandbox, or guarantee workflow success. @@ -293,7 +292,7 @@ Selection first filters by platform, then uses the following precedence: | Architecture omitted | Device's native system architecture for the selected platform | Architecture-neutral for that platform | The native system architecture is the architecture reported by the host OS, -not the architecture of the process hosting the library. For example, on an +not the architecture of the process hosting the SDK. For example, on an ARM64 device with both x64 and ARM64 catalog variants and no neutral variant, omitting architecture selects ARM64. An explicit `architecture: "x64"` selects x64 on that same device. The resolver does not require a neutral variant to @@ -303,7 +302,7 @@ Catalog validation rejects duplicate exact selectors and more than one architecture-neutral variant for the same platform. If neither an exact nor architecture-neutral variant exists, that entry contributes no match, not an empty policy or a variant for a different architecture. A failure to determine -the native system architecture when it is needed is a library error, not a +the native system architecture when it is needed is an SDK error, not a guessed selection. **Emulation risk:** A host-derived default does not establish the architecture @@ -369,19 +368,19 @@ catalog-supported policy fields because no cross-entry merge occurs. ## 5. API surface -The standalone libraries separate runtime resolution from catalog inspection. +The MXC SDKs separate runtime resolution from catalog inspection. Resolution accepts one tool or an array and composes all applicable matching entries and dependencies into one `SandboxPolicy`. Callers choose a policy-only operation or a diagnostic operation over the same resolution logic. Neither -implicitly returns the whole catalog. These are in-process library calls, not -a hosted service or additions to the MXC SDKs. +implicitly returns the whole catalog. These are in-process SDK calls, not a +hosted service, and they never launch a sandbox. The signatures below use TypeScript to describe the shared contract. Rust and C# expose the same operations and metadata with idiomatic names and types. TypeScript and C# expose single-tool and array overloads; Rust uses an idiomatic one-or-many input type because it does not support function overloading. An absent policy is `undefined` in TypeScript/JavaScript, `None` in Rust, and -`null` in C#. Library failures remain distinct from policy absence. +`null` in C#. Failures remain distinct from policy absence. ### 5.1 Runtime lookup @@ -556,92 +555,72 @@ A consumer that uses this API: records matched identities, catalog/entry revisions, warnings, and approval state in its own audit trail. -The catalog libraries never write a consumer's policy store. A consumer's own +The policy store APIs never write a consumer's own policy state. A consumer's own capability observation (see [§8](#8-relationship-to-learning-mode)) can produce candidate evidence for a future contribution to this catalog; it is not a mechanism for mutating the catalog at request time. -## 6. Intended repository and packaging boundary +## 6. Packaging inside the MXC SDKs -The catalog is intended to live in a new public repository outside -`microsoft/mxc`. Its schema, entries, resolver libraries, contribution history, -validation, and publication workflow belong there. This specification remains -in MXC while the proposed contract is reviewed. No catalog repository or -package is created by this proposal. +The policy store ships inside MXC as new APIs in the existing MXC SDKs. There +is no separate repository, standalone library, package, or CLI utility. A +consumer that already uses an MXC SDK resolves a floor and passes its final, +authorized policy to that SDK's existing sandbox APIs. -MXC retains the existing `SandboxPolicy` contract. The catalog libraries -produce policy data conforming to that contract; they do not require an MXC -executor or execution library to perform lookup. A consumer that uses MXC -passes its final, authorized policy to an existing MXC SDK separately. -Catalog and library releases do not require an MXC SDK release or changes to -MXC repository governance. +### 6.1 SDK surface and bundled data -### 6.1 Library distribution and consumption - -The initial library language coverage matches MXC's current first-party SDK -languages, but the packages are owned and released by the catalog project: - -| Language | Distribution | API form | +| Language | Package | API | |---|---|---| -| TypeScript / JavaScript | npm package | JavaScript library with TypeScript declarations | -| Rust | Cargo crate | Public Rust library API | -| C# / .NET | NuGet package | Managed library API | +| TypeScript / JavaScript | `@microsoft/mxc-sdk` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` | +| Rust | `mxc-sdk` (`mxc_sdk::policy_store`) | `resolve_sandbox_policy`, `resolve_sandbox_policy_with_diagnostics`, `get_catalog_info`, `list_catalog_entries` | +| C# / .NET | `Microsoft.Mxc.Sdk` (`MxcPolicyStore`) | `ResolveSandboxPolicy`, `ResolveSandboxPolicyWithDiagnostics`, `GetCatalogInfo`, `ListCatalogEntries` | -Repository and package names remain to be selected. This language match does -not require copying MXC's native-binding architecture or exposing sandbox -execution operations. +Each SDK returns its own existing `SandboxPolicy` type, not a parallel +catalog-only type. + +The V1 policy data is bundled statically in each SDK package. It is not +downloaded. Lookup is local and does not contact a hosted service or run the +candidate tool. `ResolveContext.catalogRevision` selects a bundled revision, +not a network lookup; an explicitly requested revision that is unavailable is +an error, not a substitution with a different revision. An omitted revision +uses the SDK's bundled default. A consumer: -1. Installs and pins the standalone library package for its language. Each - package includes a reviewed default catalog revision for local use. -2. Calls `getCatalogInfo()` or `listCatalogEntries()` for inspection. +1. Calls `getCatalogInfo()` or `listCatalogEntries()` for inspection. `resolveSandboxPolicy()` returns a policy for one tool or an array; `resolveSandboxPolicyWithDiagnostics()` adds match attribution and warnings. -3. Handles policy absence without widening its restrictive baseline. It +2. Handles policy absence without widening its restrictive baseline. It reviews the composed policy and uses the diagnostics operation when it needs contributing identities, revisions, and warnings, applying the consumer obligations in [§5.3](#53-consumer-obligations). -4. Supplies its final policy to its chosen execution integration. The catalog - library does not launch a sandbox. - -Lookup is local and does not download updates, contact a hosted service, or -run the candidate tool. `ResolveContext.catalogRevision` selects an available -local revision, not a network lookup; an explicitly requested revision that -is unavailable is an error, not a substitution with a different revision. -An omitted revision uses the library's installed default. - -Catalog revisions are also published as immutable, language-neutral data -artifacts. A library package version identifies the library release, not the -catalog revision or embedded `SandboxPolicy.version`; it declares the catalog -schema and policy versions it supports and reports its bundled -`catalogRevision`. Publishing newer data can update the packages' bundled -revision without changing resolver behavior. Installing an update does not -rewrite a consumer's previously accepted per-tool policies. - -### 6.2 Cross-language consistency and support - -All three libraries use the same catalog format and shared conformance -fixtures. Given the same catalog revision, tool inputs, and explicit resolution -context, they must agree on matching, variant selection, dependency metadata, -resolved policy, warnings, and failure categories. Language-specific absence -and error types must preserve those distinctions. - -Shared fixtures cover platform path semantics as well as ordinary lookup; -matching function names alone is not compatibility. Package CI must also -exercise installation, public API usage, and host-derived defaults on the -supported platforms. Implementation sharing between languages is a separate -engineering decision, not a requirement to depend on MXC's engine. - -Supporting three languages includes maintaining parity, dependencies, -documentation, and releases, not only writing the initial implementations. -The libraries and catalog have the same limited public-preview horizon and -are intended to retire together when Learning Mode replaces this workflow. +3. Supplies its final policy to the SDK's existing sandbox APIs. The lookup + APIs do not launch a sandbox. + +An SDK release reports its bundled `catalogRevision`; the SDK version does not +identify the catalog revision or embedded `SandboxPolicy.version`. Updating +the SDK does not rewrite a consumer's previously accepted per-tool policies. + +### 6.2 Implementation and cross-language consistency + +There is one resolver implementation, in Rust (`src/core/mxc_policy_store`), +which also owns the bundled catalog data, its JSON schemas, and the shared +conformance fixtures. The Rust SDK re-exports it. The Node and C# SDKs reach it +through the existing `mxc_ffi` C ABI, the same native library they already +load, so all three SDKs agree on matching, variant selection, dependency +metadata, resolved policy, warnings, and failure categories by construction. +Language-specific absence and error types preserve those distinctions. + +Resolution stays outside `mxc_engine`: it never selects a backend or launches +a sandbox, so it does not belong in the execution engine. + +Shared fixtures cover platform path semantics as well as ordinary lookup, and +each SDK's own test suite exercises its binding against them. ## 7. Contribution and review -- Catalog contributions are pull requests against the dedicated catalog - repository. No client or SDK can write a catalog entry at runtime. +- Catalog contributions are pull requests against `microsoft/mxc`. No client + or SDK can write a catalog entry at runtime. - Every entry change includes identity evidence, supported tool version range(s), platform evidence, a minimized requirement set, test fixtures, and provenance. @@ -654,20 +633,21 @@ are intended to retire together when Learning Mode replaces this workflow. evidence where available. - A requirement reduction requires regression evidence that every supported tool version still functions under the narrower requirement. -- Library API and implementation contributions are reviewed in the dedicated - catalog repository. These contribution requirements do not require - applications to seek maintainer approval to use the public catalog or - libraries. +- SDK API changes follow MXC's normal API review. These contribution + requirements do not require applications to seek maintainer approval to use + the bundled catalog. ## 8. Relationship to Learning Mode -Learning Mode is the intended long-term solution. The known-tool catalog only -reduces immediate first-run failures while that workflow is completed. It is -not a parallel long-term policy platform. +Learning Mode is complementary to the policy store, not a replacement for it. +The policy store supplies a reviewed, best-effort starting floor for known +tools before anything has run; Learning Mode observes what a specific workload +actually touches and helps author policy for it, including for tools the +catalog does not know. MXC's learning-mode capabilities (`learningModeLogging`, `permissiveLearningMode`, `captureDenials`; see -[`docs/learning-mode/capabilities.md`](learning-mode/capabilities.md)) are the +[`docs/learning-mode/capabilities.md`](../learning-mode/capabilities.md)) are the substrate a contributor can use to observe what a tool actually touches, the same way [#779 §5.1](https://github.com/microsoft/mxc/pull/779) describes for config floors. That observation workflow is unchanged by this document and @@ -676,10 +656,7 @@ consumer runtime behavior and not a catalog-mutation path. Whether and how a consumer turns its own runtime capability observations into a candidate catalog contribution or a locally scoped policy suggestion is -that consumer's design. No runtime submission hook is proposed here. When -Learning Mode can provide the required observation and policy-authoring -experience directly, this catalog should be retired rather than promoted into -a durable platform. +that consumer's design. No runtime submission hook is proposed here. ## 9. Trust model @@ -717,16 +694,14 @@ have cached or recorded in an audit trail. - No change to `SandboxPolicy` or `ContainerConfig` schema. - No change to executor behavior. -- No change to the MXC SDK APIs or dependencies. Catalog lookup requires an - explicit call to a standalone library; existing MXC callers see no behavior - change. -- Catalog schema and API compatibility are limited to the stopgap's support - horizon. Retirement in favor of Learning Mode is an expected outcome, not a - normal promotion milestone. +- The MXC SDK changes are additive. Catalog lookup requires an explicit call + to a new SDK API; existing MXC callers see no behavior change. +- The policy store is not part of MXC 1.0. Once it ships, the catalog schema + stays compatible within MXC 1.x. ## 12. Test plan -**Resolver libraries (TypeScript/JavaScript, Rust, and C#/.NET)** +**Resolver (Rust, exercised directly and through each SDK binding)** - shared conformance fixtures produce equivalent results and failure categories in all three languages @@ -754,14 +729,14 @@ have cached or recorded in an audit trail. selectors are rejected; no matching variant produces `undefined` - on an ARM64 host with both architecture-specific variants and no neutral variant, omitted architecture selects ARM64; explicit x64 selects x64 -- a library process running as x64 under emulation on an ARM64 host still +- an SDK process running as x64 under emulation on an ARM64 host still defaults to the native ARM64 system architecture, not its process architecture - a missing exact variant falls back to the platform's neutral variant; a different architecture's variant is never used as a fallback - successful host-derived selection and neutral fallback produce the diagnostics specified in [§5.1](#51-runtime-lookup); host-architecture - detection failure produces a library error, not a guessed match + detection failure produces an SDK error, not a guessed match - dependency chain resolution, including cycles (terminate, no duplication) - dependency `versionRange` is returned as unevaluated metadata and never used for v1 resolver matching @@ -781,12 +756,12 @@ have cached or recorded in an audit trail. **Integration** -- each package installs and performs lookup without an MXC executor or - execution library; lookup requires no network access +- each SDK performs lookup without launching a sandbox or selecting a + backend; lookup requires no network access - the bundled catalog revision matches `getCatalogInfo()`; selecting an unavailable revision fails explicitly, without falling back to another revision -- a package update leaves previously accepted consumer policies unchanged +- an SDK update leaves previously accepted consumer policies unchanged - a representative tool that fails under a minimal consumer policy succeeds once its resolved entry is composed in - the same tool still fails when the consumer's policy forbids what the entry @@ -798,23 +773,23 @@ Recommended answers are proposals for review, not decisions. | Question | Recommended answer | |---|---| -| What is the dedicated repository name and owning team? | Use a public repository outside `microsoft/mxc`; publish a separately versioned artifact so catalog updates are not coupled to SDK releases. | +| Final API names? | Pending API review. Current names are kept for now; the names may drop "Sandbox" (for example, `resolvePolicy`). | +| Does lookup need an intent, such as `git pull` versus `git push`? | Open. One floor per tool can over-grant a read-only intent; an optional intent input could select a narrower variant. | +| How does the catalog schema evolve? | Keep it compatible within MXC 1.x; a breaking change waits for a major version. | | Is invocation-name-only identity accepted automatically, or does it require explicit consumer opt-in? | Treat it as a fallback requiring explicit opt-in (`allowWeakIdentityFallback`), not the default. | | What happens on a detected tool-version mismatch: `undefined`, or a warning-bearing result the consumer may still use? | Return the resolved result with a warning; refusing outright removes information the consumer needs to decide for itself. | -| Are private or enterprise catalog overlays in scope, and if so with what precedence? | Defer until the shared catalog contract and its API are stable; define precedence explicitly before any library implementation adds overlay support. | +| Are private or enterprise catalog overlays in scope, and if so with what precedence? | Defer until the shared catalog contract and its API are stable; define precedence explicitly before the SDKs add overlay support. | | Should the first contract version's composition vocabulary expand beyond [§4.5](#45-dependencies-and-composition) before implementation? | No. Start with conflict-rejecting filesystem composition and expand only with an explicit, reviewed rule per field. | -| Who owns catalog schema, data, and library API review? | Assign catalog, library, and security reviewers in the dedicated repository; no MXC SDK integration is proposed. | -| Should the libraries share a resolver implementation or implement the contract independently? | Choose based on dependency footprint and maintenance cost, with shared conformance fixtures required either way. | +| Who owns catalog schema, data, and API review? | MXC, through its normal API review, with named catalog and security reviewers. | ## 14. Related work - [`microsoft/mxc#779`](https://github.com/microsoft/mxc/pull/779) - Sandbox Config Floors feature spec. This document's data model, floor/policy direction argument, and identity-layering analysis build directly on it. -- [`ChazGo/mxc#1`](https://github.com/ChazGo/mxc/pull/1) - draft SDK resolver - and catalog prototype exercising lookup, dependency closure, and symbol - resolution against an earlier version of this shape. -- [`docs/sandbox-policy/0.8.0/policy.md`](sandbox-policy/0.8.0/policy.md) - +- [`ChazGo/mxc#1`](https://github.com/ChazGo/mxc/pull/1) - the prototype of + this design inside the MXC SDKs, pending API review. +- [`docs/sandbox-policy/0.8.0/policy.md`](../sandbox-policy/0.8.0/policy.md) - the `SandboxPolicy` contract every catalog entry embeds. -- [`docs/versioning.md`](versioning.md) - the versioning model +- [`docs/versioning.md`](../versioning.md) - the versioning model [§4.1](#41-versions) builds on. diff --git a/policy-catalog/docs/architecture.md b/policy-catalog/docs/architecture.md deleted file mode 100644 index 125b7625e..000000000 --- a/policy-catalog/docs/architecture.md +++ /dev/null @@ -1,202 +0,0 @@ -# Architecture - -This page describes how the prototype implements the -[design proposal](design.md): the TypeScript library in `src/`, and the native -Rust (`rust/`) and .NET (`dotnet/`) libraries that match it (see -[Language bindings](#language-bindings)). The design is authoritative. Where -this page and the design disagree, the design wins and this page is a bug. - -## Boundary - -The library owns a read-only, integrity-validated catalog and a resolver that -turns tool inputs into a **candidate lower-bound** `SandboxPolicy` -([design §2](design.md#2-ownership-boundary)). - -The library does not: - -- grant access or authorize anything -- create or launch a sandbox -- contact the network or download catalog updates -- run or inspect the candidate tool -- write any consumer state -- import from, or require, the MXC SDKs or executors - -Consumers keep every decision the design assigns to them: whether lookup is -enabled, authorization and elevation, persistence, composition with their own -layers and ceilings, approval, audit, and the final sandbox creation. - -## Modules - -| Module | Responsibility | -|---|---| -| `src/types.ts` | Public types from design §5 (`ToolInput`, `ResolveContext`, `SandboxConfigResolution`, metadata). | -| `src/errors.ts` | `PolicyCatalogError`: MXC error `code` plus stable `details.reason`. | -| `src/canonical-json.ts` | Canonical JSON and SHA-256 used for revision integrity. | -| `src/catalog.ts` | Contract and revision validation, variant selection, dependency closure, composition limits. | -| `src/store.ts` | Manifest validation; lazy, digest-checked, deep-frozen revision loading. | -| `src/host.ts` | Host platform, native system architecture, and approved host symbols. | -| `src/resolver.ts` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries`. | -| `src/history.ts` | `entryRevision` monotonicity and published-revision immutability checks. | -| `src/validate.ts` | Catalog-directory validation and the `--base-ref` immutability check, shared by `policy-catalog validate` and `scripts/validate-catalog.mjs`. Tooling only; the runtime lookup path never runs git. | -| `src/cli.ts` | `policy-catalog resolve \| inspect \| validate` command-line harness over the public API (functional tests, CI, contributors). | -| `src/tooling.ts` | `./tooling` subpath exports for validation scripts and tests. It is not part of the runtime API. | - -## Resolution pipeline - -One call resolves one input or an array of inputs in a single pass. A single -input is treated exactly like a one-element array. - -1. **Validate inputs and context.** Invalid input throws `malformed_request` - (`invalid_context`). It - is never reported as absence. -2. **Load the revision.** Use the requested `catalogRevision`, or the - installed default. A missing revision is `backend_error` - (`revision_unavailable`), never a substitution. A digest mismatch is - `backend_error` (`integrity`). -3. **Match each input additively** (design §4.3). Entries are visited in - `entryId` order, so catalog file order is irrelevant. For each entry, every - satisfied predicate is collected in declaration order: - - A `purl` match is strong. - - An `invocation-name` match is weak and needs `allowWeakIdentityFallback`. - - Invocation names follow the per-OS casing rule below. - - A strong match never suppresses another entry's eligible weak match. -4. **Select a variant** (design §4.4). The exact architecture wins over the - architecture-neutral variant, and another architecture is never used as a - fallback. An omitted architecture uses the native system architecture (see - below), which is detected lazily, only when a candidate entry needs it. -5. **Close over dependencies.** Traversal is depth-first in declaration order - and rejects cycles. Every selected entry is keyed by `entryId`, so it - contributes once no matter how many inputs or edges reach it. -6. **Apply the composition limits** (design §4.5) to the whole selected set: - - every policy declares the same `sandboxPolicy.version` - - with more than one entry, only `filesystem.{deniedPaths,readonlyPaths,readwritePaths}` - may appear -7. **Resolve symbols.** `project_root` comes only from `projectRoot`. Other - symbols come from `symbols`; `host` symbols are derived only for the current - host platform. If any required symbol is missing, the result is - `policy: undefined` plus a warning that names the symbol. The resolver never - returns a partial policy. -8. **Compose.** Paths are substituted, normalized with the platform's path - rules, and de-duplicated per access class in first-seen order. Equal or - ancestor/descendant paths in different classes throw `policy_validation` - (`composition_conflict`). The resolver never picks an access class. - -## Casing - -One rule per target platform (`foldsCase` in `src/catalog.ts`), used for both -invocation-name matching and path comparison (de-duplication and -cross-class overlap): - -- **Windows and macOS:** case-insensitive. Default NTFS and APFS/HFS+ - volumes resolve names this way. -- **Linux:** exact. `gh` and `GH` are different tools and different paths. - -Catalog validation intentionally differs: it treats invocation names -case-insensitively on every platform when it looks for a repeated identity -within an entry, so `["gh", "GH"]` is rejected even in a Linux-only entry. - -## Identity strength and the caller's verification duty - -Name-only (`invocation-name`) matching is **off by default**. A caller opts -in per call with `allowWeakIdentityFallback: true` (CLI `--allow-weak`). - -The catalog records *strong* evidence (package URL today; signer or package -ID where the design adds them). It cannot check that the binary about to run -actually carries that evidence. **Verifying that the executable really has -the strong identity passed in `packageUrl` is the caller's job.** A -`packageUrl` the caller did not verify is no stronger than a name. - -Diagnostics follow the design exactly: - -- tool records in input order -- matches ordered by `entryId` -- matched predicates in declaration order -- dependency metadata de-duplicated and ordered by - entryId/entryRevision/requiredVersionRange -- warnings for weak-only matches, multi-entry matches, version-range - mismatches, unmatched inputs, the host-default architecture, and neutral - fallback - -## Native system architecture - -The spec requires the device's native architecture rather than the process -architecture ([design §4.4](design.md#44-platform-variants)). -`nodeHostEnvironment.nativeArchitecture()` reads the following: - -- **Windows:** the machine-wide `PROCESSOR_ARCHITECTURE` in - `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, read - with `%SystemRoot%\System32\reg.exe`. An x64 process emulated on ARM64 sees - `AMD64` in its own environment and from `os.machine()`, but the machine-wide - value is `ARM64`. -- **macOS:** `sysctl -n hw.optional.arm64`, read with `/usr/sbin/sysctl`. It - reports Apple silicon even under Rosetta. Intel Macs lack the key. -- **Linux:** `os.machine()`, the kernel machine type. - -Executables are invoked by absolute path. An unknown or unreadable value throws -`unsupported_containment` (`unsupported_host`). The resolver never guesses. The result is cached for the -process. - -## Integrity and immutability - -`catalog/manifest.json` lists every published revision with a SHA-256 over its -canonical JSON form (keys sorted recursively, no insignificant whitespace), so -formatting or line-ending changes never invalidate a digest. Loaded revisions -are deep-frozen. The validation pipeline compares proposed changes against a -base git ref and rejects any change to a published revision's manifest entry -or content ([design §10](design.md#10-immutable-revisions)). - -## Failure codes - -Failures reuse MXC's existing `MxcError` codes (MXC `sdk/node/src/errors.ts`, -Rust `MxcErrorCode`) where the meaning fits; the library adds no new code. -A stable, snake_case sub-reason is carried in `details.reason`, following -MXC's convention of putting structured information in `details`. Conformance -fixtures and the cross-language check assert on both. Warnings are plain -strings, as they are everywhere in MXC. - -| `code` | `details.reason` | Meaning | -|---|---|---| -| `policy_validation` | `invalid_catalog` | Catalog, manifest, or contract data violates the catalog contract: shape, unknown symbol, dependency cycle, missing dependency, base-ref check. | -| `policy_validation` | `composition_conflict` | The selected entries, or their resolved paths, cannot be composed under the v1 rules. | -| `malformed_request` | `invalid_context` | The caller's inputs or `ResolveContext` are invalid. | -| `unsupported_containment` | `unsupported_host` | The host platform or native architecture cannot be mapped or determined. The resolver never guesses. | -| `backend_error` | `integrity` | Catalog data cannot be read, or does not match its published digest (tamper). | -| `backend_error` | `revision_unavailable` | An explicitly requested revision is not installed. | - -The CLI prints a failure as -`{"error": {"code": "...", "message": "...", "details": {"reason": "..."}}}` -and exits 1. - -Absence is not a failure. `resolveSandboxPolicy` returns `undefined`, and -`resolveSandboxPolicyWithDiagnostics` returns `policy: undefined` with its -diagnostics. - -## Language bindings - -"The policy catalog library" has three native implementations with one -behavior ([design §6.2](design.md#62-cross-language-consistency-and-support)): - -| Language | Location | Package | Entry points | -|---|---|---|---| -| TypeScript | `src/` | npm `@mxc-prototype/policy-catalog` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics` | -| Rust | `rust/` (own `[workspace]`) | crate `mxc-policy-catalog` | `resolve_sandbox_policy`, `resolve_sandbox_policy_with_diagnostics` | -| C# | `dotnet/` (net8.0) | NuGet `Microsoft.Mxc.PolicyCatalog` | `ResolveSandboxPolicy`, `ResolveSandboxPolicyWithDiagnostics` | - -The Rust and .NET libraries are not wrappers. Each one embeds the bundled -catalog and reimplements canonical JSON, digest checks, path normalization -(Node `path.win32`/`path.posix` semantics), purl and version-range parsing, -variant selection, dependency closure, composition, and the error mapping -above. Parity is enforced at three levels: - -- `conformance/fixtures/*.json`: resolution cases, including expected - warnings and `{code, reason}` errors, run by every language's unit tests. -- `conformance/vectors/*.json`: path-normalization and canonical-JSON - vectors generated from the TypeScript implementation. -- `scripts/cross-language-check.mjs`: runs the three `policy-catalog` CLIs - with identical arguments (resolve, inspect, validate, library failures, - and usage errors) and requires byte-identical JSON after sorting keys and - replacing temporary directory paths. CI runs it on every OS and - architecture in the matrix. - -Warning and error message text is part of that contract, so a wording change -in one language is a change in all three. diff --git a/policy-catalog/docs/contributing.md b/policy-catalog/docs/contributing.md deleted file mode 100644 index 3339e9650..000000000 --- a/policy-catalog/docs/contributing.md +++ /dev/null @@ -1,161 +0,0 @@ -# Contributing - -This page describes the contribution flow for catalog data and for the library -([design §7](design.md#7-contribution-and-review)). No client or library can -write a catalog entry at runtime. Every change is a pull request. - -## Catalog changes - -Published revisions are immutable -([design §10](design.md#10-immutable-revisions)). Never edit a file under -`catalog/revisions/` that is already listed in `catalog/manifest.json`. That -includes corrections and security fixes: publish a new revision instead. - -1. Copy the latest revision to `catalog/revisions/.json` and set - `catalogRevision` to the same identifier. -2. Make the change. Bump `entryRevision` for every entry that changes - semantically, and leave unchanged entries alone. Validation enforces both - rules. -3. Build, compute the new file's digest, and append it to the manifest: - - ```text - npm run build - npm run digest -- catalog/revisions/.json - ``` - - Add `{ catalogRevision, file, sha256 }` to the end of `revisions` and point - `defaultRevision` at it. -4. Add or update cases in `conformance/fixtures/`. Every entry in the default - revision must appear in at least one bundled-catalog case. -5. Run the full check: - - ```text - npm run check - npm run validate -- --base-ref=origin/main - npx policy-catalog validate --base-ref origin/main # same check through the CLI - ``` - -### Entry requirements - -Each new entry, or each requirement expansion, needs the following -([design §7](design.md#7-contribution-and-review)): - -- identity evidence and the supported tool version ranges -- evidence for each platform variant -- a minimized requirement set; capture it with MXC Learning Mode before the - pull request ([design §8](design.md#8-relationship-to-learning-mode)) -- test fixtures -- `provenance` that references the review evidence - -A requirement reduction needs regression evidence that every supported tool -version still works under the narrower requirement. - -Review requires one catalog-owner approval and one security/policy-reviewer -approval, plus tool- or scenario-owner evidence where available. CI cannot -enforce this; the repository's branch rules and CODEOWNERS must. - -### Rules enforced by `npm run validate` - -- JSON Schema conformance (`schema/`) -- digest integrity for every revision in the manifest -- exact registered `SandboxPolicy` versions (`catalog/contract.v1.json`) -- entry-ID uniqueness, with no repeated identity predicate within one entry -- valid platform and architecture selectors: no duplicate exact selector and - at most one architecture-neutral variant per platform -- no backend-specific keys in a variant -- dependency closure within the same revision, with cycles rejected -- only declared symbols; every path is anchored at a symbol, with no literal, - user-specific, wildcard, or `..` paths -- no wildcard network grants: no default-allow, no allow rule without `to`, - no `/0` CIDR -- unsupported-field rejection -- v1 composition limits within every dependency closure -- `entryRevision` monotonicity across consecutive revisions -- published-revision immutability against `--base-ref=` (or `POLICY_CATALOG_BASE_REF`); an unknown ref fails -- deterministic, file-order-independent resolution for every selector -- package inclusion -- fixture coverage - -## Library changes - -Changes to the library API or behavior must keep the shared contract intact: - -- Any behavior visible across languages goes into `conformance/fixtures/`, - and the change must describe what the Rust and C# bindings need to match - ([design §6.2](design.md#62-cross-language-consistency-and-support)). -- Error codes, `details.reason` values, and absence semantics are part of the - contract. Changing them is a breaking change. -- Run `npm run check` and `npm run check:extract -- --worktree` before - opening a pull request. If you touch `rust/`, `dotnet/`, or anything the - CLIs print, also run the Rust and .NET commands below and - `node scripts/cross-language-check.mjs` (after `npm run build`). - -## Rust and .NET bindings - -The Rust crate `mxc-policy-catalog` (`rust/`, its own Cargo `[workspace]`) -and the .NET package `Microsoft.Mxc.PolicyCatalog` (`dotnet/`, net8.0) are -native implementations of the same behavior, not wrappers around the -TypeScript library. Each one embeds the bundled catalog, passes every case in -`conformance/fixtures/` and `conformance/vectors/`, and ships a -`policy-catalog` CLI whose output must match the TypeScript CLI. - -```text -# Rust (toolchain pinned by rust/rust-toolchain.toml) -cd rust -cargo fmt --check -cargo clippy --locked --all-targets --all-features -- -D warnings -cargo test --locked -cd .. -node scripts/rust-functional.mjs # cargo package, then a consumer crate outside the repo - -# .NET (SDK pinned by dotnet/global.json) -cd dotnet -dotnet build Microsoft.Mxc.PolicyCatalog.slnx -c Release -dotnet test --solution Microsoft.Mxc.PolicyCatalog.slnx -c Release --no-build -cd .. -node scripts/dotnet-functional.mjs # dotnet pack, then a consumer project on a local feed outside the repo - -# All three CLIs must print byte-identical normalized JSON -npm run build -node scripts/cross-language-check.mjs -``` - -When `src/` behavior changes, change the Rust and .NET code in the same pull -request, add or update a conformance fixture, and add a cross-language case -if the CLI output changes. - -## Test layout - -| Suite | Location | Command | -|---|---|---| -| Unit and conformance | `tests/unit/*.test.ts` | `npm run test:unit` | -| Functional (installed package, end to end) | `tests/functional/*.test.ts` | `npm run test:functional` | -| Catalog validation | `scripts/validate-catalog.mjs` | `npm run validate` | -| Package contents | `scripts/check-pack.mjs` | `npm run check:pack` | -| Offline install smoke test | `scripts/package-smoke.mjs` | `npm run smoke:package` | -| Standalone extraction | `scripts/check-extractable.mjs` | `npm run check:extract` | -| Rust unit and conformance | `rust/src`, `rust/tests` | `cargo test --locked` (in `rust/`) | -| Rust functional (packaged `.crate`) | `scripts/rust-functional.mjs` | `node scripts/rust-functional.mjs` | -| .NET unit and conformance | `dotnet/Microsoft.Mxc.PolicyCatalog.Tests` | `dotnet test` (in `dotnet/`) | -| .NET functional (packed `.nupkg`) | `scripts/dotnet-functional.mjs` | `node scripts/dotnet-functional.mjs` | -| Cross-language CLI parity | `scripts/cross-language-check.mjs` | `node scripts/cross-language-check.mjs` | - -The layout mirrors MXC's `sdk/node/tests/integration`. Each suite directory -has its own `tsconfig.json` that compiles `*.test.ts` into that directory's -`dist/`, and a `run-tests.js` that runs the compiled `*.test.js` files with -`node --test --test-reporter spec --test-force-exit`. Before either runner, -`scripts/check-tests-present.mjs` (after MXC's -`scripts/versioning/check-tests-present.js`) fails the run when the compiled -directory holds no test files. - -- Unit tests import the package by its own name, so they exercise the - compiled `dist/` output that ships. -- Functional tests never use the repository's `dist/` or `catalog/`. - `tests/functional/run-tests.js` runs `npm pack`, installs the tarball - offline into a new consumer project in a temporary directory outside the - repository, and passes that directory to the tests. The tests load the - library through the installed package's `exports` and run the installed - `bin`, in a separate process. `helpers.ts` refuses to run if the - installed package resolves inside the source tree. -- Functional tests that need a defective catalog write a synthetic or - tampered copy into a temporary directory and pass it with `--catalog`. diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index c363afc98..47f9531d6 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -549,6 +549,45 @@ PSReadLine directory required when `pwsh.exe` is present. The profile helper discovers per-user tool installations, while the temporary files helper grants the configured host temporary directory read-write. +### Policy store (prototype) + +> **PROTOTYPE, pending API review.** These APIs are proposed and may change +> before sign-off. They are not part of MXC 1.0. + +`MxcPolicyStore` resolves known tools (for example `git`, `node`, `npm`) to a +candidate floor `SandboxPolicy` from a reviewed catalog bundled in `mxc_ffi`. +Nothing is downloaded. A floor is a best-effort starting point, not a +guarantee, and it never grants access by itself: review it, compose it with +your own policy, then run. It complements Learning Mode rather than replacing +it. + +```csharp +var context = new ResolveContext +{ + ProjectRoot = "/work/repo", + Symbols = new Dictionary + { + ["node_prefix"] = "/opt/node", + ["npm_prefix"] = "/opt/npm", + ["npm_cache"] = "/home/me/.npm", + }, +}; + +// The SDK's own SandboxPolicy, or null when nothing resolves. +SandboxPolicy? floor = MxcPolicyStore.ResolveSandboxPolicy( + new ToolInput("npm") { PackageUrl = "pkg:npm/npm" }, context); + +// Name-only matches need an explicit opt-in; diagnostics report what matched. +var resolution = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics( + new ToolInput[] { "git", "npm" }, + context with { AllowWeakIdentityFallback = true }); +``` + +`GetCatalogInfo()` and `ListCatalogEntries()` inspect the bundled catalog. +Failures throw `PolicyStoreException`, which carries the `ErrorCode`, the +stable store `Reason` (for example `invalid_context`), and the underlying +`MxcException`. See [`docs/policy-store/`](../../docs/policy-store/README.md). + ### Denial capture (Windows) Select explicit ProcessContainer containment and set @@ -776,7 +815,8 @@ Exposes **run-to-completion** (`Run` / `RunAsync`), **streaming** ProcessContainer, Linux Bubblewrap, macOS Seatbelt, and Windows IsolationSession and WSLC for run/stream; the state-aware lifecycle supports IsolationSession, Windows Sandbox, and WSLC on Windows. Windows Sandbox -requires experimental opt-in; IsolationSession and WSLC do not). +requires experimental opt-in; IsolationSession and WSLC do not). It also +exposes the prototype policy store (`MxcPolicyStore`, pending API review). `SchemaVersions` exposes the minimum and maximum accepted schema versions, the latest stable schema, and the backend-specific state-aware defaults. These diff --git a/sdk/node/README.md b/sdk/node/README.md index 8c8b86a5f..17a233c93 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -502,6 +502,34 @@ Each helper returns `{ readonlyPaths, readwritePaths }` — merge what you want --- +## Policy Store (prototype) + +> **PROTOTYPE, pending API review.** These APIs are proposed and may change before sign-off. They are not part of MXC 1.0. + +The policy store resolves known tools (for example `git`, `node`, `npm`) to a candidate floor `SandboxPolicy` from a reviewed catalog bundled in the SDK's native library. Nothing is downloaded. A floor is a best-effort starting point, not a guarantee, and it never grants access by itself: review it, compose it with your own policy, then pass the result to the spawn APIs. It complements Learning Mode rather than replacing it. + +```typescript +import { resolveSandboxPolicy, resolveSandboxPolicyWithDiagnostics } from '@microsoft/mxc-sdk'; + +const context = { + projectRoot: '/work/repo', + symbols: { node_prefix: '/opt/node', npm_prefix: '/opt/npm', npm_cache: '/home/me/.npm' }, +}; + +// A SandboxPolicy, or undefined when nothing resolves. +const floor = resolveSandboxPolicy({ invocationName: 'npm', packageUrl: 'pkg:npm/npm' }, context); + +// Name-only matches need an explicit opt-in; diagnostics report what matched. +const { policy, diagnostics } = resolveSandboxPolicyWithDiagnostics( + ['git', 'npm'], + { ...context, symbols: { ...context.symbols, git_prefix: '/usr' }, allowWeakIdentityFallback: true }, +); +``` + +`getCatalogInfo()` and `listCatalogEntries()` inspect the bundled catalog without resolving a policy. Failures throw `MxcError` with the stable store reason in `details.reason` (for example `invalid_context`). See [`docs/policy-store/`](../../docs/policy-store/README.md). + +--- + ## Common Pitfalls ### UI is blocked by default on 0.5.0+ — some shells need it @@ -598,6 +626,12 @@ getAvailableToolsPolicy(env?, options?) → FilesystemPolicyResult getUserProfilePolicy() → FilesystemPolicyResult getTemporaryFilesPolicy(env?) → FilesystemPolicyResult +// Policy store (PROTOTYPE, pending API review) +resolveSandboxPolicy(tools, context?) → SandboxPolicy | undefined +resolveSandboxPolicyWithDiagnostics(tools, context?) → SandboxConfigResolution +getCatalogInfo() → CatalogInfo +listCatalogEntries() → CatalogEntryMetadata[] + // Telemetry consent (Windows-only; see Telemetry Consent section below) queryTelemetryConsentAsync() → Promise<{ state, storedState, effectiveState, needsPrompt, policy, error? }> requestTelemetryConsent(presenter, locale?) → Promise diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 7c29bb11d..8d27d898d 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -708,6 +708,51 @@ Windows Sandbox and WSLc relay attached output without interactive stdin. stdout and stdin are both terminals; use `sandbox::exec` for a typed workload with no terminal. +## Policy store (prototype) + +> **PROTOTYPE, pending API review.** These APIs are proposed and may change +> before sign-off. They are not part of MXC 1.0. + +`mxc_sdk::policy_store` resolves known tools (for example `git`, `node`, +`npm`) to a candidate floor `SandboxPolicy` from a reviewed catalog bundled in +the crate (via `mxc_policy_store`). Nothing is downloaded. A floor is a +best-effort starting point, not a guarantee, and it never grants access by +itself: review it and compose it with your own policy before building a +request. It complements Learning Mode rather than replacing it. + +```rust,no_run +use mxc_sdk::policy_store::{ + resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, ResolveContext, + ToolCandidate, ToolInput, +}; + +let ctx = ResolveContext::new() + .project_root("/work/repo") + .symbol("node_prefix", "/opt/node") + .symbol("npm_prefix", "/opt/npm") + .symbol("npm_cache", "/home/me/.npm"); + +// `Some(SandboxPolicy)`, or `None` when nothing resolves. +let floor = resolve_sandbox_policy( + ToolCandidate::new("npm").with_package_url("pkg:npm/npm"), + &ctx, +)?; + +// Name-only matches need an explicit opt-in; diagnostics report what matched. +let resolution = resolve_sandbox_policy_with_diagnostics( + vec![ToolInput::from("git"), ToolInput::from("npm")], + &ctx.clone() + .symbol("git_prefix", "/usr") + .allow_weak(true), +)?; +# let _ = (floor, resolution); +# Ok::<(), mxc_sdk::policy_store::PolicyCatalogError>(()) +``` + +`get_catalog_info` and `list_catalog_entries` inspect the bundled catalog. +Errors carry an MXC `ErrorCode` and a stable `reason()`. See +[`docs/policy-store/`](../../../docs/policy-store/README.md). + ## Relationship to `mxc_engine` and the executor binaries Backend dispatch, host probing, and config building live in the internal diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 3cb69a953..450b4de24 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -148,6 +148,12 @@ //! These include security warnings, network rules that cannot carry traffic, //! and operational warnings such as unavailable telemetry routing. //! +//! ## Policy store (prototype) +//! +//! **PROTOTYPE, pending API review.** [`policy_store`] resolves known tools to +//! a candidate floor [`SandboxPolicy`] from a catalog bundled in this crate. +//! The floor is best-effort, not a guarantee, and is not part of MXC 1.0. +//! //! ## Relationship to `mxc_engine` //! //! This crate is a thin, streaming-focused public facade. Backend dispatch, diff --git a/src/core/mxc_policy_store/README.md b/src/core/mxc_policy_store/README.md index 8ae620852..8f4d6bb66 100644 --- a/src/core/mxc_policy_store/README.md +++ b/src/core/mxc_policy_store/README.md @@ -51,7 +51,7 @@ check that already-published revisions are unchanged. ## Contributing a revision -Add a new file under `catalog/revisions/`, list it with its canonical SHA-256 -in `catalog/manifest.json`, add a bundled-catalog conformance case under -`conformance/fixtures/`, and run the tests above. Published revisions are -immutable. +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 design. From 28e7ecb210536d687558f40b46cdb36cdcc405b7 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Fri, 2 Oct 2026 10:17:48 -0700 Subject: [PATCH 26/50] refactor(dotnet-sdk): throw MxcException with a Reason from MxcPolicyStore Drop the prototype PolicyStoreException. MxcPolicyStore now throws MxcException directly, and MxcException gains a nullable Reason property carrying the policy store's stable failure reason (for example invalid_context). Reason is null for every other MXC failure, so existing catch (MxcException) callers also catch policy-store failures. MxcException stays sealed; Reason has an internal setter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 2 +- .../MxcPolicyStoreTests.cs | 13 ++-- sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs | 9 +++ .../Microsoft.Mxc.Sdk/MxcPolicyStore.cs | 60 ++++++------------- sdk/dotnet/README.md | 6 +- 5 files changed, 39 insertions(+), 51 deletions(-) diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index 376e3ce68..221b613e3 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -65,7 +65,7 @@ Verifying that the executable really carries the strong identity passed in ## Failures Failures reuse MXC's error codes, with a stable sub-reason: `details.reason` -on the Node `MxcError`, `PolicyStoreException.Reason` in C#, and +on the Node `MxcError`, `MxcException.Reason` in C#, and `PolicyCatalogError::reason()` in Rust. | Code | Reason | Meaning | diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs index 10e88621f..e1c8ee0d7 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs @@ -105,12 +105,17 @@ public void ResolveSandboxPolicyWithDiagnostics_ReportsDependencies() [Fact] public void ResolveSandboxPolicy_InvalidContextThrowsWithStableReason() { - var error = Assert.Throws(() => + var error = Assert.Throws(() => MxcPolicyStore.ResolveSandboxPolicy("npm", LinuxContext with { Platform = "plan9" })); Assert.Equal(ErrorCode.MalformedRequest, error.Code); Assert.Equal("invalid_context", error.Reason); - Assert.IsType(error.InnerException); + } + + [Fact] + public void MxcException_ReasonIsNullOutsideThePolicyStore() + { + Assert.Null(new MxcException(ErrorCode.BackendError, "failed").Reason); } [Fact] @@ -138,12 +143,12 @@ public void ParseResolution_MapsNetworkUiAndTimeout() [Fact] public void ParseResolution_RejectsFieldsTheSdkModelCannotHold() { - var error = Assert.Throws(() => + var error = Assert.Throws(() => MxcPolicyStore.ParseResolution("""{"policy":{"version":"0.9.0-alpha","timeoutMs":4294967296}}""")); Assert.Equal(ErrorCode.PolicyValidation, error.Code); Assert.Equal("invalid_catalog", error.Reason); - Assert.Throws(() => + Assert.Throws(() => MxcPolicyStore.ParseResolution("""{"policy":{"version":"0.9.0-alpha","unknownField":true}}""")); } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs index 215634eab..c361b6556 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs @@ -38,6 +38,15 @@ public sealed class MxcException : Exception /// public string? Remediation { get; } + /// + /// PROTOTYPE, pending API review. The policy store's stable failure + /// reason: invalid_catalog, composition_conflict, + /// invalid_context, unsupported_host, integrity, or + /// revision_unavailable. for every failure + /// that did not come from . + /// + 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/MxcPolicyStore.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs index c5c04d747..30f84986a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs @@ -27,9 +27,10 @@ namespace Microsoft.Mxc.Sdk; /// git pull versus git push). This API is not part of MXC 1.0. /// /// -/// Failures throw , which carries the typed -/// , the store's stable failure reason (for example -/// invalid_context), and the underlying . +/// Failures throw : +/// carries the typed and +/// carries the store's stable failure reason +/// (for example invalid_context). /// /// public static class MxcPolicyStore @@ -58,7 +59,7 @@ static MxcPolicyStore() /// PROTOTYPE, pending API review. Resolve one tool to a floor policy. /// /// The policy, or when none can be resolved. - /// The input, context, or catalog is invalid. + /// The input, context, or catalog is invalid. public static SandboxPolicy? ResolveSandboxPolicy(ToolInput tool, ResolveContext? context = null) { ArgumentNullException.ThrowIfNull(tool); @@ -70,7 +71,7 @@ static MxcPolicyStore() /// composed floor policy. /// /// The policy, or when none can be resolved. - /// The input, context, or catalog is invalid. + /// The input, context, or catalog is invalid. public static SandboxPolicy? ResolveSandboxPolicy( IReadOnlyList tools, ResolveContext? context = null) @@ -85,7 +86,7 @@ static MxcPolicyStore() /// reports which catalog entries matched, the dependencies they pulled in, /// and warnings. /// - /// The input, context, or catalog is invalid. + /// The input, context, or catalog is invalid. public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics( ToolInput tool, ResolveContext? context = null) @@ -100,7 +101,7 @@ public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics( /// and also reports which catalog entries matched each input, the /// dependencies they pulled in, and warnings. /// - /// The input, context, or catalog is invalid. + /// The input, context, or catalog is invalid. public static SandboxConfigResolution ResolveSandboxPolicyWithDiagnostics( IReadOnlyList tools, ResolveContext? context = null) @@ -178,9 +179,8 @@ private static unsafe string TakeResult(int status, MxcPolicyStoreResult* result if (status != (int)ErrorCode.Success) { var error = NativeError.ToException(status, result->error, "unknown error"); - throw new PolicyStoreException( - error, - NativeError.ToStringOrNull(result->reason_utf8)); + error.Reason = NativeError.ToStringOrNull(result->reason_utf8); + throw error; } return NativeError.ToStringOrNull(result->json_utf8) @@ -214,12 +214,13 @@ internal static SandboxConfigResolution ParseResolution(string json) } catch (JsonException error) { - throw new PolicyStoreException( - new MxcException( - ErrorCode.PolicyValidation, - $"The resolved policy is not a valid SDK SandboxPolicy: {error.Message}", - error), - "invalid_catalog"); + throw new MxcException( + ErrorCode.PolicyValidation, + $"The resolved policy is not a valid SDK SandboxPolicy: {error.Message}", + error) + { + Reason = "invalid_catalog", + }; } } @@ -235,33 +236,6 @@ private sealed record NativeResolution( [property: JsonPropertyName("diagnostics")] ResolutionDiagnostics? Diagnostics); } -/// -/// PROTOTYPE, pending API review. A policy store failure. It is an -/// whose carries the store's -/// stable failure reason. -/// -public sealed class PolicyStoreException : Exception -{ - internal PolicyStoreException(MxcException inner, string? reason) - : base(inner.Message, inner) - { - Code = inner.Code; - Reason = reason; - } - - /// The typed error code. - public ErrorCode Code { get; } - - /// - /// The stable failure reason: invalid_catalog, - /// composition_conflict, invalid_context, - /// unsupported_host, integrity, or - /// revision_unavailable. when the failure did - /// not come from the store itself. - /// - public string? Reason { get; } -} - /// Catalog platform selector values. public static class CatalogPlatforms { diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 47f9531d6..a3de0d5a8 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -584,9 +584,9 @@ var resolution = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics( ``` `GetCatalogInfo()` and `ListCatalogEntries()` inspect the bundled catalog. -Failures throw `PolicyStoreException`, which carries the `ErrorCode`, the -stable store `Reason` (for example `invalid_context`), and the underlying -`MxcException`. See [`docs/policy-store/`](../../docs/policy-store/README.md). +Failures throw `MxcException`: `Code` carries the `ErrorCode` and `Reason` +carries the stable store reason (for example `invalid_context`). `Reason` is +`null` for failures outside the policy store. See [`docs/policy-store/`](../../docs/policy-store/README.md). ### Denial capture (Windows) From 75e2621a58cd4edbc6fcc2ea667722f039bf84f7 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:15:59 -0700 Subject: [PATCH 27/50] Policy store: drop the catalog digest and runtime checksum The latest design (section 10) removes the separate catalog digest: V1 data is compiled into the native library and inherits MXC package signing. Remove the manifest sha256 field, its schema property, the runtime digest check, the canonical_sha256 helper, and the sha2 dependency. Unreadable or mislabelled bundled data is still an integrity error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 12 ++-- src/Cargo.lock | 1 - src/core/mxc_policy_store/Cargo.toml | 5 +- .../mxc_policy_store/catalog/manifest.json | 3 +- .../conformance/vectors/canonical-json.json | 29 +++----- .../schema/manifest.v1.schema.json | 7 +- src/core/mxc_policy_store/src/history.rs | 9 +-- src/core/mxc_policy_store/src/json.rs | 20 +----- src/core/mxc_policy_store/src/lib.rs | 4 +- src/core/mxc_policy_store/src/store.rs | 60 ++++------------ src/core/mxc_policy_store/src/validate.rs | 2 +- src/core/mxc_policy_store/tests/common/mod.rs | 5 +- src/core/mxc_policy_store/tests/library.rs | 68 +++++++------------ src/core/mxc_policy_store/tests/vectors.rs | 9 +-- 14 files changed, 66 insertions(+), 168 deletions(-) diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index 221b613e3..ceaaceca5 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -39,8 +39,8 @@ One call resolves one input or an array of inputs in a single pass: 1. **Validate inputs and context.** Invalid input fails as `malformed_request` (`invalid_context`), never as absence. 2. **Load the revision** — the requested `catalogRevision`, or the bundled - default. A missing revision is `backend_error` (`revision_unavailable`); a - digest mismatch is `backend_error` (`integrity`). + default. A missing revision is `backend_error` (`revision_unavailable`); + unreadable bundled data is `backend_error` (`integrity`). 3. **Match each input additively** in `entryId` order. A `purl` match is strong; an `invocation-name` match is weak and needs `allowWeakIdentityFallback`. Names and paths fold case on Windows and macOS @@ -74,7 +74,7 @@ on the Node `MxcError`, `MxcException.Reason` in C#, and | `policy_validation` | `composition_conflict` | The selected entries cannot be composed under the V1 rules. | | `malformed_request` | `invalid_context` | The caller's inputs or context are invalid. | | `unsupported_containment` | `unsupported_host` | The host platform or native architecture cannot be determined. | -| `backend_error` | `integrity` | Catalog data does not match its published digest. | +| `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 returns no policy. @@ -88,9 +88,9 @@ Published revisions are immutable. Never edit a file under 1. Copy the latest revision to `catalog/revisions/.json` and set its `catalogRevision`. 2. Make the change. Bump `entryRevision` for every entry that changes. -3. Append `{ catalogRevision, file, sha256 }` to the manifest and point - `defaultRevision` at it. To get the digest, enter 64 zeros, run the tests - below, and copy the actual digest from the integrity failure. +3. Append `{ catalogRevision, file }` to the manifest and point + `defaultRevision` at it. There is no separate catalog digest: the data is + compiled into the native library and inherits MXC package signing. 4. Add or update a case in `conformance/fixtures/bundled-catalog.json`. Every entry in the default revision needs one. 5. Run the checks (from `src/`): diff --git a/src/Cargo.lock b/src/Cargo.lock index d5c502f92..bd9551736 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1719,7 +1719,6 @@ name = "mxc_policy_store" version = "0.9.0" dependencies = [ "libc", - "sha2 0.10.9", ] [[package]] diff --git a/src/core/mxc_policy_store/Cargo.toml b/src/core/mxc_policy_store/Cargo.toml index dfaeb832c..f8a3c5966 100644 --- a/src/core/mxc_policy_store/Cargo.toml +++ b/src/core/mxc_policy_store/Cargo.toml @@ -4,16 +4,13 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false -description = "PROTOTYPE, pending API review: the MXC policy store. Resolves known tools to a best-effort candidate floor SandboxPolicy from a statically bundled, integrity-checked policy catalog." +description = "PROTOTYPE, pending API review: the MXC policy store. Resolves known tools to a best-effort candidate floor SandboxPolicy from a statically bundled policy catalog." build = "build.rs" [lib] name = "mxc_policy_store" path = "src/lib.rs" -[dependencies] -sha2.workspace = true - [target.'cfg(unix)'.dependencies] # uname(2) for the kernel machine type, Linux/macOS only. libc.workspace = true diff --git a/src/core/mxc_policy_store/catalog/manifest.json b/src/core/mxc_policy_store/catalog/manifest.json index 46950ba88..9e19a7c1f 100644 --- a/src/core/mxc_policy_store/catalog/manifest.json +++ b/src/core/mxc_policy_store/catalog/manifest.json @@ -4,8 +4,7 @@ "revisions": [ { "catalogRevision": "2026-09-29.1", - "file": "revisions/2026-09-29.1.json", - "sha256": "00f0c644d5fc67783520a12b0d4eca565457d99a713efb61d97daa558c1a7a10" + "file": "revisions/2026-09-29.1.json" } ] } diff --git a/src/core/mxc_policy_store/conformance/vectors/canonical-json.json b/src/core/mxc_policy_store/conformance/vectors/canonical-json.json index 070c20519..0de6b68cf 100644 --- a/src/core/mxc_policy_store/conformance/vectors/canonical-json.json +++ b/src/core/mxc_policy_store/conformance/vectors/canonical-json.json @@ -1,50 +1,41 @@ { - "$comment": "Generated by scripts/generate-vectors.mjs. Canonical JSON: object keys sorted by UTF-16 code units, arrays in order, no whitespace, JSON.stringify string escaping, and ECMAScript Number::toString for every number. sha256 is lower-case hex over the UTF-8 canonical text. Parse `json`, canonicalize, and compare both fields.", + "$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}", - "sha256": "e507298c9bbce4feaa4f44b2c7294c0b9e11f4941cab71ba4ee91b42c26639d4" + "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}", - "sha256": "ce29d5e391d3137cee4bdd579370a200608300049e8faa13f74af2fa9b697ff3" + "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 é 😀\",\"/\",\"
\"]", - "sha256": "814d3e90acdbabd93908ab69f5537ec84652f7f72ae47d3d0b1d09ae22699d23" + "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]", - "sha256": "f7dba9ebc15ae29db49f82357f7defe2c6e0e170c4776295d5eece73ec43f5ae" + "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\":{}}}", - "sha256": "c94ffb132d7a743e86a5152622b085cab59d4133fd98fa9fa429f3607eb7772b" + "canonical": "{\"nested\":{\"k\":[],\"o\":{}}}" }, { "json": "\"plain\"", - "canonical": "\"plain\"", - "sha256": "945603a8f587786b463c3f94fce115c0fae88fac2728cc96ddf5981cf7f61741" + "canonical": "\"plain\"" }, { "json": "42", - "canonical": "42", - "sha256": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049" + "canonical": "42" }, { "json": "false", - "canonical": "false", - "sha256": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa" + "canonical": "false" }, { "json": "null", - "canonical": "null", - "sha256": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + "canonical": "null" } ] } diff --git a/src/core/mxc_policy_store/schema/manifest.v1.schema.json b/src/core/mxc_policy_store/schema/manifest.v1.schema.json index 0d7ad485f..8ccd228c5 100644 --- a/src/core/mxc_policy_store/schema/manifest.v1.schema.json +++ b/src/core/mxc_policy_store/schema/manifest.v1.schema.json @@ -2,7 +2,7 @@ "$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 and their integrity digests. sha256 is computed over the canonical JSON form of the revision file (recursively sorted keys, no insignificant whitespace, UTF-8).", + "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"], @@ -15,11 +15,10 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["catalogRevision", "file", "sha256"], + "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$" }, - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + "file": { "type": "string", "pattern": "^revisions/[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*\\.json$" } } } } diff --git a/src/core/mxc_policy_store/src/history.rs b/src/core/mxc_policy_store/src/history.rs index 855e4f65f..a3bc98613 100644 --- a/src/core/mxc_policy_store/src/history.rs +++ b/src/core/mxc_policy_store/src/history.rs @@ -82,15 +82,13 @@ pub fn check_store_history(store: &CatalogStore) -> Vec { pub struct PublishedRevision { pub catalog_revision: Option, pub file: Option, - pub sha256: Option, } impl PublishedRevision { - pub fn new(catalog_revision: &str, file: &str, sha256: &str) -> Self { + pub fn new(catalog_revision: &str, file: &str) -> Self { Self { catalog_revision: Some(Json::from(catalog_revision)), file: Some(Json::from(file)), - sha256: Some(Json::from(sha256)), } } @@ -98,7 +96,6 @@ impl PublishedRevision { Self { catalog_revision: value.get("catalogRevision").cloned(), file: value.get("file").cloned(), - sha256: value.get("sha256").cloned(), } } @@ -153,7 +150,7 @@ fn canonical_text(text: Option<&String>) -> Option { }) } -/// Published revisions keep their manifest entry, digest, and content; new +/// Published revisions keep their manifest entry and content; new /// revisions are only appended (design §10). pub fn check_published_immutability( base: &PublishedState, @@ -169,7 +166,7 @@ pub fn check_published_immutability( )); continue; }; - if now.file != published.file || now.sha256 != published.sha256 { + if now.file != published.file { errors.push(format!( "published revision '{name}' manifest entry was modified" )); diff --git a/src/core/mxc_policy_store/src/json.rs b/src/core/mxc_policy_store/src/json.rs index 438b603c2..437c6e719 100644 --- a/src/core/mxc_policy_store/src/json.rs +++ b/src/core/mxc_policy_store/src/json.rs @@ -7,13 +7,12 @@ //! 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 digests, -//! validation messages, and output match byte for byte. +//! 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 sha2::{Digest, Sha256}; use std::cmp::Ordering; use std::fmt; @@ -686,21 +685,6 @@ fn write_canonical(value: &Json, out: &mut String) { } } -/// Lower-case hex SHA-256 of `text` (UTF-8). -pub fn sha256_hex(text: &str) -> String { - let digest = Sha256::digest(text.as_bytes()); - let mut out = String::with_capacity(64); - for byte in digest.iter() { - out.push_str(&format!("{byte:02x}")); - } - out -} - -/// Lower-case hex SHA-256 of the canonical JSON form. -pub fn canonical_sha256(value: &Json) -> String { - sha256_hex(&canonical_json(value)) -} - /// ECMAScript `String(value)` for a JSON value (used where TypeScript /// interpolates unvalidated data into a message). pub fn js_to_string(value: &Json) -> String { diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/core/mxc_policy_store/src/lib.rs index b18c4e15a..cc98318dc 100644 --- a/src/core/mxc_policy_store/src/lib.rs +++ b/src/core/mxc_policy_store/src/lib.rs @@ -68,9 +68,7 @@ pub mod tooling { PublishedRevision, PublishedState, }; pub use crate::host::architecture_from_machine; - pub use crate::json::{ - canonical_json, canonical_sha256, js_number_to_string, Json, JsonObject, - }; + pub use crate::json::{canonical_json, js_number_to_string, Json, JsonObject}; pub use crate::paths::{ case_key, folds_case, is_absolute_path, normalize_path, path_key_segments, }; diff --git a/src/core/mxc_policy_store/src/store.rs b/src/core/mxc_policy_store/src/store.rs index 9d6c6af6c..a8fc4d48e 100644 --- a/src/core/mxc_policy_store/src/store.rs +++ b/src/core/mxc_policy_store/src/store.rs @@ -1,15 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Manifest validation and lazy, digest-checked, validated revision loading -//! plus the catalog bundled into this crate. +//! 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::catalog::{ compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, validate_contract, CatalogContract, CatalogRevision, CATALOG_SCHEMA_VERSION, }; use crate::errors::{invalid_catalog, ErrorReason, PolicyCatalogError, Result}; -use crate::json::{canonical_sha256, Json, JsonObject}; +use crate::json::{Json, JsonObject}; use std::cmp::Ordering; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -20,7 +22,6 @@ use std::sync::{Arc, Mutex, OnceLock}; pub struct ManifestRevision { pub catalog_revision: String, pub file: String, - pub sha256: String, } /// A validated manifest. @@ -50,15 +51,9 @@ pub struct MemorySource { } impl MemorySource { - /// A source whose manifest publishes `revisions` with correct canonical - /// digests; the default is the last revision unless `default_revision` - /// is given. `digest_overrides` replaces digests by revision id. - pub fn publishing( - contract: Json, - revisions: &[Json], - default_revision: Option<&str>, - digest_overrides: &HashMap, - ) -> Self { + /// 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 { @@ -72,11 +67,6 @@ impl MemorySource { let mut entry = JsonObject::new(); entry.insert("catalogRevision", Json::String(id.clone())); entry.insert("file", Json::String(file)); - let digest = digest_overrides - .get(&id) - .cloned() - .unwrap_or_else(|| canonical_sha256(revision)); - entry.insert("sha256", Json::String(digest)); listed.push(Json::Object(entry)); } let default = default_revision.map(str::to_string).unwrap_or_else(|| { @@ -205,7 +195,7 @@ pub fn validate_manifest(raw: &Json) -> Result { return invalid(format!("'{at}' must be an object")); }; for key in item.keys() { - if !["catalogRevision", "file", "sha256"].contains(&key) { + if !["catalogRevision", "file"].contains(&key) { return invalid(format!("unsupported field '{at}.{key}'")); } } @@ -217,24 +207,9 @@ pub fn validate_manifest(raw: &Json) -> Result { if item.get("file").and_then(Json::as_str) != Some(expected_file.as_str()) { return invalid(format!("'{at}.file' must be '{expected_file}'")); } - let sha256 = match item.get("sha256").and_then(Json::as_str) { - Some(d) - if d.len() == 64 - && d.bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => - { - d.to_string() - } - _ => { - return invalid(format!( - "'{at}.sha256' must be a lower-case hex SHA-256 digest" - )) - } - }; revisions.push(ManifestRevision { catalog_revision, file: expected_file, - sha256, }); } for index in 1..revisions.len() { @@ -260,9 +235,9 @@ pub fn validate_manifest(raw: &Json) -> Result { }) } -/// Read-only access to installed, integrity-validated catalog revisions. -/// Revisions load lazily on first use, are verified against the manifest -/// digest and the contract, and are cached (successes only). +/// 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, @@ -335,17 +310,6 @@ impl CatalogStore { format!("catalog revision '{id}' could not be read: {message}"), ) })?; - let digest = canonical_sha256(&raw); - // BREAK-VERIFY(tamper): the published-digest comparison. - if digest != listed.sha256 { - return Err(PolicyCatalogError::new( - ErrorReason::Integrity, - format!( - "catalog revision '{id}' digest {digest} does not match the published digest {}", - listed.sha256 - ), - )); - } let revision = validate_catalog_revision(&raw, &self.contract)?; if revision.catalog_revision != id { return Err(PolicyCatalogError::new( diff --git a/src/core/mxc_policy_store/src/validate.rs b/src/core/mxc_policy_store/src/validate.rs index 97cb20df8..865188ebe 100644 --- a/src/core/mxc_policy_store/src/validate.rs +++ b/src/core/mxc_policy_store/src/validate.rs @@ -249,7 +249,7 @@ pub fn check_against_base_ref( .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, &r.sha256) + PublishedRevision::new(&r.catalog_revision, &r.file) }) .collect(); let proposed = PublishedState { revisions, files }; diff --git a/src/core/mxc_policy_store/tests/common/mod.rs b/src/core/mxc_policy_store/tests/common/mod.rs index e3cd84552..52c8e6f9b 100644 --- a/src/core/mxc_policy_store/tests/common/mod.rs +++ b/src/core/mxc_policy_store/tests/common/mod.rs @@ -11,7 +11,6 @@ use mxc_policy_store::{ Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, ToolInputs, }; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -45,19 +44,17 @@ pub fn linux_x64() -> Arc { } pub fn store_for(revisions: &[Json]) -> Result { - store_with(revisions, None, &HashMap::new()) + store_with(revisions, None) } pub fn store_with( revisions: &[Json], default_revision: Option<&str>, - digests: &HashMap, ) -> Result { CatalogStore::new(MemorySource::publishing( contract(), revisions, default_revision, - digests, )) } diff --git a/src/core/mxc_policy_store/tests/library.rs b/src/core/mxc_policy_store/tests/library.rs index e57dcc71b..cc9c1b639 100644 --- a/src/core/mxc_policy_store/tests/library.rs +++ b/src/core/mxc_policy_store/tests/library.rs @@ -8,9 +8,8 @@ mod common; use common::*; use mxc_policy_store::tooling::{ - canonical_json, canonical_sha256, check_entry_revisions, check_published_immutability, - check_store_history, validate_catalog_revision, validate_contract, Json, PublishedRevision, - PublishedState, + canonical_json, check_entry_revisions, check_published_immutability, check_store_history, + validate_catalog_revision, validate_contract, Json, PublishedRevision, PublishedState, }; use mxc_policy_store::{ get_catalog_info, list_catalog_entries, resolve_sandbox_policy, @@ -988,7 +987,7 @@ fn validation_composition_vocabulary() { // --------------------------------------------------------------------------- #[test] -fn bundled_store_verifies_and_canonical_digest_ignores_formatting() { +fn bundled_store_verifies_and_canonical_json_ignores_formatting() { let store = mxc_policy_store::bundled_catalog_store().unwrap(); assert!(check_store_history(&store).is_empty()); assert_eq!( @@ -1000,20 +999,22 @@ fn bundled_store_verifies_and_canonical_digest_ignores_formatting() { r#"{"a":[2,{"c":4,"d":3}],"b":1}"# ); assert_eq!( - canonical_sha256(&j(r#"{"a":1,"b":2}"#)), - canonical_sha256(&j(r#"{"b":2,"a":1}"#)) - ); - assert_ne!( - canonical_sha256(&j(r#"{"a":1}"#)), - canonical_sha256(&j(r#"{"a":2}"#)) + canonical_json(&j(r#"{"a":1,"b":2}"#)), + canonical_json(&j(r#"{"b":2,"a":1}"#)) ); } #[test] -fn tampered_revision_is_an_integrity_error_everywhere() { - let digests = HashMap::from([("2000-01-01.1".to_string(), "0".repeat(64))]); - let store = - Arc::new(store_with(&[revision(vec![entry("tool:a", "")])], None, &digests).unwrap()); +fn unreadable_revision_is_an_integrity_error_everywhere() { + let source = mxc_policy_store::MemorySource { + files: HashMap::new(), + ..mxc_policy_store::MemorySource::publishing( + contract(), + &[revision(vec![entry("tool:a", "")])], + None, + ) + }; + let store = Arc::new(mxc_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!( @@ -1041,20 +1042,10 @@ fn revision_id_mismatch_and_invalid_manifest() { if let Json::Object(o) = &mut relabeled { o.insert("catalogRevision", "2000-01-01.1".into()); } - let digests = HashMap::from([("2000-01-01.1".to_string(), canonical_sha256(&other))]); - assert_eq!( - reason_of( - store_with(&[relabeled.clone()], None, &digests) - .unwrap() - .revision(None) - ), - ErrorReason::Integrity - ); - // Correct digest but wrong declared id → integrity (revision-id check). - let digests = HashMap::from([("2000-01-01.1".to_string(), canonical_sha256(&other))]); + // A file that declares another revision id → integrity. let source = mxc_policy_store::MemorySource { files: HashMap::from([("revisions/2000-01-01.1.json".to_string(), other.clone())]), - ..mxc_policy_store::MemorySource::publishing(contract(), &[relabeled], None, &digests) + ..mxc_policy_store::MemorySource::publishing(contract(), &[relabeled], None) }; let error = mxc_policy_store::CatalogStore::new(source) .unwrap() @@ -1066,11 +1057,7 @@ fn revision_id_mismatch_and_invalid_manifest() { ); let r = revision(vec![entry("tool:a", "")]); assert_eq!( - reason_of(store_with( - std::slice::from_ref(&r), - Some("2001-01-01.1"), - &HashMap::new() - )), + reason_of(store_with(std::slice::from_ref(&r), Some("2001-01-01.1"))), ErrorReason::InvalidCatalog ); assert_eq!( @@ -1163,11 +1150,7 @@ fn entry_revision_history() { #[test] fn published_immutability() { - let published = PublishedRevision::new( - "2000-01-01.1", - "revisions/2000-01-01.1.json", - &"a".repeat(64), - ); + 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(), @@ -1179,11 +1162,7 @@ fn published_immutability() { let appended = PublishedState { revisions: vec![ published.clone(), - PublishedRevision::new( - "2000-01-02.1", - "revisions/2000-01-02.1.json", - &"b".repeat(64), - ), + PublishedRevision::new("2000-01-02.1", "revisions/2000-01-02.1.json"), ], files: HashMap::from([( "revisions/2000-01-01.1.json".to_string(), @@ -1202,16 +1181,15 @@ fn published_immutability() { check_published_immutability(&base, &edited), ["published revision file 'revisions/2000-01-01.1.json' was modified; publish a new revision instead"] ); - let redigested = PublishedState { + let moved = PublishedState { revisions: vec![PublishedRevision::new( "2000-01-01.1", - "revisions/2000-01-01.1.json", - &"c".repeat(64), + "revisions/moved.json", )], files, }; assert_eq!( - check_published_immutability(&base, &redigested), + check_published_immutability(&base, &moved), ["published revision '2000-01-01.1' manifest entry was modified"] ); let removed = PublishedState::default(); diff --git a/src/core/mxc_policy_store/tests/vectors.rs b/src/core/mxc_policy_store/tests/vectors.rs index 1fae4bf2a..3dabacddd 100644 --- a/src/core/mxc_policy_store/tests/vectors.rs +++ b/src/core/mxc_policy_store/tests/vectors.rs @@ -8,8 +8,8 @@ mod common; use common::*; use mxc_policy_store::tooling::{ - canonical_json, canonical_sha256, is_absolute_path, normalize_path, parse_purl, - path_key_segments, Json, ParsedPurl, + canonical_json, is_absolute_path, normalize_path, parse_purl, path_key_segments, Json, + ParsedPurl, }; use mxc_policy_store::Platform; @@ -31,11 +31,6 @@ fn canonical_json_vectors() { case.get("canonical").and_then(Json::as_str).unwrap(), "{text}" ); - assert_eq!( - canonical_sha256(&value), - case.get("sha256").and_then(Json::as_str).unwrap(), - "{text}" - ); } } From 8b0eb3d7a1ed5459c4b0c1bf88d4eac4e8c77afe Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:20:36 -0700 Subject: [PATCH 28/50] Policy store: add version schemes and purl vers ranges Add the vers module: parsing and ordering for the five entry version schemes the design allows (npm, semver, pypi, nuget, intdot), and purl vers range parsing, validation, containment, and overlap detection per the VERS specification. The resolver adopts it in the next commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/mxc_policy_store/src/lib.rs | 3 + src/core/mxc_policy_store/src/vers.rs | 1270 +++++++++++++++++++++++++ 2 files changed, 1273 insertions(+) create mode 100644 src/core/mxc_policy_store/src/vers.rs diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/core/mxc_policy_store/src/lib.rs index cc98318dc..9cf217938 100644 --- a/src/core/mxc_policy_store/src/lib.rs +++ b/src/core/mxc_policy_store/src/lib.rs @@ -37,6 +37,7 @@ pub mod resolver; pub mod store; mod text; pub mod validate; +pub mod vers; pub mod version_range; pub use errors::{ErrorCode, ErrorReason, PolicyCatalogError}; @@ -80,3 +81,5 @@ pub mod tooling { }; pub use crate::version_range::{is_valid_version_range, satisfies_version_range}; } + +pub use vers::{VersRange, Version, VersionScheme}; diff --git a/src/core/mxc_policy_store/src/vers.rs b/src/core/mxc_policy_store/src/vers.rs new file mode 100644 index 000000000..98929bc1a --- /dev/null +++ b/src/core/mxc_policy_store/src/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")); + } +} From 0a0b611e27b56a849d1e1a49681d454a6afb4c93 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:44:24 -0700 Subject: [PATCH 29/50] Policy store: default + additive overlays, intents, and per-pair resolution Implement the entry model from the latest Policy Store design in mxc_policy_store: - Each entry has one unversioned default, additive platform/architecture overlays, at most one non-overlapping purl vers version variant, and named intents. A required root versionScheme (npm, semver, pypi, nuget, intdot) governs every range. Overlays may add access, dependencies, and intents; they can never remove or narrow anything. - ToolCandidate gains an optional intent. Resolution reports per-pair version status (matched_default, matched_version, version_out_of_range, version_unparseable), intent selection, and tool_unmatched / intent_unsupported, with structured warnings. There is no wildcard fallback; an undefined intent contributes nothing. - Composition across pairs: filesystem and positive outbound rules union to satisfy every requested pair; read-write supersedes read-only; catalog denies that conflict with a required grant are removed with a diagnostic; combinations the model cannot express fail. - Build-time validation materializes every platform x architecture x version x intent effective policy, checks the default is a subset of each, and renders a reviewer view (catalog/views/). - Catalog revision 2026-10-02.1 replaces the unreleased 2026-09-29.1 and adds a git entry (fetch/push/local, Windows programData/Git, an SSH dependency on push for 2.40-2.49, and a bundle-fetch intent from 2.50) plus an ssh entry. Conformance fixtures are regenerated with a new MXC_POLICY_STORE_UPDATE_FIXTURES mode and reviewed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mxc_policy_store/catalog/contract.v1.json | 8 + .../mxc_policy_store/catalog/manifest.json | 6 +- .../catalog/revisions/2026-09-29.1.json | 133 -- .../catalog/revisions/2026-10-02.1.json | 271 +++ .../catalog/views/2026-10-02.1.md | 91 + .../conformance/fixtures/bundled-catalog.json | 1608 ++++++++++++++++- .../fixtures/synthetic-catalog.json | 1443 +++++++++++++-- .../schema/catalog.v1.schema.json | 420 ++++- src/core/mxc_policy_store/src/catalog.rs | 841 +++++---- src/core/mxc_policy_store/src/compose.rs | 384 ++++ src/core/mxc_policy_store/src/effective.rs | 562 ++++++ src/core/mxc_policy_store/src/errors.rs | 19 +- src/core/mxc_policy_store/src/host.rs | 3 + src/core/mxc_policy_store/src/lib.rs | 35 +- src/core/mxc_policy_store/src/model.rs | 339 +++- src/core/mxc_policy_store/src/paths.rs | 18 +- src/core/mxc_policy_store/src/request.rs | 14 +- src/core/mxc_policy_store/src/resolver.rs | 719 +++++--- src/core/mxc_policy_store/src/text.rs | 20 +- .../mxc_policy_store/src/version_range.rs | 204 --- src/core/mxc_policy_store/src/view.rs | 193 ++ .../tests/catalog_validation.rs | 58 +- src/core/mxc_policy_store/tests/common/mod.rs | 7 +- .../mxc_policy_store/tests/conformance.rs | 35 + src/core/mxc_policy_store/tests/library.rs | 1154 ++++++++---- 25 files changed, 6919 insertions(+), 1666 deletions(-) delete mode 100644 src/core/mxc_policy_store/catalog/revisions/2026-09-29.1.json create mode 100644 src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json create mode 100644 src/core/mxc_policy_store/catalog/views/2026-10-02.1.md create mode 100644 src/core/mxc_policy_store/src/compose.rs create mode 100644 src/core/mxc_policy_store/src/effective.rs delete mode 100644 src/core/mxc_policy_store/src/version_range.rs create mode 100644 src/core/mxc_policy_store/src/view.rs diff --git a/src/core/mxc_policy_store/catalog/contract.v1.json b/src/core/mxc_policy_store/catalog/contract.v1.json index 5a4bac525..5c47cfa16 100644 --- a/src/core/mxc_policy_store/catalog/contract.v1.json +++ b/src/core/mxc_policy_store/catalog/contract.v1.json @@ -30,6 +30,14 @@ "temp_dir": { "source": "host", "description": "The current user's temporary directory." + }, + "ssh_prefix": { + "source": "caller", + "description": "Directory containing the ssh executable." + }, + "programData": { + "source": "host", + "description": "The Windows common application-data directory (%ProgramData%). Derived only on a Windows host." } } } diff --git a/src/core/mxc_policy_store/catalog/manifest.json b/src/core/mxc_policy_store/catalog/manifest.json index 9e19a7c1f..8700a63c4 100644 --- a/src/core/mxc_policy_store/catalog/manifest.json +++ b/src/core/mxc_policy_store/catalog/manifest.json @@ -1,10 +1,10 @@ { "catalogSchemaVersion": "1", - "defaultRevision": "2026-09-29.1", + "defaultRevision": "2026-10-02.1", "revisions": [ { - "catalogRevision": "2026-09-29.1", - "file": "revisions/2026-09-29.1.json" + "catalogRevision": "2026-10-02.1", + "file": "revisions/2026-10-02.1.json" } ] } diff --git a/src/core/mxc_policy_store/catalog/revisions/2026-09-29.1.json b/src/core/mxc_policy_store/catalog/revisions/2026-09-29.1.json deleted file mode 100644 index 823e5827c..000000000 --- a/src/core/mxc_policy_store/catalog/revisions/2026-09-29.1.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "catalogSchemaVersion": "1", - "catalogRevision": "2026-09-29.1", - "entries": [ - { - "entryId": "tool:git", - "entryRevision": 1, - "displayName": "Git", - "identity": [ - { "kind": "invocation-name", "names": ["git", "git.exe"] } - ], - "platformVariants": [ - { - "when": { "platform": "windows" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${git_prefix}"], - "readwritePaths": ["${project_root}"] - } - } - }, - { - "when": { "platform": "linux" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${git_prefix}"], - "readwritePaths": ["${project_root}"] - } - } - }, - { - "when": { "platform": "macos" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${git_prefix}"], - "readwritePaths": ["${project_root}"] - } - } - } - ], - "provenance": { - "method": "prototype-migration", - "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" - } - }, - { - "entryId": "tool:node", - "entryRevision": 1, - "displayName": "Node.js", - "identity": [ - { "kind": "invocation-name", "names": ["node", "node.exe"] } - ], - "platformVariants": [ - { - "when": { "platform": "windows" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["${node_prefix}"] } - } - }, - { - "when": { "platform": "linux" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["${node_prefix}"] } - } - }, - { - "when": { "platform": "macos" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["${node_prefix}"] } - } - } - ], - "provenance": { - "method": "prototype-migration", - "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" - } - }, - { - "entryId": "tool:npm", - "entryRevision": 1, - "displayName": "npm / npx", - "identity": [ - { "kind": "purl", "value": "pkg:npm/npm" }, - { "kind": "invocation-name", "names": ["npm", "npm.cmd", "npx", "npx.cmd"] } - ], - "platformVariants": [ - { - "when": { "platform": "windows" }, - "dependencies": [{ "entryId": "tool:node" }], - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${npm_prefix}"], - "readwritePaths": ["${project_root}", "${npm_cache}"] - } - } - }, - { - "when": { "platform": "linux" }, - "dependencies": [{ "entryId": "tool:node" }], - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${npm_prefix}"], - "readwritePaths": ["${project_root}", "${npm_cache}"] - } - } - }, - { - "when": { "platform": "macos" }, - "dependencies": [{ "entryId": "tool:node" }], - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${npm_prefix}"], - "readwritePaths": ["${project_root}", "${npm_cache}"] - } - } - } - ], - "provenance": { - "method": "prototype-migration", - "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" - } - } - ] -} diff --git a/src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json b/src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json new file mode 100644 index 000000000..15f086d66 --- /dev/null +++ b/src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json @@ -0,0 +1,271 @@ +{ + "catalogSchemaVersion": "1", + "catalogRevision": "2026-10-02.1", + "entries": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "displayName": "Git", + "versionScheme": "intdot", + "identity": [ + { + "kind": "purl", + "value": "pkg:generic/git" + }, + { + "kind": "invocation-name", + "names": [ + "git", + "git.exe" + ] + } + ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "${git_prefix}" + ], + "readwritePaths": [ + "${project_root}" + ] + } + }, + "intents": { + "local": { + "exampleSubcommands": [ + "status", + "commit", + "log" + ] + }, + "fetch": { + "exampleSubcommands": [ + "fetch", + "pull", + "clone" + ], + "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", + "intents": { + "bundle-fetch": { + "exampleSubcommands": [ + "fetch --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": 1, + "displayName": "Node.js", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "node", + "node.exe" + ] + } + ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "${node_prefix}" + ] + } + } + }, + "provenance": { + "method": "prototype-migration", + "sourceRevision": "ChazGo/mxc@17070fb:sdk/node/src/config-floors/catalog.json" + } + }, + { + "entryId": "tool:npm", + "entryRevision": 1, + "displayName": "npm / npx", + "versionScheme": "npm", + "identity": [ + { + "kind": "purl", + "value": "pkg:npm/npm" + }, + { + "kind": "invocation-name", + "names": [ + "npm", + "npm.cmd", + "npx", + "npx.cmd" + ] + } + ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "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": 1, + "displayName": "OpenSSH client", + "versionScheme": "intdot", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "ssh", + "ssh.exe" + ] + } + ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "${ssh_prefix}" + ] + } + } + }, + "provenance": { + "method": "design-example", + "sourceRevision": "docs/mxc-policy-store.md (Policy Store design, git example)" + } + } + ] +} diff --git a/src/core/mxc_policy_store/catalog/views/2026-10-02.1.md b/src/core/mxc_policy_store/catalog/views/2026-10-02.1.md new file mode 100644 index 000000000..70259d236 --- /dev/null +++ b/src/core/mxc_policy_store/catalog/views/2026-10-02.1.md @@ -0,0 +1,91 @@ + + +# Catalog revision 2026-10-02.1 — reviewer view + +Every effective policy each entry can produce: the default, plus platform additions, at most one version variant, and the selected intent. `(all)` means no intent was requested. Paths are unsubstituted templates. + +## `tool:git` — Git (revision 1, `intdot` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | +|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | +| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | +| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | +| windows | any | push | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | +| 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 | +|---|---|---|---|---|---|---|---| +| 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` | +| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | +| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | +| windows | any | push | `${git_prefix}`
`${programData}\Git`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | `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` | +| 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` | +| 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` | +| 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` | + +### Version `vers:intdot/>=2.50|<3` + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | +|---|---|---|---|---|---|---|---| +| 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` | — | +| windows | any | bundle-fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}`
`${temp_dir}\git-bundles` | — | `198.51.100.20/32 tcp/443` | — | +| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | +| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | +| windows | any | push | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | +| 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` | — | +| linux | any | bundle-fetch | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `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` | — | +| macos | any | bundle-fetch | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `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 1, `semver` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | +|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${node_prefix}` | — | — | — | — | +| linux | any | (all) | `${node_prefix}` | — | — | — | — | +| macos | any | (all) | `${node_prefix}` | — | — | — | — | + +## `tool:npm` — npm / npx (revision 1, `npm` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | +|---|---|---|---|---|---|---|---| +| 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 1, `intdot` versions) + +### Default (no detected version or out of range) + +| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | +|---|---|---|---|---|---|---|---| +| windows | any | (all) | `${ssh_prefix}` | — | — | — | — | +| linux | any | (all) | `${ssh_prefix}` | — | — | — | — | +| macos | any | (all) | `${ssh_prefix}` | — | — | — | — | diff --git a/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json index 43b50f9c5..9b1aca8db 100644 --- a/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json +++ b/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from resolveSandboxPolicyWithDiagnostics(tools, context), and resolveSandboxPolicy must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` is the failure's MXC error `code` and stable `details.reason`. `tools` is a single input or an array.", + "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from resolveSandboxPolicyWithDiagnostics(tools, context), and resolveSandboxPolicy must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` is the failure's MXC error `code` and stable `details.reason`. `tools` is a single input or an array. Expectations are generated by `MXC_POLICY_STORE_UPDATE_FIXTURES=1 cargo test -p mxc_policy_store --test conformance` and reviewed by hand.", "catalog": "bundled", "cases": [ { @@ -10,144 +10,529 @@ "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\work\\repo", - "symbols": { "git_prefix": "C:\\Program Files\\Git\\cmd" } + "symbols": { + "git_prefix": "C:\\Program Files\\Git\\cmd", + "programData": "C:\\ProgramData" + } }, "expect": { "policy": { "version": "0.9.0-alpha", "filesystem": { - "readonlyPaths": ["C:\\Program Files\\Git\\cmd"], - "readwritePaths": ["C:\\work\\repo"] + "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-09-29.1", + "catalogRevision": "2026-10-02.1", "tools": [ - { "inputIndex": 0, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + ] + } ], "resolvedDependencies": [], "warnings": [ - "input 0 ('git.exe') matched tool:git only by invocation name (weak identity)", - "tool:git uses its architecture-neutral windows variant; no x64-specific variant exists" + "input 0 ('git.exe') matched tool:git only by invocation name (weak identity)" ] } } }, { "name": "node on linux with omitted architecture reports the native-architecture default", - "tools": [{ "invocationName": "node" }], - "context": { "platform": "linux", "allowWeakIdentityFallback": true, "symbols": { "node_prefix": "/usr/local/bin" } }, - "host": { "platform": "linux", "nativeArchitecture": "arm64" }, + "host": { + "platform": "linux", + "nativeArchitecture": "arm64" + }, + "tools": [ + { + "invocationName": "node" + } + ], + "context": { + "platform": "linux", + "allowWeakIdentityFallback": true, + "symbols": { + "node_prefix": "/usr/local/bin" + } + }, "expect": { - "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["/usr/local/bin"] } }, + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "/usr/local/bin" + ] + } + }, "diagnostics": { - "catalogRevision": "2026-09-29.1", + "catalogRevision": "2026-10-02.1", "tools": [ - { "inputIndex": 0, "matches": [{ "entryId": "tool:node", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:node", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + } ], "resolvedDependencies": [], "warnings": [ - "input 0 ('node') matched tool:node only by invocation name (weak identity)", - "architecture was not specified; variants were selected for the native system architecture 'arm64'; the tool's architecture was not verified", - "tool:node uses its architecture-neutral linux variant; no arm64-specific variant exists" + "input 0 ('node') matched tool:node only by invocation name (weak identity)" ] } } }, { - "name": "npm by package URL composes the node dependency on macos, with both npm predicates attributed", - "tools": { "invocationName": "npx", "packageUrl": "pkg:npm/npm@10.9.0" }, + "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" } + "symbols": { + "npm_prefix": "/opt/homebrew/bin", + "npm_cache": "/Users/dev/.npm", + "node_prefix": "/opt/homebrew/bin" + } }, "expect": { "policy": { "version": "0.9.0-alpha", "filesystem": { - "readonlyPaths": ["/opt/homebrew/bin"], - "readwritePaths": ["/Users/dev/app", "/Users/dev/.npm"] + "readonlyPaths": [ + "/opt/homebrew/bin" + ], + "readwritePaths": [ + "/Users/dev/app", + "/Users/dev/.npm" + ] } }, "diagnostics": { - "catalogRevision": "2026-09-29.1", + "catalogRevision": "2026-10-02.1", "tools": [ { "inputIndex": 0, - "matches": [{ - "entryId": "tool:npm", - "entryRevision": 1, - "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] - }] + "status": "matched_default", + "matches": [ + { + "entryId": "tool:npm", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:node", + "entryRevision": 1, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } } ], - "resolvedDependencies": [{ "entryId": "tool:node", "entryRevision": 1 }], "warnings": [ - "tool:npm uses its architecture-neutral macos variant; no arm64-specific variant exists", - "tool:node uses its architecture-neutral macos variant; no arm64-specific variant exists" + "input 0 ('npx'): the version '10.9.0' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion" ] } } }, { "name": "an array of tools composes once per entry: shared dependency and repeated input contribute once", - "tools": ["git", "npm", "node", "npm"], + "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" } + "symbols": { + "git_prefix": "/usr/bin", + "npm_prefix": "/opt/node/bin", + "npm_cache": "/home/u/.npm", + "node_prefix": "/opt/node/bin" + } }, "expect": { "policy": { "version": "0.9.0-alpha", "filesystem": { - "readonlyPaths": ["/usr/bin", "/opt/node/bin"], - "readwritePaths": ["/w", "/home/u/.npm"] + "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-09-29.1", + "catalogRevision": "2026-10-02.1", "tools": [ - { "inputIndex": 0, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }, - { "inputIndex": 1, "matches": [{ "entryId": "tool:npm", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }, - { "inputIndex": 2, "matches": [{ "entryId": "tool:node", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }, - { "inputIndex": 3, "matches": [{ "entryId": "tool:npm", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + ] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:npm", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + }, + { + "inputIndex": 2, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:node", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + }, + { + "inputIndex": 3, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:npm", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + } + ], + "resolvedDependencies": [ + { + "entryId": "tool:node", + "entryRevision": 1, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } ], - "resolvedDependencies": [{ "entryId": "tool:node", "entryRevision": 1 }], "warnings": [ "input 0 ('git') matched tool:git only by invocation name (weak identity)", "input 1 ('npm') matched tool:npm only by invocation name (weak identity)", "input 2 ('node') matched tool:node only by invocation name (weak identity)", - "input 3 ('npm') matched tool:npm only by invocation name (weak identity)", - "tool:git uses its architecture-neutral linux variant; no x64-specific variant exists", - "tool:npm uses its architecture-neutral linux variant; no x64-specific variant exists", - "tool:node uses its architecture-neutral linux variant; no x64-specific variant exists" + "input 3 ('npm') matched tool:npm only by invocation name (weak identity)" ] } } }, { "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" } }, + "tools": [ + "cargo", + "git" + ], + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "projectRoot": "/w", + "symbols": { + "git_prefix": "/usr/bin" + } + }, "expect": { - "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["/usr/bin"], "readwritePaths": ["/w"] } }, + "policy": { + "version": "0.9.0-alpha", + "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-09-29.1", + "catalogRevision": "2026-10-02.1", "tools": [ - { "inputIndex": 0, "matches": [] }, - { "inputIndex": 1, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + { + "inputIndex": 0, + "status": "tool_unmatched", + "matches": [] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + ] + } ], "resolvedDependencies": [], "warnings": [ - "input 0 ('cargo') matched no eligible catalog entry", - "input 1 ('git') matched tool:git only by invocation name (weak identity)", - "tool:git uses its architecture-neutral linux variant; no x64-specific variant exists" + { + "code": "tool_unmatched", + "inputIndex": 0, + "message": "input 0 ('cargo') matched no eligible catalog entry" + }, + "input 1 ('git') matched tool:git only by invocation name (weak identity)" ] } } @@ -155,41 +540,102 @@ { "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" } }, + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "npm_prefix": "/p", + "npm_cache": "/c", + "node_prefix": "/n" + } + }, "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-09-29.1", - "tools": [{ "inputIndex": 0, "matches": [] }], + "catalogRevision": "2026-10-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "tool_unmatched", + "matches": [] + } + ], "resolvedDependencies": [], - "warnings": ["input 0 ('npm') matched no eligible catalog entry: tool:npm matched only by invocation name and allowWeakIdentityFallback is not enabled"] + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "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" }, + "context": { + "platform": "linux", + "architecture": "x64" + }, "expect": { "policy": null, - "diagnostics": { "catalogRevision": "2026-09-29.1", "tools": [], "resolvedDependencies": [], "warnings": [] } + "diagnostics": { + "catalogRevision": "2026-10-02.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" } }, + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "symbols": { + "git_prefix": "/usr/bin" + } + }, "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-09-29.1", + "catalogRevision": "2026-10-02.1", "tools": [ - { "inputIndex": 0, "matches": [{ "entryId": "tool:git", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] } + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + ] + } ], "resolvedDependencies": [], "warnings": [ "input 0 ('git') matched tool:git only by invocation name (weak identity)", - "tool:git uses its architecture-neutral linux variant; no x64-specific variant exists", "required symbol 'project_root' (needed by tool:git) is unresolved; supply ResolveContext.projectRoot; no policy was returned" ] } @@ -198,20 +644,1038 @@ { "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" } + "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" } + "context": { + "platform": "linux", + "architecture": "x64", + "allowWeakIdentityFallback": true, + "symbols": { + "node_prefix": "bin" + } + }, + "expectError": { + "code": "malformed_request", + "reason": "invalid_context" + } }, { - "name": "symbol values that make access classes overlap across tools are a composition conflict", - "tools": ["git", "node"], - "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/opt", "symbols": { "git_prefix": "/usr/bin", "node_prefix": "/opt/node" } }, - "expectError": { "code": "policy_validation", "reason": "composition_conflict" } + "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": { + "policy": { + "version": "0.9.0-alpha", + "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-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] + } + } + ] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:node", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + } + ], + "resolvedDependencies": [], + "warnings": [ + "input 0 ('git') matched tool:git only by invocation name (weak identity)", + "input 1 ('node') matched tool:node only by invocation name (weak identity)", + "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": { + "policy": { + "version": "0.9.0-alpha", + "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-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] + } + } + ] + } + ], + "resolvedDependencies": [], + "warnings": [] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "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-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_version", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "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": 1, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "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-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_version", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "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": [] + } + } + }, + { + "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": { + "policy": null, + "diagnostics": { + "catalogRevision": "2026-10-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "intent_unsupported", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "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", + "detectedVersion": "2.45.1", + "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": { + "policy": null, + "diagnostics": { + "catalogRevision": "2026-10-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "intent_unsupported", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "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", + "intent": "bundle-fetch", + "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", + "detectedVersion": "2.30.0", + "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": { + "policy": null, + "diagnostics": { + "catalogRevision": "2026-10-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "version_unparseable", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "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", + "intent": "fetch", + "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": { + "policy": { + "version": "0.9.0-alpha", + "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-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_version", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "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": [] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "C:\\Program Files\\Git", + "C:\\ProgramData\\Git" + ], + "readwritePaths": [ + "C:\\work" + ] + } + }, + "diagnostics": { + "catalogRevision": "2026-10-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + ] + } + ], + "resolvedDependencies": [], + "warnings": [] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "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-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] + } + } + ] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "fetch", + "mode": "named", + "selected": [ + "fetch" + ] + } + } + ] + }, + { + "inputIndex": 2, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:git", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] + } + } + ] + }, + { + "inputIndex": 3, + "status": "tool_unmatched", + "matches": [] + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 3, + "message": "input 3 ('cargo') matched no eligible catalog entry" + } + ] + } + } + }, + { + "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" + } } ] } diff --git a/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json index ef6e8fbd2..699d84598 100644 --- a/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json +++ b/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Language-neutral conformance cases against an inline synthetic catalog revision (not published). Covers additive identity matching, equal-strength and strong+weak matches to different entries, version-range warnings, native-architecture defaults, exact vs neutral variants, dependency metadata, and composition rejection. See bundled-catalog.json for the case format. `host` overrides the simulated host (default linux/x64).", + "$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", @@ -8,276 +8,1497 @@ "entryId": "tool:base", "entryRevision": 2, "displayName": "Base runtime", - "identity": [{ "kind": "invocation-name", "names": ["base"] }], + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "base" + ] + } + ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/share" + ] + } + } + }, "platformVariants": [ { - "when": { "platform": "linux" }, - "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/lib", "${git_prefix}/share"] } } + "when": { + "platform": "linux" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/lib" + ] + } + } }, { - "when": { "platform": "windows", "architecture": "arm64" }, - "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\arm64"] } } + "when": { + "platform": "windows", + "architecture": "arm64" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}\\arm64" + ] + } + } }, { - "when": { "platform": "windows", "architecture": "x64" }, - "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}\\x64"] } } + "when": { + "platform": "windows", + "architecture": "x64" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}\\x64" + ] + } + } } ], - "provenance": { "method": "fixture", "sourceRevision": "fixture" } + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } }, { "entryId": "tool:app-plugin", "entryRevision": 1, "displayName": "App plugin host", - "identity": [{ "kind": "invocation-name", "names": ["app"] }], - "platformVariants": [ + "versionScheme": "semver", + "identity": [ { - "when": { "platform": "linux" }, - "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["${git_prefix}/plugins"] } } + "kind": "invocation-name", + "names": [ + "app" + ] } ], - "provenance": { "method": "fixture", "sourceRevision": "fixture" } + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "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", "versionRange": ">=2 <3" }, - { "kind": "invocation-name", "names": ["app", "app.cmd"] } + { + "kind": "purl", + "value": "pkg:npm/app" + }, + { + "kind": "invocation-name", + "names": [ + "app", + "app.cmd" + ] + } ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "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" }, - "dependencies": [{ "entryId": "tool:base", "versionRange": ">=22" }], - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { "readwritePaths": ["${project_root}"], "readonlyPaths": ["${git_prefix}/lib"] } + "when": { + "platform": "linux", + "architecture": "arm64" + }, + "policyAdditions": { + "filesystem": { + "readonlyPaths": [ + "${git_prefix}/lib-arm64" + ] + } } - }, + } + ], + "versionVariants": [ { - "when": { "platform": "linux" }, - "dependencies": [{ "entryId": "tool:base" }], - "sandboxPolicy": { "version": "0.9.0-alpha", "filesystem": { "readwritePaths": ["${project_root}"] } } + "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 + } + ] + } + ] + } + } + } + } + } }, { - "when": { "platform": "windows" }, - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { "readwritePaths": ["${project_root}"] }, - "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } + "versionRange": "vers:npm/>=3.0.0", + "intents": { + "publish": { + "exampleSubcommands": [ + "publish" + ], + "policyAdditions": { + "filesystem": { + "readwritePaths": [ + "${temp_dir}/app-publish" + ] + } + } + } } } ], - "provenance": { "method": "fixture", "sourceRevision": "fixture" } + "provenance": { + "method": "fixture", + "sourceRevision": "fixture" + } } ] }, "cases": [ { - "name": "exact architecture variant precedes the neutral variant; dependency range is unevaluated metadata", - "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app", "detectedVersion": "2.4.1" }, - "context": { "platform": "linux", "architecture": "arm64", "projectRoot": "/w/proj", "symbols": { "git_prefix": "/opt/g" } }, + "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": { "policy": { "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["/opt/g/lib", "/opt/g/share"], "readwritePaths": ["/w/proj"] } + "filesystem": { + "readonlyPaths": [ + "/g/lib-arm64", + "/g/tools", + "/g/share", + "/g/lib" + ], + "readwritePaths": [ + "/w" + ] + } }, "diagnostics": { "catalogRevision": "2000-01-01.1", "tools": [ { "inputIndex": 0, - "matches": [{ "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] }] + "status": "matched_version", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + }, + { + "entryId": "tool:base", + "entryRevision": 2, + "requiredVersionRange": "vers:semver/>=22.0.0", + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } } ], - "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2, "requiredVersionRange": ">=22" }], - "warnings": ["tool:base uses its architecture-neutral linux variant; no arm64-specific variant exists"] + "warnings": [] } } }, { - "name": "a strong match does not suppress another entry's eligible weak match; both contribute once", - "tools": [{ "invocationName": "app", "packageUrl": "pkg:npm/app" }], - "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, + "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": { "policy": { "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["/g/lib", "/g/share", "/g/plugins"], "readwritePaths": ["/w"] } + "filesystem": { + "readonlyPaths": [ + "/g/tools", + "/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, + "status": "matched_default", "matches": [ - { "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] }, - { "entryId": "tool:app-plugin", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] } + { + "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 }], - "warnings": [ - "input 0 ('app') matched tool:app-plugin only by invocation name (weak identity)", - "input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute", - "tool:app uses its architecture-neutral linux variant; no x64-specific variant exists", - "tool:base uses its architecture-neutral linux variant; no x64-specific variant exists", - "tool:app-plugin uses its architecture-neutral linux variant; no x64-specific variant exists" - ] + "resolvedDependencies": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [] } } }, { - "name": "equal-strength weak matches to different entries both contribute, not an ambiguity error", - "tools": "app", - "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/w", "symbols": { "git_prefix": "/g" } }, + "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": { "policy": { "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["/g/lib", "/g/share", "/g/plugins"], "readwritePaths": ["/w"] } + "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, + "status": "version_out_of_range", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [ + { + "code": "version_out_of_range", + "inputIndex": 0, + "entryId": "tool:app", + "detectedVersion": "1.0.0", + "intent": "serve", + "message": "input 0 ('app'): detected version '1.0.0' is outside every reviewed version range for tool:app; its unversioned default applies" + } + ] + } + } + }, + { + "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": { + "policy": null, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "status": "version_unparseable", "matches": [ - { "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }, - { "entryId": "tool:app-plugin", "entryRevision": 1, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] } + { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "version_unparseable", + "detectedVersion": "not a version" + } + } ] } ], - "resolvedDependencies": [{ "entryId": "tool:base", "entryRevision": 2 }], + "resolvedDependencies": [], "warnings": [ - "input 0 ('app') matched tool:app only by invocation name (weak identity)", - "input 0 ('app') matched tool:app-plugin only by invocation name (weak identity)", - "input 0 ('app') matched 2 entries (tool:app, tool:app-plugin); all contribute", - "tool:app uses its architecture-neutral linux variant; no x64-specific variant exists", - "tool:base uses its architecture-neutral linux variant; no x64-specific variant exists", - "tool:app-plugin uses its architecture-neutral linux variant; no x64-specific variant exists" + { + "code": "version_unparseable", + "inputIndex": 0, + "entryId": "tool:app", + "detectedVersion": "not a version", + "intent": "serve", + "message": "input 0 ('app'): detected version 'not a version' is not a valid npm version for tool:app; the input contributes nothing" + } ] } } }, { - "name": "version-range mismatch is a warning, not a refusal; a standalone entry may carry network", - "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app@3.0.0" }, - "context": { "platform": "windows", "architecture": "x64", "projectRoot": "D:\\src" }, + "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": { "policy": { "version": "0.9.0-alpha", - "filesystem": { "readwritePaths": ["D:\\src"] }, - "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } + "filesystem": { + "readonlyPaths": [ + "/g/share", + "/g/lib" + ], + "readwritePaths": [ + "/w", + "/tmp/app-publish" + ] + } }, "diagnostics": { "catalogRevision": "2000-01-01.1", "tools": [ { "inputIndex": 0, - "matches": [{ "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "purl", "strength": "strong" }, { "kind": "invocation-name", "strength": "weak" }] }] + "status": "matched_version", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [] + } + } + }, + { + "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": { + "policy": null, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "status": "intent_unsupported", + "matches": [ + { + "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": [ - "input 0 ('app'): detected version '3.0.0' is outside the reviewed range '>=2 <3' for tool:app", - "tool:app uses its architecture-neutral windows variant; no x64-specific variant exists" + { + "code": "intent_unsupported", + "inputIndex": 0, + "entryId": "tool:app", + "detectedVersion": "2.4.1", + "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": "no platform variant contributes no match", - "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, - "context": { "platform": "macos", "architecture": "arm64", "projectRoot": "/w" }, + "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": { - "policy": null, + "policy": { + "version": "0.9.0-alpha", + "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, "matches": [] }], - "resolvedDependencies": [], - "warnings": ["input 0 ('app') matched no eligible catalog entry: tool:app has no variant for macos/arm64; tool:app-plugin matched only by invocation name and allowWeakIdentityFallback is not enabled"] + "tools": [ + { + "inputIndex": 0, + "status": "matched_version", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + }, + { + "entryId": "tool:base", + "entryRevision": 2, + "requiredVersionRange": "vers:semver/>=22.0.0", + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "/g/tools", + "/g/share", + "/g/lib" + ], + "readwritePaths": [ + "/w" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [ + "input 0 ('app'): the version '2.4.1' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion" + ] } } }, { - "name": "omitted architecture on an ARM64 host selects the native ARM64 variant", + "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", - "host": { "platform": "windows", "nativeArchitecture": "arm64" }, - "context": { "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "C:\\g" } }, + "context": { + "allowWeakIdentityFallback": true, + "symbols": { + "git_prefix": "C:\\g" + } + }, "expect": { - "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["C:\\g\\arm64"] } }, + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "C:\\g\\share", + "C:\\g\\arm64" + ] + } + }, "diagnostics": { "catalogRevision": "2000-01-01.1", - "tools": [{ "inputIndex": 0, "matches": [{ "entryId": "tool:base", "entryRevision": 2, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }], + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + } + ], "resolvedDependencies": [], "warnings": [ "input 0 ('base') matched tool:base only by invocation name (weak identity)", - "architecture was not specified; variants were selected for the native system architecture 'arm64'; the tool's architecture was not verified" + "architecture was not specified; overlays were selected for the native system architecture 'arm64'; the tool's architecture was not verified" ] } } }, { - "name": "explicit x64 on the same ARM64 host selects the x64 variant", + "name": "explicit x64 on the same ARM64 host selects the x64 overlay", + "host": { + "platform": "windows", + "nativeArchitecture": "arm64" + }, "tools": "base", - "host": { "platform": "windows", "nativeArchitecture": "arm64" }, - "context": { "architecture": "x64", "allowWeakIdentityFallback": true, "symbols": { "git_prefix": "C:\\g" } }, + "context": { + "architecture": "x64", + "allowWeakIdentityFallback": true, + "symbols": { + "git_prefix": "C:\\g" + } + }, "expect": { - "policy": { "version": "0.9.0-alpha", "filesystem": { "readonlyPaths": ["C:\\g\\x64"] } }, + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "C:\\g\\share", + "C:\\g\\x64" + ] + } + }, "diagnostics": { "catalogRevision": "2000-01-01.1", - "tools": [{ "inputIndex": 0, "matches": [{ "entryId": "tool:base", "entryRevision": 2, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }], + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:base", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + } + ], "resolvedDependencies": [], - "warnings": ["input 0 ('base') matched tool:base only by invocation name (weak identity)"] + "warnings": [ + "input 0 ('base') matched tool:base only by invocation name (weak identity)" + ] } } }, { "name": "invocation names are case-insensitive on windows", "tools": "APP.CMD", - "context": { "platform": "windows", "architecture": "arm64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\p" }, + "context": { + "platform": "windows", + "architecture": "arm64", + "allowWeakIdentityFallback": true, + "projectRoot": "C:\\p", + "symbols": { + "git_prefix": "C:\\g" + } + }, "expect": { "policy": { "version": "0.9.0-alpha", - "filesystem": { "readwritePaths": ["C:\\p"] }, - "network": { "egress": { "default": "deny", "allow": [{ "to": [{ "cidr": "192.0.2.0/24" }], "ports": [{ "protocol": "tcp", "port": 443 }] }] } } + "filesystem": { + "readonlyPaths": [ + "C:\\g\\tools", + "C:\\g\\share", + "C:\\g\\arm64" + ], + "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, "matches": [{ "entryId": "tool:app", "entryRevision": 5, "matchedIdentities": [{ "kind": "invocation-name", "strength": "weak" }] }] }], - "resolvedDependencies": [], + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], "warnings": [ - "input 0 ('APP.CMD') matched tool:app only by invocation name (weak identity)", - "tool:app uses its architecture-neutral windows variant; no arm64-specific variant exists" + "input 0 ('APP.CMD') matched tool:app only by invocation name (weak identity)" ] } } }, { "name": "invocation names are case-sensitive on linux", - "tools": "APP", - "context": { "platform": "linux", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "/p", "symbols": { "git_prefix": "/g" } }, + "tools": "BASE", + "context": { + "platform": "linux", + "architecture": "x64", + "projectRoot": "/w", + "symbols": { + "git_prefix": "/g", + "temp_dir": "/tmp" + }, + "allowWeakIdentityFallback": true + }, "expect": { "policy": null, "diagnostics": { "catalogRevision": "2000-01-01.1", - "tools": [{ "inputIndex": 0, "matches": [] }], + "tools": [ + { + "inputIndex": 0, + "status": "tool_unmatched", + "matches": [] + } + ], "resolvedDependencies": [], - "warnings": ["input 0 ('APP') matched no eligible catalog entry"] + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "message": "input 0 ('BASE') matched no eligible catalog entry" + } + ] } } }, { - "name": "network fields cannot compose across entries selected by different inputs", - "tools": ["APP.cmd", "base"], - "context": { "platform": "windows", "architecture": "x64", "allowWeakIdentityFallback": true, "projectRoot": "C:\\p", "symbols": { "git_prefix": "C:\\g" } }, - "expectError": { "code": "policy_validation", "reason": "composition_conflict" } + "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": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "/g/tools", + "/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, + "status": "matched_default", + "matches": [ + { + "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, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [] + } + } }, { - "name": "symbol values that make access classes overlap are a composition conflict", - "tools": { "invocationName": "app", "packageUrl": "pkg:npm/app" }, - "context": { "platform": "linux", "architecture": "x64", "projectRoot": "/g", "symbols": { "git_prefix": "/g" } }, - "expectError": { "code": "policy_validation", "reason": "composition_conflict" } + "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": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readwritePaths": [ + "/g" + ] + } + }, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ], + "warnings": [ + "read-only '/g/tools' (tool:app) is covered by read-write '/g' (tool:app); the read-only entry was omitted", + "read-only '/g/share' (tool:base) is covered by read-write '/g' (tool:app); the read-only entry was omitted", + "read-only '/g/lib' (tool:base) 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": { + "policy": null, + "diagnostics": { + "catalogRevision": "2000-01-01.1", + "tools": [ + { + "inputIndex": 0, + "status": "tool_unmatched", + "matches": [] + } + ], + "resolvedDependencies": [], + "warnings": [ + { + "code": "tool_unmatched", + "inputIndex": 0, + "intent": "build", + "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/core/mxc_policy_store/schema/catalog.v1.schema.json b/src/core/mxc_policy_store/schema/catalog.v1.schema.json index a65202275..b0eb5a92a 100644 --- a/src/core/mxc_policy_store/schema/catalog.v1.schema.json +++ b/src/core/mxc_policy_store/schema/catalog.v1.schema.json @@ -2,33 +2,102 @@ "$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 (identity uniqueness, dependency closure, cycle freedom, symbol validity, unsafe paths, composition limits) are enforced by the catalog validator, not by this schema.", + "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, composition limits) are enforced by the catalog validator, not by this schema.", "type": "object", "additionalProperties": false, - "required": ["catalogSchemaVersion", "catalogRevision", "entries"], + "required": [ + "catalogSchemaVersion", + "catalogRevision", + "entries" + ], "properties": { - "catalogSchemaVersion": { "const": "1" }, - "catalogRevision": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*$" }, - "entries": { "type": "array", "items": { "$ref": "#/$defs/entry" } } + "catalogSchemaVersion": { + "const": "1" + }, + "catalogRevision": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[1-9][0-9]*$" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + } }, "$defs": { "entry": { "type": "object", "additionalProperties": false, - "required": ["entryId", "entryRevision", "displayName", "identity", "platformVariants", "provenance"], + "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 }, - "identity": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/identity" } }, - "platformVariants": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/platformVariant" } }, + "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"], + "required": [ + "method", + "sourceRevision" + ], "properties": { - "method": { "type": "string", "minLength": 1 }, - "sourceRevision": { "type": "string", "minLength": 1 } + "method": { + "type": "string", + "minLength": 1 + }, + "sourceRevision": { + "type": "string", + "minLength": 1 + } } } } @@ -38,55 +107,234 @@ { "type": "object", "additionalProperties": false, - "required": ["kind", "value"], + "required": [ + "kind", + "value" + ], "properties": { - "kind": { "const": "purl" }, - "value": { "type": "string", "pattern": "^pkg:" }, - "versionRange": { "type": "string", "minLength": 1 } + "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"], + "required": [ + "kind", + "names" + ], "properties": { - "kind": { "const": "invocation-name" }, - "names": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + "kind": { + "const": "invocation-name" + }, + "names": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } } } ] }, + "default": { + "type": "object", + "additionalProperties": false, + "required": [ + "sandboxPolicy" + ], + "properties": { + "sandboxPolicy": { + "$ref": "#/$defs/sandboxPolicy" + }, + "dependencies": { + "$ref": "#/$defs/dependencies" + }, + "intents": { + "$ref": "#/$defs/intents" + } + } + }, "platformVariant": { "type": "object", "additionalProperties": false, - "required": ["when", "sandboxPolicy"], + "required": [ + "when" + ], "properties": { "when": { "type": "object", "additionalProperties": false, - "required": ["platform"], + "required": [ + "platform" + ], "properties": { - "platform": { "enum": ["windows", "linux", "macos"] }, - "architecture": { "enum": ["x64", "arm64"] } + "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" + }, + "intents": { + "description": "New intents.", + "$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" + }, + "intents": { + "description": "New intents.", + "$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": "object", - "additionalProperties": false, - "required": ["entryId"], - "properties": { - "entryId": { "type": "string" }, - "versionRange": { "type": "string", "minLength": 1 } + "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" } } }, - "sandboxPolicy": { "$ref": "#/$defs/sandboxPolicy" } + "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." + } + } } }, - "pathList": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "networkAction": { "enum": ["allow", "deny"] }, + "pathList": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "networkAction": { + "enum": [ + "allow", + "deny" + ] + }, "networkRule": { "type": "object", "additionalProperties": false, @@ -97,10 +345,19 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["cidr"], + "required": [ + "cidr" + ], "properties": { - "cidr": { "type": "string" }, - "except": { "type": "array", "items": { "type": "string" } } + "cidr": { + "type": "string" + }, + "except": { + "type": "array", + "items": { + "type": "string" + } + } } } }, @@ -111,9 +368,24 @@ "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 } + "protocol": { + "enum": [ + "tcp", + "udp", + "icmp", + "any" + ] + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "endPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } } } } @@ -123,16 +395,26 @@ "description": "Catalog-supported subset of the MXC SandboxPolicy contract. Backend-specific keys (processContainer, seatbelt, lxc, containment, ...) are rejected so variants stay backend-neutral.", "type": "object", "additionalProperties": false, - "required": ["version"], + "required": [ + "version" + ], "properties": { - "version": { "type": "string" }, + "version": { + "type": "string" + }, "filesystem": { "type": "object", "additionalProperties": false, "properties": { - "deniedPaths": { "$ref": "#/$defs/pathList" }, - "readonlyPaths": { "$ref": "#/$defs/pathList" }, - "readwritePaths": { "$ref": "#/$defs/pathList" } + "deniedPaths": { + "$ref": "#/$defs/pathList" + }, + "readonlyPaths": { + "$ref": "#/$defs/pathList" + }, + "readwritePaths": { + "$ref": "#/$defs/pathList" + } } }, "network": { @@ -143,17 +425,33 @@ "type": "object", "additionalProperties": false, "properties": { - "default": { "$ref": "#/$defs/networkAction" }, - "allow": { "type": "array", "items": { "$ref": "#/$defs/networkRule" } }, - "deny": { "type": "array", "items": { "$ref": "#/$defs/networkRule" } } + "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" } + "default": { + "$ref": "#/$defs/networkAction" + }, + "hostLoopback": { + "$ref": "#/$defs/networkAction" + } } } } @@ -162,12 +460,26 @@ "type": "object", "additionalProperties": false, "properties": { - "allowWindows": { "type": "boolean" }, - "clipboard": { "enum": ["none", "read", "write", "all"] }, - "allowInputInjection": { "type": "boolean" } + "allowWindows": { + "type": "boolean" + }, + "clipboard": { + "enum": [ + "none", + "read", + "write", + "all" + ] + }, + "allowInputInjection": { + "type": "boolean" + } } }, - "timeoutMs": { "type": "integer", "minimum": 1 } + "timeoutMs": { + "type": "integer", + "minimum": 1 + } } } } diff --git a/src/core/mxc_policy_store/src/catalog.rs b/src/core/mxc_policy_store/src/catalog.rs index a20851e8e..a99198ccf 100644 --- a/src/core/mxc_policy_store/src/catalog.rs +++ b/src/core/mxc_policy_store/src/catalog.rs @@ -1,17 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Contract and revision validation, variant selection, dependency closure, -//! and composition limits. Error messages match the original TypeScript -//! prototype byte for byte, so the frozen conformance vectors still apply. +//! Catalog contract, entry model (one unversioned default plus additive +//! platform and version overlays), and contract/revision validation. The +//! effective-policy materialization lives in [`crate::effective`]. use crate::errors::{invalid_catalog, Result}; -use crate::json::{cmp_utf16, js_number_to_string, js_to_string, Json, JsonObject}; +use crate::json::{js_number_to_string, js_to_string, Json, JsonObject}; use crate::model::{Architecture, IdentityStrength, Platform}; -use crate::paths::path_key_segments; use crate::purl::parse_purl; use crate::text::{is_symbol_name, js_to_lower, replace_symbols, symbol_matches}; -use crate::version_range::is_valid_version_range; +use crate::vers::{VersRange, VersionScheme}; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; @@ -65,40 +64,6 @@ impl CatalogContract { } } -/// One identity predicate of an entry. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum IdentityPredicate { - Purl { - value: String, - version_range: Option, - }, - 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, - pub version_range: Option, -} - /// An embedded, validated `SandboxPolicy`. The reviewed JSON object is kept /// as written so field order and values are preserved exactly. #[derive(Clone, Debug, PartialEq)] @@ -137,12 +102,98 @@ impl CatalogPolicy { } } +/// 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, +} + +/// 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)>, + /// New intents. + pub intents: Vec<(String, IntentDefinition)>, +} + +/// The unversioned default every entry has exactly once. +#[derive(Clone, Debug, PartialEq)] +pub struct EntryDefault { + pub sandbox_policy: CatalogPolicy, + pub dependencies: Vec, + pub intents: Vec<(String, IntentDefinition)>, +} + #[derive(Clone, Debug, PartialEq)] pub struct PlatformVariant { pub platform: Platform, pub architecture: Option, - pub dependencies: Option>, - pub sandbox_policy: CatalogPolicy, + 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)] @@ -150,8 +201,11 @@ 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). @@ -165,7 +219,7 @@ pub struct CatalogRevision { pub entries: Vec, } -fn fail(message: impl AsRef) -> Result { +pub(crate) fn fail(message: impl AsRef) -> Result { Err(invalid_catalog(message)) } @@ -325,7 +379,11 @@ pub fn validate_contract(raw: &Json) -> Result { // Paths and symbols // --------------------------------------------------------------------------- -fn validate_template_path(value: &str, at: &str, contract: &CatalogContract) -> Result<()> { +pub(crate) fn validate_template_path( + value: &str, + at: &str, + contract: &CatalogContract, +) -> Result<()> { if value.contains(['*', '?']) { return fail(format!("'{at}' contains a wildcard")); } @@ -352,60 +410,6 @@ fn validate_template_path(value: &str, at: &str, contract: &CatalogContract) -> Ok(()) } -/// The symbols a policy references, in first-seen order. -pub fn policy_symbols(policy: &CatalogPolicy) -> Vec { - let mut seen: Vec = Vec::new(); - for field in COMPOSABLE_FILESYSTEM_FIELDS { - for value in policy.filesystem_field(field) { - for (_, _, name) in symbol_matches(value) { - if !seen.iter().any(|s| s == name) { - seen.push(name.to_string()); - } - } - } - } - seen -} - -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) -} - -/// Finds the first equal or ancestor/descendant pair of paths in different -/// access classes. `classes` is in [`COMPOSABLE_FILESYSTEM_FIELDS`] order. -pub fn find_cross_class_overlap( - classes: &[(&str, Vec)], - platform: Platform, -) -> Option { - let fields: Vec<&(&str, Vec)> = classes - .iter() - .filter(|(_, values)| !values.is_empty()) - .collect(); - for i in 0..fields.len() { - for j in i + 1..fields.len() { - for left in &fields[i].1 { - for right in &fields[j].1 { - if is_same_or_nested( - &path_key_segments(left, platform), - &path_key_segments(right, platform), - ) { - return Some(format!( - "'{left}' ({}) overlaps '{right}' ({})", - fields[i].0, fields[j].0 - )); - } - } - } - } - } - None -} - // --------------------------------------------------------------------------- // Embedded SandboxPolicy // --------------------------------------------------------------------------- @@ -490,7 +494,7 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> Ok(()) } -fn validate_sandbox_policy( +pub(crate) fn validate_sandbox_policy( raw: Option<&Json>, at: &str, contract: &CatalogContract, @@ -624,7 +628,7 @@ fn validate_sandbox_policy( /// Stable comparison keys for one predicate; invocation names always fold. fn identity_keys(predicate: &IdentityPredicate) -> Vec { match predicate { - IdentityPredicate::Purl { value, .. } => { + IdentityPredicate::Purl { value } => { vec![format!( "purl:{}", parse_purl(value).map(|p| p.key).unwrap_or_default() @@ -650,30 +654,17 @@ fn validate_identity(raw: Option<&Json>, at: &str) -> Result { - only_fields(item, &["kind", "value", "versionRange"], &item_at)?; + 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() { return fail(format!( - "'{item_at}.value' must not pin a version; use 'versionRange'" + "'{item_at}.value' must not pin a version; versions select 'versionVariants'" )); } - let mut version_range = None; - if let Some(range) = item.get("versionRange") { - let range = non_empty_string(Some(range), &format!("{item_at}.versionRange"))?; - if !is_valid_version_range(&range) { - return fail(format!( - "'{item_at}.versionRange' is not a valid version range" - )); - } - version_range = Some(range); - } - predicates.push(IdentityPredicate::Purl { - value, - version_range, - }); + predicates.push(IdentityPredicate::Purl { value }); } Some("invocation-name") => { only_fields(item, &["kind", "names"], &item_at)?; @@ -699,14 +690,258 @@ fn validate_identity(raw: Option<&Json>, at: &str) -> Result, 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"], &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); + } + list.push(Dependency { + entry_id, + version_range, + }); + } + 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 'intents' to declare a new one" + )); + } + } + let intents = validate_intents(item.get("intents"), &format!("{at}.intents"), contract)?; + for (name, _) in &intents { + if default_intents.iter().any(|(n, _)| n == name) { + return fail(format!( + "'{at}.intents.{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, + intents, + }) +} + +const OVERLAY_FIELDS: [&str; 4] = [ + "policyAdditions", + "dependencies", + "intentAdditions", + "intents", +]; + +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, &["sandboxPolicy", "dependencies", "intents"], at)?; + Ok(EntryDefault { + sandbox_policy: validate_sandbox_policy( + item.get("sandboxPolicy"), + &format!("{at}.sandboxPolicy"), + 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 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 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(); @@ -715,7 +950,9 @@ fn validate_variants( let Some(item) = item.as_object() else { return fail(format!("'{item_at}' must be an object")); }; - only_fields(item, &["when", "dependencies", "sandboxPolicy"], &item_at)?; + 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")); }; @@ -755,51 +992,63 @@ fn validate_variants( format!("'{item_at}' duplicates selector '{selector}'") }); } - let mut dependencies = None; - if let Some(raw_dependencies) = item.get("dependencies") { - let Some(raw_dependencies) = raw_dependencies.as_array() else { - return fail(format!("'{item_at}.dependencies' must be an array")); - }; - let mut seen = HashSet::new(); - let mut list = Vec::new(); - for (dep_index, dependency) in raw_dependencies.iter().enumerate() { - let dep_at = format!("{item_at}.dependencies[{dep_index}]"); - let Some(dependency) = dependency.as_object() else { - return fail(format!("'{dep_at}' must be an object")); - }; - only_fields(dependency, &["entryId", "versionRange"], &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 !is_valid_version_range(&range) { - return fail(format!( - "'{dep_at}.versionRange' is not a valid version range" - )); - } - version_range = Some(range); - } - list.push(Dependency { - entry_id, - version_range, - }); - } - dependencies = Some(list); - } - let sandbox_policy = validate_sandbox_policy( - item.get("sandboxPolicy"), - &format!("{item_at}.sandboxPolicy"), - contract, - )?; variants.push(PlatformVariant { platform, architecture, - dependencies, - sandbox_policy, + 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) @@ -830,8 +1079,11 @@ fn validate_entry(raw: &Json, at: &str, contract: &CatalogContract) -> Result Result Result { - pub variant: &'a PlatformVariant, - pub exact: bool, -} - -/// Selects the exact architecture first, then the platform's neutral variant. -/// Another architecture's variant is never a fallback. -pub fn select_variant( - entry: &CatalogEntry, - platform: Platform, - architecture: Architecture, -) -> Option> { - let for_platform = || { - entry +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() - .filter(move |v| v.platform == platform) - }; - if let Some(variant) = for_platform().find(|v| v.architecture == Some(architecture)) { - return Some(VariantSelection { - variant, - exact: true, - }); + .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.intents)); + } + out } - for_platform() - .find(|v| v.architecture.is_none()) - .map(|variant| VariantSelection { - variant, - exact: false, - }) -} - -#[derive(Clone, Copy, Debug)] -pub struct ClosureNode<'a> { - pub entry: &'a CatalogEntry, - pub variant: &'a PlatformVariant, - pub exact: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ClosureFailure { - /// `cycle`, `missing-entry`, or `unsupported-dependency`. - pub reason: &'static str, - pub detail: String, } pub type EntryIndex<'a> = HashMap<&'a str, &'a CatalogEntry>; @@ -937,120 +1181,12 @@ pub fn entry_index(entries: &[CatalogEntry]) -> EntryIndex<'_> { entries.iter().map(|e| (e.entry_id.as_str(), e)).collect() } -struct Closure<'a, 'm> { - by_id: &'m EntryIndex<'a>, - platform: Platform, - architecture: Architecture, - nodes: Vec>, - done: HashSet<&'a str>, - stack: Vec<&'a str>, -} - -impl<'a> Closure<'a, '_> { - fn visit( - &mut self, - entry: &'a CatalogEntry, - selection: VariantSelection<'a>, - ) -> std::result::Result<(), ClosureFailure> { - if self.stack.contains(&entry.entry_id.as_str()) { - let mut path: Vec<&str> = self.stack.clone(); - path.push(&entry.entry_id); - return Err(ClosureFailure { - reason: "cycle", - detail: path.join(" -> "), - }); - } - if self.done.contains(entry.entry_id.as_str()) { - return Ok(()); - } - self.stack.push(&entry.entry_id); - self.nodes.push(ClosureNode { - entry, - variant: selection.variant, - exact: selection.exact, - }); - for dependency in selection.variant.dependencies.iter().flatten() { - let Some(target) = self.by_id.get(dependency.entry_id.as_str()).copied() else { - return Err(ClosureFailure { - reason: "missing-entry", - detail: format!("{} -> {}", entry.entry_id, dependency.entry_id), - }); - }; - let Some(selected) = select_variant(target, self.platform, self.architecture) else { - return Err(ClosureFailure { - reason: "unsupported-dependency", - detail: format!( - "{} -> {} has no {}/{} variant", - entry.entry_id, dependency.entry_id, self.platform, self.architecture - ), - }); - }; - self.visit(target, selected)?; - } - self.stack.pop(); - self.done.insert(&entry.entry_id); - Ok(()) - } -} - -/// Deterministic depth-first dependency closure; the root first, each -/// dependency in declaration order and once. Cycles are reported. -pub fn dependency_closure<'a>( - root: &'a CatalogEntry, - root_selection: VariantSelection<'a>, - by_id: &EntryIndex<'a>, - platform: Platform, - architecture: Architecture, -) -> std::result::Result>, ClosureFailure> { - let mut closure = Closure { - by_id, - platform, - architecture, - nodes: Vec::new(), - done: HashSet::new(), - stack: Vec::new(), - }; - closure.visit(root, root_selection)?; - Ok(closure.nodes) -} - -/// Composition limits for the v1 vocabulary (design §4.5). -pub fn composition_violation(nodes: &[ClosureNode<'_>]) -> Option { - let mut versions: Vec<&str> = Vec::new(); - for node in nodes { - let version = node.variant.sandbox_policy.version(); - if !versions.contains(&version) { - versions.push(version); - } - } - if versions.len() > 1 { - versions.sort_by(|a, b| cmp_utf16(a, b)); - return Some(format!( - "mixed sandboxPolicy.version values ({})", - versions.join(", ") - )); - } - if nodes.len() < 2 { - return None; - } - for node in nodes { - for key in node.variant.sandbox_policy.raw().keys() { - if key != "version" && key != "filesystem" { - return Some(format!( - "'{}' uses '{key}', which has no v1 cross-entry composition rule", - node.entry.entry_id - )); - } - } - } - None -} - // --------------------------------------------------------------------------- // Revision-level validation // --------------------------------------------------------------------------- -/// Validates one catalog revision against the v1 contract (design §7). +/// 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, @@ -1095,61 +1231,30 @@ pub fn validate_catalog_revision( } for entry in &entries { - for variant in &entry.platform_variants { - for dependency in variant.dependencies.iter().flatten() { - if !by_id.contains_key(dependency.entry_id.as_str()) { - 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)); - } + 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)); } - } - for platform in Platform::ALL { - for architecture in Architecture::ALL { - let Some(selected) = select_variant(entry, platform, architecture) else { - continue; - }; - let nodes = - match dependency_closure(entry, selected, &by_id, platform, architecture) { - Ok(nodes) => nodes, - Err(failure) => { - return fail(format!( - "'{}' on {platform}/{architecture}: {} ({})", - entry.entry_id, failure.reason, failure.detail - )) - } - }; - if let Some(violation) = composition_violation(&nodes) { + 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!( - "'{}' on {platform}/{architecture}: {violation}", - entry.entry_id - )); - } - let classes: Vec<(&str, Vec)> = COMPOSABLE_FILESYSTEM_FIELDS - .iter() - .map(|field| { - let values = nodes - .iter() - .flat_map(|node| node.variant.sandbox_policy.filesystem_field(field)) - .map(str::to_string) - .collect(); - (*field, values) - }) - .collect(); - if let Some(overlap) = find_cross_class_overlap(&classes, platform) { - return fail(format!( - "'{}' on {platform}/{architecture}: {overlap}", - entry.entry_id + "'{}' requires '{}' with range '{range}', which does not use that entry's versionScheme '{}'", + entry.entry_id, dependency.entry_id, target.version_scheme )); } } } } + crate::effective::validate_materializations(&entries, &by_id, contract)?; + Ok(CatalogRevision { catalog_schema_version: contract.catalog_schema_version.clone(), catalog_revision, diff --git a/src/core/mxc_policy_store/src/compose.rs b/src/core/mxc_policy_store/src/compose.rs new file mode 100644 index 000000000..df01c46af --- /dev/null +++ b/src/core/mxc_policy_store/src/compose.rs @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Floor composition of selected components (design §4.5): every selected +//! base, its selected additions, and its dependencies combine into one +//! `SandboxPolicy` that preserves the access each requested pair needs. + +use crate::catalog::{Additions, CatalogPolicy}; +use crate::json::{canonical_json, cmp_utf16, Json, JsonObject}; +use crate::model::{FilesystemPolicy, Platform, SandboxPolicy}; +use crate::paths::{normalize_path, path_exact_segments, path_key_segments}; +use crate::text::symbol_matches; +use std::collections::HashSet; + +/// One selected (entry, base, additions) contribution. +#[derive(Clone, Debug)] +pub struct Component<'a> { + pub entry_id: &'a str, + pub base: &'a CatalogPolicy, + pub additions: Vec<&'a Additions>, +} + +impl Component<'_> { + fn has_additions(&self) -> bool { + self.additions.iter().any(|a| !a.is_empty()) + } + + fn needs_network(&self) -> bool { + self.base.field("network").is_some() + || self.additions.iter().any(|a| !a.egress_allow.is_empty()) + } + + /// Path templates of one access class, base first, then additions. + fn paths(&self, class: Class) -> Vec<&str> { + let mut out = self.base.filesystem_field(class.field()); + for additions in &self.additions { + let extra = match class { + Class::Denied => &[][..], + Class::Readonly => &additions.readonly_paths[..], + Class::Readwrite => &additions.readwrite_paths[..], + }; + out.extend(extra.iter().map(String::as_str)); + } + out + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class { + Denied, + Readonly, + Readwrite, +} + +impl Class { + const ALL: [Class; 3] = [Class::Denied, Class::Readonly, Class::Readwrite]; + + fn field(self) -> &'static str { + match self { + Class::Denied => "deniedPaths", + Class::Readonly => "readonlyPaths", + Class::Readwrite => "readwritePaths", + } + } + + fn label(self) -> &'static str { + match self { + Class::Denied => "denied", + Class::Readonly => "read-only", + Class::Readwrite => "read-write", + } + } +} + +/// The symbols the components reference, first-seen order, with the entries +/// that need each. +pub fn component_symbols(components: &[Component<'_>]) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for component in components { + for class in Class::ALL { + for template in component.paths(class) { + for (_, _, name) in symbol_matches(template) { + match out.iter_mut().find(|(n, _)| n == name) { + Some((_, ids)) => { + if !ids.iter().any(|id| id == component.entry_id) { + ids.push(component.entry_id.to_string()); + } + } + None => out.push((name.to_string(), vec![component.entry_id.to_string()])), + } + } + } + } + } + out +} + +/// Rejects combinations the v1 composition rules cannot express (design +/// §4.5), rather than approximating them with broader access. +pub fn compose_check(components: &[Component<'_>]) -> Result<(), String> { + let mut versions: Vec<&str> = Vec::new(); + for component in components { + let version = component.base.version(); + if !versions.contains(&version) { + versions.push(version); + } + } + if versions.len() > 1 { + versions.sort_by(|a, b| cmp_utf16(a, b)); + return Err(format!( + "mixed sandboxPolicy.version values ({})", + versions.join(", ") + )); + } + if is_passthrough(components) { + return Ok(()); + } + for component in components { + for key in component.base.raw().keys() { + if !matches!(key, "version" | "filesystem" | "network") { + return Err(format!( + "'{}' uses '{key}', which has no v1 cross-policy composition rule", + component.entry_id + )); + } + } + } + if verbatim_network(components).is_some() { + return Ok(()); + } + for component in components { + let Some(network) = component.base.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", + component.entry_id + )); + } + for (field, _) in value.as_object().into_iter().flat_map(|o| o.iter()) { + if !matches!(field, "default" | "allow") { + return Err(format!( + "'{}' uses 'network.egress.{field}', which cannot be combined with other network requirements", + component.entry_id + )); + } + } + } + } + Ok(()) +} + +/// A single selected policy without additions or dependencies keeps every +/// catalog-supported field without cross-policy composition. +fn is_passthrough(components: &[Component<'_>]) -> bool { + components.len() == 1 && !components[0].has_additions() +} + +/// The one component whose base network passes through unchanged: the only +/// component needing network, contributing no outbound additions. +fn verbatim_network<'c, 'a>(components: &'c [Component<'a>]) -> Option<&'c Component<'a>> { + let mut needing = components.iter().filter(|c| c.needs_network()); + let only = needing.next()?; + if needing.next().is_some() || only.additions.iter().any(|a| !a.egress_allow.is_empty()) { + return None; + } + Some(only) +} + +/// The composed policy and the composition diagnostics. +#[derive(Clone, Debug)] +pub struct Composed { + pub policy: SandboxPolicy, + pub warnings: Vec, +} + +struct PathItem { + path: String, + exact: Vec, + folded: Vec, + entry_ids: Vec, +} + +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(", ") +} + +/// Composes components that passed [`compose_check`]. `resolve` maps a path +/// template to its substituted value (identity for symbolic validation). +pub fn compose_policy( + components: &[Component<'_>], + resolve: &dyn Fn(&str) -> String, + platform: Platform, +) -> Composed { + let mut warnings = Vec::new(); + let mut classes: Vec> = Vec::new(); + for class in Class::ALL { + let mut items: Vec = Vec::new(); + for component in components { + for template in component.paths(class) { + let path = normalize_path(&resolve(template), platform); + let exact = path_exact_segments(&path, platform); + match items.iter_mut().find(|i| i.exact == exact) { + Some(item) => { + if !item.entry_ids.iter().any(|id| id == component.entry_id) { + item.entry_ids.push(component.entry_id.to_string()); + } + } + None => items.push(PathItem { + folded: path_key_segments(&path, Platform::Windows), + path, + exact, + entry_ids: vec![component.entry_id.to_string()], + }), + } + } + } + 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<(Class, &PathItem)> = Class::ALL + .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(format!( + "{} '{}' ({}) and {} '{}' ({}) differ only by case; filesystem case sensitivity was not determined, so they were compared case-sensitively and kept distinct", + left_class.label(), + left.path, + ids(left), + right_class.label(), + right.path, + ids(right) + )); + } + } + } + + let [denied, readonly, readwrite] = <[Vec; 3]>::try_from(classes) + .unwrap_or_else(|_| unreachable!("three access classes")); + + let readonly: Vec = readonly + .into_iter() + .filter(|ro| match readwrite.iter().find(|rw| is_within(&ro.exact, &rw.exact)) { + Some(rw) => { + warnings.push(format!( + "read-only '{}' ({}) is covered by read-write '{}' ({}); the read-only entry was omitted", + ro.path, + ids(ro), + rw.path, + ids(rw) + )); + false + } + None => true, + }) + .collect(); + + let denied: Vec = denied + .into_iter() + .filter(|deny| { + let grant = readwrite + .iter() + .map(|g| (Class::Readwrite, g)) + .chain(readonly.iter().map(|g| (Class::Readonly, g))) + .find(|(_, g)| is_same_or_nested(&deny.exact, &g.exact)); + match grant { + Some((class, g)) => { + warnings.push(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), + class.label(), + g.path, + ids(g), + deny.path + )); + false + } + None => true, + } + }) + .collect(); + + let has_filesystem = components.iter().any(|c| { + c.base.has_filesystem() + || c.additions + .iter() + .any(|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(|| FilesystemPolicy { + denied_paths: pick(denied), + readonly_paths: pick(readonly), + readwrite_paths: pick(readwrite), + }); + + let root = components[0].base; + let passthrough = is_passthrough(components); + let network = if passthrough { + root.field("network").cloned() + } else if let Some(only) = verbatim_network(components) { + only.base.field("network").cloned() + } else { + compose_network(components) + }; + Composed { + policy: SandboxPolicy { + version: root.version().to_string(), + filesystem, + network, + ui: passthrough.then(|| root.field("ui").cloned()).flatten(), + timeout_ms: passthrough + .then(|| root.field("timeoutMs").and_then(Json::as_f64)) + .flatten(), + }, + warnings, + } +} + +/// Unions the outbound allow rules under a deny-by-default egress. +fn compose_network(components: &[Component<'_>]) -> Option { + let mut seen = HashSet::new(); + let mut allow = Vec::new(); + let mut any = false; + for component in components { + if let Some(network) = component.base.field("network") { + any = true; + let rules = network + .get("egress") + .and_then(|e| e.get("allow")) + .and_then(Json::as_array); + for rule in rules.into_iter().flatten() { + if seen.insert(canonical_json(rule)) { + allow.push(rule.clone()); + } + } + } + for additions in &component.additions { + for rule in &additions.egress_allow { + any = true; + if seen.insert(canonical_json(rule)) { + allow.push(rule.clone()); + } + } + } + } + if !any { + return None; + } + let mut egress = JsonObject::new(); + egress.insert("default", "deny".into()); + if !allow.is_empty() { + egress.insert("allow", Json::Array(allow)); + } + let mut network = JsonObject::new(); + network.insert("egress", Json::Object(egress)); + Some(Json::Object(network)) +} diff --git a/src/core/mxc_policy_store/src/effective.rs b/src/core/mxc_policy_store/src/effective.rs new file mode 100644 index 000000000..ad35637b9 --- /dev/null +++ b/src/core/mxc_policy_store/src/effective.rs @@ -0,0 +1,562 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Effective policies (design §4.3–§4.4): the default plus the selected +//! platform overlay, at most one version variant, and the selected intents; +//! dependency closure; and the build-time materialization of every effective +//! policy the catalog can produce. + +use crate::catalog::{ + fail, validate_sandbox_policy, Additions, CatalogContract, CatalogEntry, Dependency, + EntryIndex, IntentDefinition, Overlay, PlatformVariant, VersionVariant, +}; +use crate::compose::{compose_check, compose_policy, Component}; +use crate::errors::{invalid_catalog, Result}; +use crate::json::{cmp_utf16, Json}; +use crate::model::{ + Architecture, DependencyRecord, IntentMode, IntentSelection, Platform, SandboxPolicy, + VersionSelection, +}; +use std::collections::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 a neutral +/// selection is a fallback worth reporting). +pub fn has_arch_specific(entry: &CatalogEntry, platform: Platform) -> bool { + entry + .platform_variants + .iter() + .any(|v| v.platform == platform && v.architecture.is_some()) +} + +#[derive(Clone, Debug)] +pub struct EffectiveIntent<'a> { + pub name: String, + pub additions: Vec<&'a Additions>, + pub dependencies: Vec<&'a Dependency>, +} + +/// One effective policy before intent selection. +#[derive(Clone, Debug)] +pub struct Effective<'a> { + pub entry: &'a CatalogEntry, + pub base_additions: Vec<&'a Additions>, + pub base_dependencies: Vec<&'a Dependency>, + /// 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 (used to compare a default with a new intent). + BaseOnly, +} + +/// The selected base plus intents of one effective policy. +#[derive(Clone, Debug)] +pub struct Selected<'a> { + pub additions: Vec<&'a Additions>, + pub dependencies: Vec<&'a Dependency>, + pub intent_names: Vec, +} + +fn add_intents<'a>( + intents: &mut Vec>, + list: &'a [(String, IntentDefinition)], + extend: bool, + origin: &str, +) -> std::result::Result<(), String> { + for (name, definition) in list { + match intents.iter_mut().find(|i| &i.name == name) { + Some(existing) if extend => { + existing.additions.push(&definition.additions); + existing.dependencies.extend(&definition.dependencies); + } + 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(), + additions: vec![&definition.additions], + dependencies: definition.dependencies.iter().collect(), + }), + } + } + 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_additions: Vec::new(), + base_dependencies: entry.default.dependencies.iter().collect(), + intents: Vec::new(), + }; + add_intents( + &mut effective.intents, + &entry.default.intents, + false, + "the default", + )?; + let overlays: [(Option<&'a Overlay>, String); 2] = [ + ( + platform.map(|p| &p.overlay), + platform.map_or_else(String::new, |p| { + format!( + "the {}/{} overlay", + p.platform, + p.architecture.map_or("*", Architecture::as_str) + ) + }), + ), + ( + version.map(|v| &v.overlay), + version.map_or_else(String::new, |v| { + format!("version range '{}'", v.version_range) + }), + ), + ]; + for (overlay, origin) in overlays { + let Some(overlay) = overlay else { continue }; + effective.base_additions.push(&overlay.policy_additions); + effective.base_dependencies.extend(&overlay.dependencies); + add_intents( + &mut effective.intents, + &overlay.intent_additions, + true, + &origin, + )?; + add_intents(&mut effective.intents, &overlay.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)?], + }; + let mut additions = self.base_additions.clone(); + let mut dependencies = self.base_dependencies.clone(); + for intent in &chosen { + additions.extend(&intent.additions); + dependencies.extend(&intent.dependencies); + } + Some(Selected { + additions, + dependencies, + intent_names: chosen.iter().map(|i| i.name.clone()).collect(), + }) + } +} + +/// A node of the composed set: the component and how its platform overlay +/// was selected. +#[derive(Clone, Debug)] +pub struct Node<'a> { + pub component: Component<'a>, + /// Neutral fallback on a platform with architecture-specific overlays. + pub neutral_fallback: Option, +} + +/// Accumulates the dependency closure of every contribution in one lookup. +pub struct Closure<'a, 'm> { + pub by_id: &'m EntryIndex<'a>, + pub platform: Platform, + pub architecture: &'m dyn Fn() -> Result, + pub nodes: Vec>, + pub records: Vec, + done: HashSet<&'a str>, +} + +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, + nodes: Vec::new(), + records: Vec::new(), + done: HashSet::new(), + } + } + + /// Adds the dependencies of a contribution of `root`, transitively. + pub fn add_dependencies( + &mut self, + root: &'a CatalogEntry, + dependencies: &[&'a Dependency], + ) -> Result<()> { + let mut stack = vec![root.entry_id.as_str()]; + self.visit_all(dependencies, &mut stack) + } + + fn visit_all( + &mut self, + dependencies: &[&'a Dependency], + 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 selected = effective + .select(IntentChoice::All) + .expect("all intents always select"); + let record = DependencyRecord { + entry_id: target.entry_id.clone(), + entry_revision: target.entry_revision, + required_version_range: dependency.version_range.clone(), + version_selection: VersionSelection::default_match(), + intent_selection: IntentSelection { + requested: None, + mode: IntentMode::All, + selected: selected.intent_names.clone(), + }, + }; + if !self.records.contains(&record) { + self.records.push(record); + } + if !self.done.insert(&target.entry_id) { + continue; + } + self.nodes.push(Node { + component: Component { + entry_id: &target.entry_id, + base: &target.default.sandbox_policy, + additions: selected.additions, + }, + neutral_fallback: selection + .filter(|s| !s.exact && has_arch_specific(target, self.platform)) + .map(|_| self.platform), + }); + stack.push(&target.entry_id); + self.visit_all(&selected.dependencies, 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 §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 policy: SandboxPolicy, + pub dependency_entry_ids: Vec, + pub intent_names: Vec, +} + +fn compose_symbolic( + entry: &CatalogEntry, + selected: &Selected<'_>, + by_id: &EntryIndex<'_>, + platform: Platform, + architecture: Architecture, +) -> std::result::Result<(SandboxPolicy, Vec), String> { + let arch = move || Ok(architecture); + let mut closure = Closure::new(by_id, platform, &arch); + closure + .add_dependencies(entry, &selected.dependencies) + .map_err(|e| e.detail().to_string())?; + let mut components = vec![Component { + entry_id: &entry.entry_id, + base: &entry.default.sandbox_policy, + additions: selected.additions.clone(), + }]; + components.extend(closure.nodes.iter().map(|n| n.component.clone())); + compose_check(&components)?; + let composed = compose_policy(&components, &|t: &str| t.to_string(), platform); + 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.policy, deps)) +} + +fn rule_keys(policy: &SandboxPolicy) -> HashSet { + policy + .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::json::canonical_json) + .collect() +} + +fn fs_paths( + policy: &SandboxPolicy, + field: fn(&crate::model::FilesystemPolicy) -> &Option>, +) -> Vec { + policy + .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: &SandboxPolicy, + default_deps: &[String], + effective: &SandboxPolicy, + effective_deps: &[String], + platform: Platform, +) -> Option { + use crate::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 composed policy, 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 (policy, deps) = + compose_symbolic(entry, &selected, by_id, platform, architecture) + .map_err(|e| invalid_catalog(format!("{at}: {e}")))?; + if let Err(e) = + validate_sandbox_policy(Some(&policy.to_json()), "composed", contract) + { + return fail(format!("{at}: composed policy is 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_policy, 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_policy, &default_deps, &policy, &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(), + policy, + dependency_entry_ids: deps, + intent_names: selected.intent_names, + }); + } + } + } + } + Ok(out) +} + +/// Revision-level materialization check, called from catalog validation. +pub(crate) fn validate_materializations( + entries: &[CatalogEntry], + by_id: &EntryIndex<'_>, + contract: &CatalogContract, +) -> Result<()> { + for entry in entries { + materialize_entry(entry, by_id, contract)?; + } + Ok(()) +} diff --git a/src/core/mxc_policy_store/src/errors.rs b/src/core/mxc_policy_store/src/errors.rs index 17caf4cda..fe16d9bc0 100644 --- a/src/core/mxc_policy_store/src/errors.rs +++ b/src/core/mxc_policy_store/src/errors.rs @@ -41,6 +41,7 @@ impl fmt::Display for ErrorCode { pub enum ErrorReason { InvalidCatalog, CompositionConflict, + AmbiguousMatch, InvalidContext, UnsupportedHost, Integrity, @@ -48,9 +49,10 @@ pub enum ErrorReason { } impl ErrorReason { - pub const ALL: [ErrorReason; 6] = [ + pub const ALL: [ErrorReason; 7] = [ ErrorReason::InvalidCatalog, ErrorReason::CompositionConflict, + ErrorReason::AmbiguousMatch, ErrorReason::InvalidContext, ErrorReason::UnsupportedHost, ErrorReason::Integrity, @@ -61,6 +63,7 @@ impl ErrorReason { 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", @@ -71,9 +74,9 @@ impl ErrorReason { /// The one code each reason maps to (`ERROR_CODE_FOR_REASON`). pub fn code(self) -> ErrorCode { match self { - ErrorReason::InvalidCatalog | ErrorReason::CompositionConflict => { - ErrorCode::PolicyValidation - } + ErrorReason::InvalidCatalog + | ErrorReason::CompositionConflict + | ErrorReason::AmbiguousMatch => ErrorCode::PolicyValidation, ErrorReason::InvalidContext => ErrorCode::MalformedRequest, ErrorReason::UnsupportedHost => ErrorCode::UnsupportedContainment, ErrorReason::Integrity | ErrorReason::RevisionUnavailable => ErrorCode::BackendError, @@ -114,6 +117,13 @@ impl PolicyCatalogError { 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 { @@ -147,6 +157,7 @@ mod tests { assert_eq!( codes, [ + "policy_validation", "policy_validation", "policy_validation", "malformed_request", diff --git a/src/core/mxc_policy_store/src/host.rs b/src/core/mxc_policy_store/src/host.rs index a81c8115d..763496d6d 100644 --- a/src/core/mxc_policy_store/src/host.rs +++ b/src/core/mxc_policy_store/src/host.rs @@ -253,6 +253,9 @@ impl HostEnvironment for SystemHost { match name { "user_home" => home_dir(), "temp_dir" => temp_dir(), + "programData" if cfg!(windows) => { + std::env::var("ProgramData").ok().filter(|v| !v.is_empty()) + } _ => None, } } diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/core/mxc_policy_store/src/lib.rs index 9cf217938..955efc857 100644 --- a/src/core/mxc_policy_store/src/lib.rs +++ b/src/core/mxc_policy_store/src/lib.rs @@ -6,9 +6,8 @@ //! bundled statically in this crate. //! //! The API names, shapes, and catalog contents are proposed and may change -//! before sign-off (for example, the names may drop "Sandbox", and lookup may -//! gain an intent such as `git pull` versus `git push`). The feature is not -//! part of MXC 1.0. +//! before sign-off (for example, the names may drop "Sandbox"). The feature +//! is not part of MXC 1.0. //! //! The runtime lookup API ([`resolve_sandbox_policy`], //! [`resolve_sandbox_policy_with_diagnostics`], [`get_catalog_info`], @@ -17,7 +16,7 @@ //! callers compose with their own policy. 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 integrity-checked on first use; nothing is downloaded. +//! compiled in and validated on first use; nothing is downloaded. //! //! Most callers reach this crate through an MXC SDK: `mxc_sdk::policy_store` //! in Rust, and the Node and C# SDKs through `mxc_ffi`. [`tooling`] exposes @@ -25,6 +24,8 @@ //! paths) that the crate's own tests enforce. pub mod catalog; +pub mod compose; +pub mod effective; pub mod errors; pub mod history; pub mod host; @@ -38,15 +39,18 @@ pub mod store; mod text; pub mod validate; pub mod vers; -pub mod version_range; +pub mod view; pub use errors::{ErrorCode, ErrorReason, PolicyCatalogError}; pub use host::{FixedHost, HostEnvironment, SystemHost}; pub use model::{ - Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, - Diagnostics, EntryMatchRecord, FilesystemPolicy, IdentityStrength, MatchedIdentity, Platform, - PlatformVariantMetadata, Provenance, ResolveContext, SandboxConfigResolution, SandboxPolicy, - SymbolMap, ToolCandidate, ToolInput, ToolInputs, ToolRecord, + Architecture, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, + CatalogInfo, CatalogIntentMetadata, DefaultMetadata, DependencyRecord, Diagnostics, + EntryMatchRecord, FilesystemPolicy, IdentityStrength, IntentMode, IntentSelection, + MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, ResolveContext, + SandboxConfigResolution, SandboxPolicy, SymbolMap, ToolCandidate, ToolInput, ToolInputs, + ToolRecord, ToolResolutionStatus, ToolResolutionWarning, ToolWarningCode, VersionSelection, + VersionStatus, VersionVariantMetadata, Warning, }; pub use resolver::{ get_catalog_info, list_catalog_entries, resolve_sandbox_policy, @@ -60,9 +64,14 @@ pub use store::{ /// Contribution and CI tooling (not part of the runtime lookup API). pub mod tooling { pub use crate::catalog::{ - compare_catalog_revisions, select_variant, validate_catalog_revision, validate_contract, - CatalogContract, CatalogEntry, CatalogRevision, IdentityPredicate, PlatformVariant, - SymbolSource, + compare_catalog_revisions, validate_catalog_revision, validate_contract, Additions, + CatalogContract, CatalogEntry, CatalogRevision, Dependency, EntryDefault, + IdentityPredicate, IntentDefinition, Overlay, PlatformVariant, SymbolSource, + VersionVariant, + }; + pub use crate::effective::{ + materialize, materialize_entry, select_platform_variant, Effective, IntentChoice, + Materialized, PlatformSelection, }; pub use crate::history::{ check_entry_revisions, check_published_immutability, check_store_history, @@ -79,7 +88,7 @@ pub mod tooling { check_against_base_ref, read_published_state_at_ref, validate_bundled_catalog, validate_catalog_directory, CatalogValidationReport, }; - pub use crate::version_range::{is_valid_version_range, satisfies_version_range}; + pub use crate::view::render_reviewer_view; } pub use vers::{VersRange, Version, VersionScheme}; diff --git a/src/core/mxc_policy_store/src/model.rs b/src/core/mxc_policy_store/src/model.rs index b6b02154b..8ea2c1aa0 100644 --- a/src/core/mxc_policy_store/src/model.rs +++ b/src/core/mxc_policy_store/src/model.rs @@ -7,6 +7,7 @@ //! omitted rather than written as `null`. use crate::json::{Json, JsonObject}; +use crate::vers::VersionScheme; use std::fmt; /// Catalog platform selector (design §4.4). @@ -69,12 +70,17 @@ impl fmt::Display for Architecture { } } -/// Runtime lookup input (design §5.1). +/// Runtime lookup input (design §5.1). `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 { @@ -94,6 +100,11 @@ impl ToolCandidate { self.detected_version = Some(value.into()); self } + + pub fn with_intent(mut self, value: impl Into) -> Self { + self.intent = Some(value.into()); + self + } } /// A bare name is shorthand for `ToolCandidate { invocation_name }`. @@ -279,10 +290,10 @@ impl ResolveContext { } /// Strength of a matched identity predicate. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum IdentityStrength { - Strong, Weak, + Strong, } impl IdentityStrength { @@ -302,7 +313,7 @@ pub struct FilesystemPolicy { pub readwrite_paths: Option>, } -fn strings(values: &[String]) -> Json { +pub(crate) fn strings(values: &[String]) -> Json { Json::Array(values.iter().map(|v| Json::String(v.clone())).collect()) } @@ -323,8 +334,7 @@ impl FilesystemPolicy { } /// The catalog-supported subset of MXC's `SandboxPolicy`, structurally -/// compatible with the MXC SDK type. `network` and `ui` are carried as the -/// reviewed JSON from the catalog. +/// compatible with the MXC SDK type. `network` and `ui` are carried as JSON. #[derive(Clone, Debug, PartialEq)] pub struct SandboxPolicy { pub version: String, @@ -354,6 +364,117 @@ impl SandboxPolicy { } } +/// 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, +} + +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", + } + } +} + +#[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, + Unsupported, +} + +impl IntentMode { + pub fn as_str(self) -> &'static str { + match self { + IntentMode::Named => "named", + IntentMode::All => "all", + 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, @@ -365,11 +486,16 @@ pub struct EntryMatchRecord { 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, + pub status: ToolResolutionStatus, + /// At most one entry; empty for `tool_unmatched`. pub matches: Vec, } @@ -378,6 +504,84 @@ pub struct DependencyRecord { pub entry_id: String, pub entry_revision: f64, pub required_version_range: Option, + pub version_selection: VersionSelection, + pub intent_selection: IntentSelection, +} + +/// Code of a structured per-input warning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolWarningCode { + VersionOutOfRange, + VersionUnparseable, + IntentUnsupported, + ToolUnmatched, +} + +impl ToolWarningCode { + pub fn as_str(self) -> &'static str { + match self { + ToolWarningCode::VersionOutOfRange => "version_out_of_range", + ToolWarningCode::VersionUnparseable => "version_unparseable", + ToolWarningCode::IntentUnsupported => "intent_unsupported", + ToolWarningCode::ToolUnmatched => "tool_unmatched", + } + } +} + +/// A structured per-input warning (design §5.1 `ToolResolutionWarning`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolResolutionWarning { + pub code: ToolWarningCode, + pub input_index: usize, + pub entry_id: Option, + pub detected_version: Option, + pub intent: Option, + pub message: String, +} + +/// A diagnostics warning: a structured per-input warning or free text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Warning { + Text(String), + Tool(ToolResolutionWarning), +} + +impl Warning { + /// The human-readable message. + pub fn message(&self) -> &str { + match self { + Warning::Text(text) => text, + Warning::Tool(w) => &w.message, + } + } + + pub fn to_json(&self) -> Json { + match self { + Warning::Text(text) => Json::String(text.clone()), + Warning::Tool(w) => { + let mut o = JsonObject::new(); + o.insert("code", w.code.as_str().into()); + o.insert("inputIndex", Json::Number(w.input_index as f64)); + if let Some(v) = &w.entry_id { + o.insert("entryId", v.as_str().into()); + } + if let Some(v) = &w.detected_version { + o.insert("detectedVersion", v.as_str().into()); + } + if let Some(v) = &w.intent { + o.insert("intent", v.as_str().into()); + } + o.insert("message", w.message.as_str().into()); + Json::Object(o) + } + } + } +} + +impl From for Warning { + fn from(value: String) -> Self { + Warning::Text(value) + } } #[derive(Clone, Debug, PartialEq)] @@ -385,7 +589,7 @@ pub struct Diagnostics { pub catalog_revision: String, pub tools: Vec, pub resolved_dependencies: Vec, - pub warnings: Vec, + pub warnings: Vec, } impl Diagnostics { @@ -401,6 +605,7 @@ impl Diagnostics { .map(|tool| { let mut record = JsonObject::new(); record.insert("inputIndex", Json::Number(tool.input_index as f64)); + record.insert("status", tool.status.as_str().into()); let matches = tool .matches .iter() @@ -419,6 +624,10 @@ impl Diagnostics { }) .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()); + } Json::Object(o) }) .collect(); @@ -437,11 +646,16 @@ impl Diagnostics { 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", strings(&self.warnings)); + object.insert( + "warnings", + Json::Array(self.warnings.iter().map(Warning::to_json).collect()), + ); Json::Object(object) } } @@ -468,28 +682,17 @@ impl SandboxConfigResolution { /// Inspection metadata for one identity predicate (design §5.2). #[derive(Clone, Debug, PartialEq, Eq)] pub enum CatalogIdentityMetadata { - Purl { - value: String, - version_range: Option, - }, - InvocationName { - names: Vec, - }, + Purl { value: String }, + InvocationName { names: Vec }, } impl CatalogIdentityMetadata { pub fn to_json(&self) -> Json { let mut o = JsonObject::new(); match self { - CatalogIdentityMetadata::Purl { - value, - version_range, - } => { + CatalogIdentityMetadata::Purl { value } => { o.insert("kind", "purl".into()); o.insert("value", value.as_str().into()); - if let Some(range) = version_range { - o.insert("versionRange", range.as_str().into()); - } } CatalogIdentityMetadata::InvocationName { names } => { o.insert("kind", "invocation-name".into()); @@ -500,12 +703,64 @@ impl CatalogIdentityMetadata { } } +/// 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 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("intents", intents_json(&self.intents)); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DefaultMetadata { + pub dependency_entry_ids: Vec, + pub sandbox_policy_version: String, + pub intents: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PlatformVariantMetadata { pub platform: Platform, pub architecture: Option, - pub dependency_entry_ids: Vec, - pub sandbox_policy_version: String, + pub additions: CatalogAdditionsMetadata, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VersionVariantMetadata { + pub version_range: String, + pub additions: CatalogAdditionsMetadata, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -530,8 +785,11 @@ pub struct CatalogEntryMetadata { 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, } @@ -542,11 +800,23 @@ impl CatalogEntryMetadata { 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 variants = self + let mut default = JsonObject::new(); + default.insert( + "dependencyEntryIds", + strings(&self.default.dependency_entry_ids), + ); + default.insert( + "sandboxPolicyVersion", + self.default.sandbox_policy_version.as_str().into(), + ); + default.insert("intents", intents_json(&self.default.intents)); + o.insert("default", Json::Object(default)); + let platform_variants = self .platform_variants .iter() .map(|v| { @@ -555,15 +825,22 @@ impl CatalogEntryMetadata { if let Some(arch) = v.architecture { vo.insert("architecture", arch.as_str().into()); } - vo.insert("dependencyEntryIds", strings(&v.dependency_entry_ids)); - vo.insert( - "sandboxPolicyVersion", - v.sandbox_policy_version.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("platformVariants", Json::Array(variants)); + o.insert("versionVariants", Json::Array(version_variants)); o.insert("provenance", self.provenance.to_json()); Json::Object(o) } diff --git a/src/core/mxc_policy_store/src/paths.rs b/src/core/mxc_policy_store/src/paths.rs index 79ff42587..85f7d402d 100644 --- a/src/core/mxc_policy_store/src/paths.rs +++ b/src/core/mxc_policy_store/src/paths.rs @@ -465,6 +465,16 @@ pub fn is_absolute_path(value: &str, platform: Platform) -> bool { /// 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 { @@ -499,7 +509,13 @@ pub fn path_key_segments(value: &str, platform: Platform) -> Vec { }; segments .iter() - .map(|segment| case_key(segment, platform)) + .map(|segment| { + if fold { + case_key(segment, platform) + } else { + segment.to_string() + } + }) .collect() } diff --git a/src/core/mxc_policy_store/src/request.rs b/src/core/mxc_policy_store/src/request.rs index a08f67593..b338ee02e 100644 --- a/src/core/mxc_policy_store/src/request.rs +++ b/src/core/mxc_policy_store/src/request.rs @@ -5,7 +5,7 @@ //! `mxc_ffi`: `{"tools": , "context"?: {...}}`. //! //! A tool input is a bare invocation name or -//! `{"invocationName", "packageUrl"?, "detectedVersion"?}`. The context keys +//! `{"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. @@ -38,7 +38,7 @@ fn tool_input(value: &Json, index: usize) -> Result { Json::Object(object) => { only_keys( object, - &["invocationName", "packageUrl", "detectedVersion"], + &["invocationName", "packageUrl", "detectedVersion", "intent"], &at, )?; let Some(invocation_name) = optional_string(object, "invocationName", &at)? else { @@ -48,6 +48,7 @@ fn tool_input(value: &Json, index: usize) -> Result { 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!( @@ -149,7 +150,7 @@ mod tests { #[test] fn parses_a_full_request() { let (tools, ctx) = parse_resolve_request( - r#"{"tools":["git",{"invocationName":"npm","packageUrl":"pkg:npm/npm","detectedVersion":null}], + r#"{"tools":["git",{"invocationName":"npm","packageUrl":"pkg:npm/npm","detectedVersion":null,"intent":"install"}], "context":{"platform":"linux","allowWeakIdentityFallback":true,"symbols":{"a":"/x"},"projectRoot":null}}"#, ) .unwrap(); @@ -157,7 +158,11 @@ mod tests { 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")) + 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); @@ -182,6 +187,7 @@ mod tests { 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}}}"#, diff --git a/src/core/mxc_policy_store/src/resolver.rs b/src/core/mxc_policy_store/src/resolver.rs index 82468fabc..123c52519 100644 --- a/src/core/mxc_policy_store/src/resolver.rs +++ b/src/core/mxc_policy_store/src/resolver.rs @@ -1,37 +1,37 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Runtime lookup and inspection. +//! Runtime lookup and inspection (design §4.3–§5.2): 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::catalog::{ - composition_violation, dependency_closure, entry_index, find_cross_class_overlap, - policy_symbols, select_variant, CatalogEntry, ClosureNode, IdentityPredicate, SymbolSource, - VariantSelection, COMPOSABLE_FILESYSTEM_FIELDS, + entry_index, CatalogEntry, Dependency, IdentityPredicate, IntentDefinition, Overlay, + SymbolSource, }; -use crate::errors::{invalid_context, ErrorReason, PolicyCatalogError, Result}; +use crate::compose::{component_symbols, compose_check, compose_policy, Component}; +use crate::effective::{ + has_arch_specific, materialize, select_platform_variant, Closure, IntentChoice, Node, +}; +use crate::errors::{invalid_catalog, invalid_context, ErrorReason, PolicyCatalogError, Result}; use crate::host::{HostEnvironment, SystemHost}; use crate::json::cmp_utf16; use crate::model::{ - Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, - Diagnostics, EntryMatchRecord, FilesystemPolicy, MatchedIdentity, Platform, - PlatformVariantMetadata, Provenance, ResolveContext, SandboxConfigResolution, SandboxPolicy, - ToolCandidate, ToolInputs, ToolRecord, + Architecture, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, + CatalogInfo, CatalogIntentMetadata, DefaultMetadata, Diagnostics, EntryMatchRecord, IntentMode, + IntentSelection, MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, + ResolveContext, SandboxConfigResolution, SandboxPolicy, ToolCandidate, ToolInputs, ToolRecord, + ToolResolutionStatus, ToolResolutionWarning, ToolWarningCode, VersionSelection, VersionStatus, + VersionVariantMetadata, Warning, }; -use crate::paths::{case_key, is_absolute_path, normalize_path, path_key_segments}; +use crate::paths::{case_key, is_absolute_path}; use crate::purl::{parse_purl, ParsedPurl}; use crate::store::{bundled_catalog_store, CatalogStore}; use crate::text::replace_symbols; -use crate::version_range::satisfies_version_range; use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; -struct EntryMatch<'a> { - entry: &'a CatalogEntry, - selection: VariantSelection<'a>, - satisfied: Vec<&'a IdentityPredicate>, -} - fn validate_candidate(candidate: &ToolCandidate, index: usize) -> Result<()> { if candidate.invocation_name.is_empty() { return Err(invalid_context(format!( @@ -46,6 +46,7 @@ fn validate_candidate(candidate: &ToolCandidate, index: usize) -> Result<()> { 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!( @@ -60,6 +61,52 @@ fn describe_input(index: usize, tool: &ToolCandidate) -> String { format!("input {index} ('{}')", tool.invocation_name) } +fn tool_warning( + code: ToolWarningCode, + input_index: usize, + entry_id: Option<&str>, + tool: &ToolCandidate, + message: String, +) -> Warning { + Warning::Tool(ToolResolutionWarning { + code, + input_index, + entry_id: entry_id.map(str::to_string), + detected_version: tool.detected_version.clone(), + intent: tool.intent.clone(), + 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), + intents: intent_metadata(&overlay.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, @@ -129,16 +176,13 @@ impl PolicyCatalog { 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, - version_range, - } => CatalogIdentityMetadata::Purl { + IdentityPredicate::Purl { value } => CatalogIdentityMetadata::Purl { value: value.clone(), - version_range: version_range.clone(), }, IdentityPredicate::InvocationName { names } => { CatalogIdentityMetadata::InvocationName { @@ -147,19 +191,26 @@ impl PolicyCatalog { } }) .collect(), + default: DefaultMetadata { + dependency_entry_ids: dependency_ids(&entry.default.dependencies), + sandbox_policy_version: entry.default.sandbox_policy.version().to_string(), + intents: intent_metadata(&entry.default.intents), + }, platform_variants: entry .platform_variants .iter() .map(|variant| PlatformVariantMetadata { platform: variant.platform, architecture: variant.architecture, - dependency_entry_ids: variant - .dependencies - .iter() - .flatten() - .map(|d| d.entry_id.clone()) - .collect(), - sandbox_policy_version: variant.sandbox_policy.version().to_string(), + 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 { @@ -185,7 +236,7 @@ impl PolicyCatalog { /// Resolves one tool or a list of tools in one pass: the composed /// candidate policy with attribution and warnings. Library failures are - /// errors, never absence. The result is a candidate lower bound, not + /// errors, never absence. The result is a best-effort floor, not /// authorization. pub fn resolve_sandbox_policy_with_diagnostics( &self, @@ -207,83 +258,232 @@ impl PolicyCatalog { None => self.host.platform()?, }; let allow_weak = ctx.allow_weak_identity_fallback; - let architecture = LazyArchitecture { + 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 warnings: Vec = Vec::new(); let mut tool_records = Vec::new(); - let mut selected: Vec> = Vec::new(); - let mut selected_ids: HashSet<&str> = HashSet::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 roots: Vec> = Vec::new(); + let mut contributed: HashSet<(String, String, Option, String)> = HashSet::new(); + let mut closure = Closure::new(&by_id, platform, &architecture); + for (input_index, tool) in candidates.iter().enumerate() { - let matches = self.match_tool( + let purl = self.parse_input_purl(tool, input_index, &mut warnings)?; + let choice = match self.match_tool( &ordered, tool, + purl.as_ref(), input_index, platform, allow_weak, &architecture, - &mut warnings, - )?; - tool_records.push(ToolRecord { - input_index, - matches: matches - .iter() - .map(|m| EntryMatchRecord { - entry_id: m.entry.entry_id.clone(), - entry_revision: m.entry.entry_revision, - matched_identities: m - .satisfied - .iter() - .map(|p| MatchedIdentity { - kind: p.kind().to_string(), - strength: p.strength(), - }) - .collect(), - }) - .collect(), - }); - if matches.len() > 1 { - let ids: Vec<&str> = matches.iter().map(|m| m.entry.entry_id.as_str()).collect(); - warnings.push(format!( - "{} matched {} entries ({}); all contribute", + )? { + Ok(choice) => choice, + Err(skipped) => { + let suffix = if skipped.is_empty() { + String::new() + } else { + format!(": {}", skipped.join("; ")) + }; + warnings.push(tool_warning( + ToolWarningCode::ToolUnmatched, + input_index, + None, + tool, + format!( + "{} matched no eligible catalog entry{suffix}", + describe_input(input_index, tool) + ), + )); + tool_records.push(ToolRecord { + input_index, + status: ToolResolutionStatus::ToolUnmatched, + matches: Vec::new(), + }); + continue; + } + }; + let entry = choice.entry; + if !choice.strong { + warnings.push(Warning::Text(format!( + "{} matched {} only by invocation name (weak identity)", describe_input(input_index, tool), - matches.len(), - ids.join(", ") - )); + entry.entry_id + ))); } - for m in &matches { - let nodes = - dependency_closure(m.entry, m.selection, &by_id, platform, architecture.get()?) - .map_err(|f| { - PolicyCatalogError::new( - ErrorReason::InvalidCatalog, - format!( - "dependency resolution failed: {} ({})", - f.reason, f.detail - ), - ) - })?; - for node in nodes { - if selected_ids.insert(node.entry.entry_id.as_str()) { - selected.push(node); + let matched_identities: Vec = choice + .satisfied + .iter() + .map(|p| MatchedIdentity { + kind: p.kind().to_string(), + strength: p.strength(), + }) + .collect(); + let mut record = EntryMatchRecord { + 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( + ToolWarningCode::VersionUnparseable, + input_index, + Some(&entry.entry_id), + tool, + 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, + status: ToolResolutionStatus::Version( + VersionStatus::VersionUnparseable, + ), + matches: vec![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( + ToolWarningCode::VersionOutOfRange, + input_index, + Some(&entry.entry_id), + tool, + 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( + ToolWarningCode::IntentUnsupported, + input_index, + Some(&entry.entry_id), + tool, + 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, + status: ToolResolutionStatus::IntentUnsupported, + matches: vec![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); + tool_records.push(ToolRecord { + input_index, + status, + matches: vec![record], + }); + + let key = ( + entry.entry_id.clone(), + version_variant + .map(|v| v.version_range.as_str().to_string()) + .unwrap_or_default(), + selection.map(|s| s.index), + tool.intent.clone().unwrap_or_else(|| "*".to_string()), + ); + if contributed.insert(key) { + roots.push(Node { + component: Component { + entry_id: &entry.entry_id, + base: &entry.default.sandbox_policy, + additions: selected.additions.clone(), + }, + neutral_fallback: selection + .filter(|s| !s.exact && has_arch_specific(entry, platform)) + .map(|_| platform), + }); + closure.add_dependencies(entry, &selected.dependencies)?; + } } + let resolved_dependencies = closure.sorted_records(); + let mut nodes = roots; + nodes.extend(closure.nodes); let mut diagnostics = Diagnostics { catalog_revision: revision.catalog_revision.clone(), tools: tool_records, - resolved_dependencies: dependency_records(&selected, &by_id), + resolved_dependencies, warnings: Vec::new(), }; - if selected.is_empty() { + if nodes.is_empty() { diagnostics.warnings = warnings; return Ok(SandboxConfigResolution { policy: None, @@ -292,32 +492,45 @@ impl PolicyCatalog { } if ctx_architecture.is_none() { - warnings.push(format!( - "architecture was not specified; variants were selected for the native system architecture '{}'; the tool's architecture was not verified", - architecture.get()? - )); + if let Some(native) = lazy.value.get() { + warnings.push(Warning::Text(format!( + "architecture was not specified; overlays were selected for the native system architecture '{native}'; the tool's architecture was not verified" + ))); + } } - for node in &selected { - if !node.exact { - warnings.push(format!( - "{} uses its architecture-neutral {platform} variant; no {}-specific variant exists", - node.entry.entry_id, - architecture.get()? - )); + let mut reported: HashSet<&str> = HashSet::new(); + for node in &nodes { + if let Some(platform) = node.neutral_fallback { + if reported.insert(node.component.entry_id) { + warnings.push(Warning::Text(format!( + "{} uses its architecture-neutral {platform} additions; no {}-specific overlay exists", + node.component.entry_id, + architecture()? + ))); + } } } - if let Some(violation) = composition_violation(&selected) { + let components: Vec> = nodes.into_iter().map(|n| n.component).collect(); + if let Err(violation) = compose_check(&components) { return Err(PolicyCatalogError::new( ErrorReason::CompositionConflict, format!("selected entries cannot be composed: {violation}"), )); } - let symbols = self.resolve_symbols(&selected, ctx, platform, &mut warnings)?; + let symbols = self.resolve_symbols(&components, ctx, platform, &mut warnings)?; let policy = match symbols { None => None, - Some(symbols) => Some(compose_policy(&selected, &symbols, platform)?), + Some(symbols) => { + let composed = compose_policy( + &components, + &|template: &str| replace_symbols(template, |name| symbols[name].clone()), + platform, + ); + warnings.extend(composed.warnings.into_iter().map(Warning::Text)); + Some(composed.policy) + } }; diagnostics.warnings = warnings; Ok(SandboxConfigResolution { @@ -326,37 +539,52 @@ impl PolicyCatalog { }) } + fn parse_input_purl( + &self, + tool: &ToolCandidate, + input_index: usize, + warnings: &mut Vec, + ) -> Result> { + let Some(package_url) = &tool.package_url else { + return Ok(None); + }; + let Some(purl) = parse_purl(package_url) else { + return Err(invalid_context(format!( + "{}: '{package_url}' is not a valid package URL", + describe_input(input_index, tool) + ))); + }; + if let Some(version) = &purl.version { + warnings.push(Warning::Text(format!( + "{}: the version '{version}' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion", + describe_input(input_index, tool) + ))); + } + Ok(Some(purl)) + } + + /// 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: &LazyArchitecture<'_>, - warnings: &mut Vec, - ) -> Result>> { - let mut purl: Option = None; - if let Some(package_url) = &tool.package_url { - purl = parse_purl(package_url); - if purl.is_none() { - return Err(invalid_context(format!( - "{}: '{package_url}' is not a valid package URL", - describe_input(input_index, tool) - ))); - } - } + architecture: &dyn Fn() -> Result, + ) -> Result, Vec>> { let invocation = case_key(&tool.invocation_name, platform); - - let mut matches = Vec::new(); + 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 { + IdentityPredicate::Purl { value } => match purl { Some(purl) => parse_purl(value).is_some_and(|p| p.key == purl.key), None => false, }, @@ -378,68 +606,58 @@ impl PolicyCatalog { )); continue; } - let Some(selection) = select_variant(entry, platform, architecture.get()?) else { - skipped.push(format!( - "{} has no variant for {platform}/{}", - entry.entry_id, - architecture.get()? - )); - continue; - }; - for predicate in &satisfied { - let IdentityPredicate::Purl { - version_range: Some(range), - .. - } = predicate - else { - continue; - }; - let evidence = tool - .detected_version - .clone() - .or_else(|| purl.as_ref().and_then(|p| p.version.clone())); - let Some(evidence) = evidence else { - continue; - }; - let in_range = satisfies_version_range(&evidence, range); - if in_range != Some(true) { - warnings.push(format!( - "{}: detected version '{evidence}' {} the reviewed range '{range}' for {}", - describe_input(input_index, tool), - if in_range == Some(false) { - "is outside" - } else { - "could not be compared with" - }, - entry.entry_id - )); - } - } - if !strong { - warnings.push(format!( - "{} matched {} only by invocation name (weak identity)", - describe_input(input_index, tool), - entry.entry_id - )); - } - matches.push(EntryMatch { - entry, - selection, - satisfied, + 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)) }); - } - if matches.is_empty() { - let suffix = if skipped.is_empty() { - String::new() - } else { - format!(": {}", skipped.join("; ")) + 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), }; - warnings.push(format!( - "{} matched no eligible catalog entry{suffix}", - describe_input(input_index, tool) + 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(matches) + Ok(Ok(top.remove(0))) } fn validate_context( @@ -481,56 +699,55 @@ impl PolicyCatalog { Ok((platform, architecture)) } - /// Resolves every symbol the selected entries need; `None` (with + /// Resolves every symbol the selected components need; `None` (with /// warnings) when any is unresolved. Never a partial policy. fn resolve_symbols( &self, - nodes: &[ClosureNode<'_>], + components: &[Component<'_>], ctx: &ResolveContext, platform: Platform, - warnings: &mut Vec, + warnings: &mut Vec, ) -> Result>> { let contract = self.store.contract(); let mut values: HashMap = HashMap::new(); let mut missing: Vec<(String, Vec)> = Vec::new(); - for node in nodes { - for name in policy_symbols(&node.variant.sandbox_policy) { - if values.contains_key(&name) { - continue; - } - let definition = contract - .symbol(&name) - .expect("validated revisions reference declared symbols"); - let value = if definition.source == SymbolSource::Context { - ctx.project_root.clone() - } else { - let mut value = ctx - .symbols - .as_ref() - .and_then(|s| s.get(&name)) - .map(str::to_string); - if value.is_none() - && definition.source == SymbolSource::Host - && self.host.platform().ok() == Some(platform) - { - value = self.host.symbol(&name); - } - value - }; - let Some(value) = value else { - match missing.iter_mut().find(|(n, _)| *n == name) { - Some((_, ids)) => ids.push(node.entry.entry_id.clone()), - None => missing.push((name, vec![node.entry.entry_id.clone()])), + for (name, entry_ids) in component_symbols(components) { + let definition = contract + .symbol(&name) + .expect("validated revisions reference declared symbols"); + let value = if definition.source == SymbolSource::Context { + ctx.project_root.clone() + } else { + let supplied = ctx + .symbols + .as_ref() + .and_then(|s| s.get(&name)) + .map(str::to_string); + if supplied.is_none() + && definition.source == SymbolSource::Host + && self.host.platform().ok() == Some(platform) + { + let discovered = self.host.symbol(&name); + if let Some(value) = &discovered { + warnings.push(Warning::Text(format!( + "symbol '{name}' resolved from the host environment to '{value}'" + ))); } - continue; - }; - if value.contains("${") || !is_absolute_path(&value, platform) { - return Err(invalid_context(format!( - "symbol '{name}' must resolve to an absolute {platform} path" - ))); + discovered + } else { + supplied } - values.insert(name, value); + }; + let Some(value) = value else { + missing.push((name, entry_ids)); + continue; + }; + if value.contains("${") || !is_absolute_path(&value, platform) { + return Err(invalid_context(format!( + "symbol '{name}' must resolve to an absolute {platform} path" + ))); } + values.insert(name, value); } if !missing.is_empty() { missing.sort_by(|a, b| cmp_utf16(&a.0, &b.0)); @@ -541,10 +758,10 @@ impl PolicyCatalog { } else { format!("ResolveContext.symbols.{name}") }; - warnings.push(format!( + warnings.push(Warning::Text(format!( "required symbol '{name}' (needed by {}) is unresolved; supply {hint}; no policy was returned", entry_ids.join(", ") - )); + ))); } return Ok(None); } @@ -552,94 +769,6 @@ impl PolicyCatalog { } } -/// Distinct dependency edges among the selected entries, ordered by -/// entryId, entryRevision, then requiredVersionRange (absent first). -fn dependency_records( - nodes: &[ClosureNode<'_>], - by_id: &HashMap<&str, &CatalogEntry>, -) -> Vec { - let mut records: Vec = Vec::new(); - for node in nodes { - for dependency in node.variant.dependencies.iter().flatten() { - let target = by_id[dependency.entry_id.as_str()]; - let record = DependencyRecord { - entry_id: target.entry_id.clone(), - entry_revision: target.entry_revision, - required_version_range: dependency.version_range.clone(), - }; - if !records.contains(&record) { - records.push(record); - } - } - } - 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), - }, - ) - }); - records -} - -/// Composes the v1 vocabulary (design §4.5). -fn compose_policy( - nodes: &[ClosureNode<'_>], - symbols: &HashMap, - platform: Platform, -) -> Result { - let mut classes: Vec<(&str, Vec)> = Vec::new(); - for field in COMPOSABLE_FILESYSTEM_FIELDS { - let mut seen: HashSet> = HashSet::new(); - let mut out = Vec::new(); - for node in nodes { - for template in node.variant.sandbox_policy.filesystem_field(field) { - let substituted = replace_symbols(template, |name| symbols[name].clone()); - let resolved = normalize_path(&substituted, platform); - if seen.insert(path_key_segments(&resolved, platform)) { - out.push(resolved); - } - } - } - classes.push((field, out)); - } - if let Some(overlap) = find_cross_class_overlap(&classes, platform) { - return Err(PolicyCatalogError::new( - ErrorReason::CompositionConflict, - format!("resolved paths overlap across access classes: {overlap}"), - )); - } - - let root = &nodes[0].variant.sandbox_policy; - let filesystem = nodes - .iter() - .any(|n| n.variant.sandbox_policy.has_filesystem()) - .then(|| { - let pick = |index: usize| Some(classes[index].1.clone()).filter(|v| !v.is_empty()); - FilesystemPolicy { - denied_paths: pick(0), - readonly_paths: pick(1), - readwrite_paths: pick(2), - } - }); - Ok(SandboxPolicy { - version: root.version().to_string(), - filesystem, - network: root.field("network").cloned(), - ui: root.field("ui").cloned(), - timeout_ms: root.field("timeoutMs").and_then(|v| v.as_f64()), - }) -} - fn bundled() -> Result<&'static PolicyCatalog> { static CATALOG: OnceLock = OnceLock::new(); if let Some(catalog) = CATALOG.get() { diff --git a/src/core/mxc_policy_store/src/text.rs b/src/core/mxc_policy_store/src/text.rs index 9d226cdea..cda99e94e 100644 --- a/src/core/mxc_policy_store/src/text.rs +++ b/src/core/mxc_policy_store/src/text.rs @@ -24,17 +24,12 @@ pub fn js_trim(value: &str) -> &str { value.trim_matches(js_is_space) } -/// ECMAScript line terminators (what `.` in a regular expression does not match). -pub fn js_is_line_terminator(c: char) -> bool { - matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') -} - /// `String.prototype.toLowerCase` (locale-independent Unicode lowercasing). pub fn js_to_lower(value: &str) -> String { value.to_lowercase() } -/// Every match of `/\$\{([a-z][a-z0-9_]*)\}/g`: `(start, end, name)` byte ranges. +/// 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(); @@ -47,7 +42,7 @@ pub fn symbol_matches(value: &str) -> Vec<(usize, usize, &str)> { let mut j = i + 3; while bytes .get(j) - .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_') + .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_') { j += 1; } @@ -62,7 +57,7 @@ pub fn symbol_matches(value: &str) -> Vec<(usize, usize, &str)> { matches } -/// `value.replace(/\$\{([a-z][a-z0-9_]*)\}/g, name => replacement(name))`. +/// `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; @@ -75,11 +70,11 @@ pub fn replace_symbols(value: &str, mut replacement: impl FnMut(&str) -> String) out } -/// `/^[a-z][a-z0-9_]*$/` +/// `/^[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_lowercase() || c.is_ascii_digit() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } #[cfg(test)] @@ -94,13 +89,14 @@ mod tests { #[test] fn symbols() { - let found: Vec<&str> = symbol_matches("${a}/${b_1}${C}${x") + let found: Vec<&str> = symbol_matches("${a}/${b_1}${C}${x${programData}") .iter() .map(|m| m.2) .collect(); - assert_eq!(found, ["a", "b_1"]); + 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/core/mxc_policy_store/src/version_range.rs b/src/core/mxc_policy_store/src/version_range.rs deleted file mode 100644 index 170168f39..000000000 --- a/src/core/mxc_policy_store/src/version_range.rs +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Minimal version-range support for catalog v1, identical to the original -//! TypeScript prototype. -//! -//! ```text -//! range := comparatorSet ( "||" comparatorSet )* -//! comparatorSet := comparator ( " " comparator )* -//! comparator := op? version -//! op := ">=" | "<=" | ">" | "<" | "=" -//! version := N ( "." N ( "." N )? )? -//! ``` -//! -//! Missing components are zero-filled. A comparator with no operator is a -//! prefix match. Prerelease and build metadata on evidence are ignored. - -use crate::text::{js_is_line_terminator, js_is_space, js_trim}; - -type Triple = [f64; 3]; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Op { - Ge, - Le, - Gt, - Lt, - Eq, - Prefix, -} - -struct Comparator { - op: Op, - version: Triple, - parts: usize, -} - -/// Consumes `\d+` (ASCII) from `s`, returning the digits and the rest. -fn digits(s: &str) -> Option<(&str, &str)> { - let end = s - .bytes() - .position(|b| !b.is_ascii_digit()) - .unwrap_or(s.len()); - if end == 0 { - None - } else { - Some((&s[..end], &s[end..])) - } -} - -fn number(digits: &str) -> f64 { - digits.parse::().expect("ASCII digits parse as f64") -} - -/// `/^(>=|<=|>|<|=)?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/` -fn parse_comparator(token: &str) -> Option { - let (op, rest) = if let Some(rest) = token.strip_prefix(">=") { - (Op::Ge, rest) - } else if let Some(rest) = token.strip_prefix("<=") { - (Op::Le, rest) - } else if let Some(rest) = token.strip_prefix('>') { - (Op::Gt, rest) - } else if let Some(rest) = token.strip_prefix('<') { - (Op::Lt, rest) - } else if let Some(rest) = token.strip_prefix('=') { - (Op::Eq, rest) - } else { - (Op::Prefix, token) - }; - let (major, mut rest) = digits(rest)?; - let mut version = [number(major), 0.0, 0.0]; - let mut parts = 1; - if let Some(after_dot) = rest.strip_prefix('.') { - let (minor, after_minor) = digits(after_dot)?; - version[1] = number(minor); - parts = 2; - rest = after_minor; - if let Some(after_dot) = rest.strip_prefix('.') { - let (patch, after_patch) = digits(after_dot)?; - version[2] = number(patch); - parts = 3; - rest = after_patch; - } - } - if !rest.is_empty() { - return None; - } - Some(Comparator { op, version, parts }) -} - -fn parse_range(range: &str) -> Option>> { - if js_trim(range).is_empty() { - return None; - } - let mut sets = Vec::new(); - for alternative in range.split("||") { - let tokens: Vec<&str> = js_trim(alternative) - .split(js_is_space) - .filter(|t| !t.is_empty()) - .collect(); - if tokens.is_empty() { - return None; - } - let mut comparators = Vec::new(); - for token in tokens { - comparators.push(parse_comparator(token)?); - } - sets.push(comparators); - } - Some(sets) -} - -/// True when `range` uses the supported v1 range grammar. -pub fn is_valid_version_range(range: &str) -> bool { - parse_range(range).is_some() -} - -/// `/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/` over the trimmed value. -fn parse_version_evidence(value: &str) -> Option { - let trimmed = js_trim(value); - let rest = trimmed.strip_prefix('v').unwrap_or(trimmed); - let (major, mut rest) = digits(rest)?; - let mut version = [number(major), 0.0, 0.0]; - for slot in version.iter_mut().skip(1) { - if let Some(after_dot) = rest.strip_prefix('.') { - if let Some((value, after)) = digits(after_dot) { - *slot = number(value); - rest = after; - } - } - } - if rest.is_empty() - || ((rest.starts_with('-') || rest.starts_with('+')) - && !rest[1..].chars().any(js_is_line_terminator)) - { - Some(version) - } else { - None - } -} - -fn compare(left: &Triple, right: &Triple) -> i32 { - for index in 0..3 { - if left[index] != right[index] { - return if left[index] < right[index] { -1 } else { 1 }; - } - } - 0 -} - -fn satisfies_comparator(version: &Triple, comparator: &Comparator) -> bool { - let order = compare(version, &comparator.version); - match comparator.op { - Op::Ge => order >= 0, - Op::Le => order <= 0, - Op::Gt => order > 0, - Op::Lt => order < 0, - Op::Eq => order == 0, - Op::Prefix => (0..comparator.parts).all(|i| version[i] == comparator.version[i]), - } -} - -/// Evaluates `version` against `range`; `None` when either cannot be evaluated. -pub fn satisfies_version_range(version: &str, range: &str) -> Option { - let parsed_version = parse_version_evidence(version)?; - let parsed_range = parse_range(range)?; - Some( - parsed_range - .iter() - .any(|set| set.iter().all(|c| satisfies_comparator(&parsed_version, c))), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn grammar() { - for range in [">=10 <12", "22", ">=1.2.3", "=1.0.0", "<2 || >=4"] { - assert!(is_valid_version_range(range), "{range}"); - } - for range in [ - "", "^1.2.3", "~1", "1.x", ">= 1", "1 ||", "latest", "1.2.3.4", "1", - ] { - assert!(!is_valid_version_range(range), "{range}"); - } - } - - #[test] - fn evaluation() { - assert_eq!(satisfies_version_range("10.9.0", ">=10 <12"), Some(true)); - assert_eq!(satisfies_version_range("v12.0.0", ">=10 <12"), Some(false)); - assert_eq!(satisfies_version_range("22.3.1", "22"), Some(true)); - assert_eq!(satisfies_version_range("23.0.0", "22"), Some(false)); - assert_eq!(satisfies_version_range("3.0.0", "<2 || >=3"), Some(true)); - assert_eq!(satisfies_version_range("10.0.0-rc.1", ">=10"), Some(true)); - assert_eq!(satisfies_version_range("nightly", ">=10"), None); - assert_eq!(satisfies_version_range(" 1.2 ", "1.2"), Some(true)); - assert_eq!(satisfies_version_range("1.0.0-a\nb", ">=1"), None); - assert_eq!(satisfies_version_range("1..2", ">=1"), None); - assert_eq!(satisfies_version_range("1.2.3.4", ">=1"), None); - } -} diff --git a/src/core/mxc_policy_store/src/view.rs b/src/core/mxc_policy_store/src/view.rs new file mode 100644 index 000000000..35ddcd3be --- /dev/null +++ b/src/core/mxc_policy_store/src/view.rs @@ -0,0 +1,193 @@ +// 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::catalog::{entry_index, CatalogContract, CatalogRevision}; +use crate::effective::{materialize_entry, Materialized}; +use crate::errors::Result; +use crate::json::{cmp_utf16, Json}; +use crate::model::{Architecture, FilesystemPolicy, 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(&FilesystemPolicy) -> &Option>) -> Vec { + m.policy + .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.policy.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 + } +} + +fn row_cells(m: &Materialized) -> [String; 5] { + [ + 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), + ] +} + +/// 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: the default, plus platform additions, at most one version variant, and the selected intent. `(all)` means no intent was requested. Paths are unsubstituted templates.\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::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 |\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; 5])> = 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; 5])> = 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) +} diff --git a/src/core/mxc_policy_store/tests/catalog_validation.rs b/src/core/mxc_policy_store/tests/catalog_validation.rs index 2c2e4ef01..632ddac7a 100644 --- a/src/core/mxc_policy_store/tests/catalog_validation.rs +++ b/src/core/mxc_policy_store/tests/catalog_validation.rs @@ -9,8 +9,11 @@ //! `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 it supports; -//! - every entry has a bundled-catalog conformance case. +//! every platform and architecture (every effective policy is also +//! materialized and validated by `validate_catalog_revision`); +//! - the rendered reviewer view in `catalog/views/` is current; +//! - 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. @@ -19,7 +22,7 @@ mod common; use common::{crate_dir, read_json}; use mxc_policy_store::tooling::{ - select_variant, validate_bundled_catalog, IdentityPredicate, Json, + render_reviewer_view, validate_bundled_catalog, IdentityPredicate, Json, }; use mxc_policy_store::{ bundled_catalog_store, Architecture, FixedHost, Platform, PolicyCatalog, ResolveContext, @@ -96,16 +99,13 @@ fn every_entry_resolves_deterministically_through_its_own_identity() { IdentityPredicate::InvocationName { names } => { candidate.invocation_name = names[0].clone() } - IdentityPredicate::Purl { value, .. } => { + IdentityPredicate::Purl { value } => { candidate.package_url = Some(value.clone()) } } } for platform in Platform::ALL { for architecture in Architecture::ALL { - if select_variant(entry, platform, architecture).is_none() { - continue; - } let catalog = PolicyCatalog::with_host( store.clone(), Arc::new(FixedHost::new(platform, architecture)), @@ -162,9 +162,18 @@ fn every_entry_has_a_bundled_conformance_case() { .into_iter() .flatten() { - let tools = case - .get("expect") - .and_then(|e| e.get("diagnostics")) + 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() { @@ -195,3 +204,32 @@ fn every_entry_has_a_bundled_conformance_case() { "entries without a bundled conformance case: {missing:?}" ); } + +/// The reviewer view is generated; set `MXC_POLICY_STORE_UPDATE_VIEWS=1` to +/// rewrite it 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")); + if update { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, &rendered).unwrap(); + continue; + } + 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() + ); + } +} diff --git a/src/core/mxc_policy_store/tests/common/mod.rs b/src/core/mxc_policy_store/tests/common/mod.rs index 52c8e6f9b..d089c09f8 100644 --- a/src/core/mxc_policy_store/tests/common/mod.rs +++ b/src/core/mxc_policy_store/tests/common/mod.rs @@ -82,13 +82,14 @@ pub fn revision(entries: Vec) -> Json { revision_with(entries, "2000-01-01.1") } -/// Minimal valid entry (linux, readwrite `${project_root}`) with overrides. +/// 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}", + r#"{{"entryId":"{entry_id}","entryRevision":1,"displayName":"{name}","versionScheme":"semver", "identity":[{{"kind":"invocation-name","names":["{name}"]}}], - "platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}}}], + "default":{{"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}}}, "provenance":{{"method":"test","sourceRevision":"test"}}}}"# )); if !overrides.is_empty() { diff --git a/src/core/mxc_policy_store/tests/conformance.rs b/src/core/mxc_policy_store/tests/conformance.rs index 840162378..cf3997bed 100644 --- a/src/core/mxc_policy_store/tests/conformance.rs +++ b/src/core/mxc_policy_store/tests/conformance.rs @@ -4,6 +4,11 @@ //! 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. mod common; @@ -30,9 +35,13 @@ fn all_conformance_fixtures() { .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}: {}", @@ -57,6 +66,21 @@ fn all_conformance_fixtures() { 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_sandbox_policy_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 @@ -115,6 +139,17 @@ fn all_conformance_fixtures() { } 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/core/mxc_policy_store/tests/library.rs b/src/core/mxc_policy_store/tests/library.rs index cc9c1b639..15bde32c9 100644 --- a/src/core/mxc_policy_store/tests/library.rs +++ b/src/core/mxc_policy_store/tests/library.rs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Ports of the original TypeScript prototype's resolver, validation, and -//! store unit suites. +//! 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. mod common; @@ -14,8 +15,9 @@ use mxc_policy_store::tooling::{ use mxc_policy_store::{ get_catalog_info, list_catalog_entries, resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, FixedHost, HostEnvironment, - Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, ToolCandidate, ToolInput, - ToolInputs, + IntentMode, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, + SandboxConfigResolution, ToolCandidate, ToolInput, ToolInputs, ToolResolutionStatus, + VersionStatus, Warning, }; use std::collections::HashMap; use std::sync::Arc; @@ -41,101 +43,135 @@ fn validate_err(entries: Vec, needle: &str) { ); } -fn linux(policy: &str, extra: &str) -> String { - let extra = if extra.is_empty() { - String::new() - } else { - format!(",{extra}") - }; - format!(r#"{{"when":{{"platform":"linux"}}{extra},"sandboxPolicy":{policy}}}"#) -} - -fn variants(list: &[String]) -> String { - format!(r#"{{"platformVariants":[{}]}}"#, list.join(",")) +/// `{"default":{"sandboxPolicy":}}` as entry overrides. +fn with_default(policy: &str, extra: &str) -> String { + format!(r#"{{"default":{{"sandboxPolicy":{policy}}}{extra}}}"#) } -fn fs_paths(policy: &Option) -> Json { +fn fs_json(policy: &Option) -> Json { policy .as_ref() - .unwrap() + .expect("a policy") .filesystem .as_ref() - .unwrap() + .expect("a filesystem section") .to_json() } +fn messages(warnings: &[Warning]) -> String { + warnings + .iter() + .map(Warning::message) + .collect::>() + .join("\n") +} + +fn egress(result: &SandboxConfigResolution) -> Option { + result + .policy + .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}}}]}}"#) +} + // --------------------------------------------------------------------------- -// Runtime lookup: defaults +// Platform and architecture selection // --------------------------------------------------------------------------- fn arch_catalog(host: Arc) -> PolicyCatalog { catalog_for( revision(vec![entry( "tool:a", - &format!( - r#"{{"identity":[{{"kind":"purl","value":"pkg:npm/a"}},{{"kind":"invocation-name","names":["a"]}}], + r#"{"identity":[{"kind":"purl","value":"pkg:npm/a"},{"kind":"invocation-name","names":["a"]}], "platformVariants":[ - {{"when":{{"platform":"windows","architecture":"x64"}},"sandboxPolicy":{{"version":"{V}","timeoutMs":1}}}}, - {{"when":{{"platform":"windows","architecture":"arm64"}},"sandboxPolicy":{{"version":"{V}","timeoutMs":2}}}}]}}"# - ), + {"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_context_uses_host_platform_native_arch_default_revision_no_weak() { +fn omitted_architecture_uses_the_native_system_architecture() { let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); - assert_eq!( - catalog - .resolve_sandbox_policy("a", &ResolveContext::new()) - .unwrap(), - None - ); + let ctx = ResolveContext::new() + .project_root("C:\\p") + .symbol("git_prefix", "C:\\g"); + assert_eq!(catalog.resolve_sandbox_policy("a", &ctx).unwrap(), None); let result = catalog .resolve_sandbox_policy_with_diagnostics( ToolCandidate::new("a").with_package_url("pkg:npm/a"), - &ResolveContext::new(), + &ctx, ) .unwrap(); - assert_eq!(result.policy.unwrap().timeout_ms, Some(2.0)); + assert_eq!( + fs_json(&result.policy), + j(r#"{"readonlyPaths":["C:\\g\\arm64"],"readwritePaths":["C:\\p"]}"#) + ); assert_eq!(result.diagnostics.catalog_revision, "2000-01-01.1"); - assert!(result - .diagnostics - .warnings - .join("\n") + assert!(messages(&result.diagnostics.warnings) .contains("native system architecture 'arm64'; the tool's architecture was not verified")); } #[test] -fn explicit_architecture_wins_and_suppresses_host_default_warning() { +fn explicit_architecture_wins_and_suppresses_the_host_default_warning() { let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); let result = catalog .resolve_sandbox_policy_with_diagnostics( ToolCandidate::new("a").with_package_url("pkg:npm/a"), - &ResolveContext::new().architecture(Architecture::X64), + &ResolveContext::new() + .architecture(Architecture::X64) + .project_root("C:\\p") + .symbol("git_prefix", "C:\\g"), ) .unwrap(); - assert_eq!(result.policy.unwrap().timeout_ms, Some(1.0)); + assert_eq!( + fs_json(&result.policy), + j(r#"{"readonlyPaths":["C:\\g\\x64"],"readwritePaths":["C:\\p"]}"#) + ); assert!(result.diagnostics.warnings.is_empty()); } #[test] -fn another_architecture_is_never_a_fallback() { +fn another_architecture_is_never_used_and_neutral_additions_are_reported() { let catalog = catalog_for( - revision(vec![entry( - "tool:a", - &format!( - r#"{{"platformVariants":[{{"when":{{"platform":"linux","architecture":"arm64"}},"sandboxPolicy":{{"version":"{V}"}}}}]}}"# + 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 result = catalog - .resolve_sandbox_policy_with_diagnostics("a", &weak().architecture(Architecture::X64)) + let ctx = weak() + .architecture(Architecture::X64) + .project_root("/p") + .symbol("node_prefix", "/n"); + let a = catalog + .resolve_sandbox_policy_with_diagnostics("a", &ctx) .unwrap(); - assert_eq!(result.policy, None); - assert!(result.diagnostics.warnings[0].contains("tool:a has no variant for linux/x64")); + assert_eq!(fs_json(&a.policy), j(r#"{"readwritePaths":["/p"]}"#)); + assert!(!messages(&a.diagnostics.warnings).contains("architecture")); + let b = catalog + .resolve_sandbox_policy_with_diagnostics("b", &ctx) + .unwrap(); + assert_eq!( + fs_json(&b.policy), + j(r#"{"readonlyPaths":["/n"],"readwritePaths":["/p"]}"#) + ); + assert!(messages(&b.diagnostics.warnings).contains( + "tool:b uses its architecture-neutral linux additions; no x64-specific overlay exists" + )); } struct FailingHost; @@ -155,57 +191,81 @@ impl HostEnvironment for FailingHost { } #[test] -fn host_detection_failure_is_an_error_and_only_attempted_when_needed() { - let store = Arc::new(store_for(&[revision(vec![entry("tool:t", "")])]).unwrap()); +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_sandbox_policy("t", &weak().project_root("/p")) + .resolve_sandbox_policy("u", &weak().project_root("/p")) .unwrap_err(); assert!(error.message().contains("unknown machine")); assert_eq!(error.reason(), ErrorReason::UnsupportedHost); + let result = catalog + .resolve_sandbox_policy_with_diagnostics("t", &weak().project_root("/p")) + .unwrap(); + assert_eq!(fs_json(&result.policy), j(r#"{"readwritePaths":["/p"]}"#)); + assert!(!messages(&result.diagnostics.warnings).contains("architecture")); assert_eq!( catalog.resolve_sandbox_policy("nothing", &weak()).unwrap(), None ); - let policy = catalog + assert!(catalog .resolve_sandbox_policy( - "t", - &weak().architecture(Architecture::X64).project_root("/p"), + "u", + &weak().architecture(Architecture::X64).project_root("/p") ) - .unwrap(); - assert_eq!(fs_paths(&policy), j(r#"{"readwritePaths":["/p"]}"#)); + .unwrap() + .is_some()); } +// --------------------------------------------------------------------------- +// Symbols +// --------------------------------------------------------------------------- + #[test] fn never_fabricates_project_root_or_caller_symbols() { let result = bundled_catalog(linux_x64()) .resolve_sandbox_policy_with_diagnostics("git", &weak().architecture(Architecture::X64)) .unwrap(); assert_eq!(result.policy, None); - let warnings = result.diagnostics.warnings.join("\n"); + 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 host_symbols_only_for_current_host_platform_and_caller_overrides() { +fn host_symbols_only_for_the_current_host_platform_and_caller_overrides_win() { let rev = revision(vec![entry( "tool:t", - &format!( - r#"{{"platformVariants":[ - {{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{user_home}}/.cfg"]}}}}}}, - {{"when":{{"platform":"macos"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{user_home}}/.cfg"]}}}}}}]}}"# + &with_default( + &format!( + r#"{{"version":"{V}","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_sandbox_policy_with_diagnostics("t", &weak()) + .unwrap(); assert_eq!( - fs_paths(&catalog.resolve_sandbox_policy("t", &weak()).unwrap()), + fs_json(&derived.policy), j(r#"{"readonlyPaths":["/home/me/.cfg"]}"#) ); + assert!(messages(&derived.diagnostics.warnings) + .contains("symbol 'user_home' resolved from the host environment to '/home/me'")); assert_eq!( catalog .resolve_sandbox_policy( @@ -217,27 +277,55 @@ fn host_symbols_only_for_current_host_platform_and_caller_overrides() { .unwrap(), None ); + let overridden = catalog + .resolve_sandbox_policy_with_diagnostics("t", &weak().symbol("user_home", "/srv/u")) + .unwrap(); assert_eq!( - fs_paths( - &catalog - .resolve_sandbox_policy("t", &weak().symbol("user_home", "/srv/u")) - .unwrap() - ), + fs_json(&overridden.policy), 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"); + let policy = bundled_catalog(linux_x64()) + .resolve_sandbox_policy( + 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 = weak() - .architecture(Architecture::X64) - .project_root("/p") - .symbol("git_prefix", "/g"); + let ctx = git_ctx(); let a = catalog .resolve_sandbox_policy_with_diagnostics("git", &ctx) .unwrap(); @@ -254,17 +342,17 @@ fn string_object_and_one_element_array_are_equivalent() { a ); assert_eq!(a.diagnostics.tools[0].input_index, 0); - let strict = ResolveContext::new() - .architecture(Architecture::X64) - .project_root("/p") - .symbol("git_prefix", "/g"); + let strict = ResolveContext { + allow_weak_identity_fallback: false, + ..ctx + }; assert_eq!( catalog.resolve_sandbox_policy("git", &strict).unwrap(), None ); assert_eq!( catalog - .resolve_sandbox_policy(ToolCandidate::new("git"), &strict) + .resolve_sandbox_policy(ToolCandidate::new("git").with_intent("fetch"), &strict) .unwrap(), None ); @@ -288,6 +376,10 @@ fn invalid_context_and_inputs_are_failures_not_absence() { ToolCandidate::new("npm").with_package_url("").into(), ResolveContext::new(), ), + ( + ToolCandidate::new("git").with_intent("").into(), + ResolveContext::new(), + ), ( "git".into(), ResolveContext { @@ -320,7 +412,6 @@ fn invalid_context_and_inputs_are_failures_not_absence() { "{tools:?} {ctx:?}" ); } - // A relative or `${`-containing symbol value is rejected once it is needed. for value in ["bin", "/x/${node_prefix}"] { let ctx = weak() .architecture(Architecture::X64) @@ -341,7 +432,7 @@ fn results_are_caller_owned_and_deterministic() { .project_root("/p") .symbol("npm_prefix", "/n") .symbol("npm_cache", "/c") - .symbol("node_prefix", "/n"); + .symbol("node_prefix", "/node"); let tool = ToolCandidate::new("npm").with_package_url("pkg:npm/npm"); let mut first = catalog .resolve_sandbox_policy_with_diagnostics(tool.clone(), &ctx) @@ -357,154 +448,451 @@ fn results_are_caller_owned_and_deterministic() { .as_mut() .unwrap() .push("/mutated".into()); - first.diagnostics.warnings.push("mutated".into()); + first + .diagnostics + .warnings + .push("mutated".to_string().into()); let second = catalog .resolve_sandbox_policy_with_diagnostics(tool, &ctx) .unwrap(); assert_eq!( - fs_paths(&second.policy), - j(r#"{"readonlyPaths":["/n"],"readwritePaths":["/p","/c"]}"#) + fs_json(&second.policy), + j(r#"{"readonlyPaths":["/n","/node"],"readwritePaths":["/p","/c"]}"#) ); - assert!(!second.diagnostics.warnings.contains(&"mutated".to_string())); + assert!(!messages(&second.diagnostics.warnings).contains("mutated")); } #[test] -fn dedupes_normalized_paths_with_platform_casing() { - let windows = catalog_for( - revision(vec![entry( - "tool:w", - &format!( - r#"{{"platformVariants":[{{"when":{{"platform":"windows"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}","${{node_prefix}}\\"]}}}}}}]}}"# - ), - )]), - linux_x64(), - ); - let ctx = weak() - .platform(Platform::Windows) - .architecture(Architecture::X64) - .symbol("git_prefix", "C:\\Tools") - .symbol("node_prefix", "c:\\tools"); +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_sandbox_policy_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!( - fs_paths(&windows.resolve_sandbox_policy("w", &ctx).unwrap()), - j(r#"{"readonlyPaths":["C:\\Tools"]}"#) + on(Platform::Linux, "GH"), + ToolResolutionStatus::ToolUnmatched ); - let tmpl = |platform: &str, id: &str| { + 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#"{{"platformVariants":[{{"when":{{"platform":"{platform}"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}","${{node_prefix}}/"]}}}}}}]}}"# + &format!(r#"{{"identity":[{{"kind":"invocation-name","names":["x"]}}]{extra}}}"#), + ) + }; + let entries = vec![ + named("tool:a", ""), + named( + "tool:b", + r#","default":{"sandboxPolicy":{"version":"0.9.0-alpha"},"intents":{"build":{}}}"#, + ), + entry( + "tool:c", + r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"},{"kind":"invocation-name","names":["x"]}],"default":{"sandboxPolicy":{"version":"0.9.0-alpha"}}}"#, + ), + ]; + 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_sandbox_policy_with_diagnostics(tool, &ctx) + .unwrap() + .diagnostics + .tools[0] + .matches[0] + .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_sandbox_policy("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_sandbox_policy_with_diagnostics("x", &weak().project_root("/p")) + .unwrap(); + assert_eq!(result.diagnostics.tools[0].matches[0].entry_id, "tool:b"); +} + +#[test] +fn package_url_versions_are_ignored_with_a_warning() { + let result = bundled_catalog(linux_x64()) + .resolve_sandbox_policy_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) + ); + assert!(messages(&result.diagnostics.warnings).contains( + "input 0 ('git'): the version '2.45.0' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion" + )); +} + +// --------------------------------------------------------------------------- +// 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) -> SandboxConfigResolution { + bundled_catalog(linux_x64()) + .resolve_sandbox_policy_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].matches[0]; + 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].matches[0]; + 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.policy), + 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.policy, none.policy); + let Warning::Tool(warning) = &outside.diagnostics.warnings[0] else { + panic!("structured warning expected"); }; - let linux = catalog_for(revision(vec![tmpl("linux", "tool:l")]), linux_x64()); - let ctx = weak() - .symbol("git_prefix", "/Tools") - .symbol("node_prefix", "/tools"); + assert_eq!(warning.code.as_str(), "version_out_of_range"); + assert_eq!(warning.entry_id.as_deref(), Some("tool:git")); + assert_eq!(warning.detected_version.as_deref(), Some("2.30.0")); + assert_eq!(warning.intent.as_deref(), Some("push")); + + let bad = resolve_git(git(Some("banana"), Some("push"))); + assert_eq!(bad.policy, None); + assert_eq!( + bad.diagnostics.tools[0].status, + ToolResolutionStatus::Version(VersionStatus::VersionUnparseable) + ); + assert_eq!(bad.diagnostics.tools[0].matches[0].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].matches[0] + .intent_selection + .clone() + .unwrap(); + assert_eq!(selection.mode, IntentMode::All); + assert_eq!( + selection.selected, + ["bundle-fetch", "fetch", "local", "push"] + ); + assert_eq!( + fs_json(&all.policy), + 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.policy.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.matches[0].version_selection.status, + VersionStatus::MatchedVersion + ); assert_eq!( - fs_paths(&linux.resolve_sandbox_policy("l", &ctx).unwrap()), - j(r#"{"readonlyPaths":["/Tools","/tools"]}"#) + tool.matches[0].intent_selection.as_ref().unwrap().mode, + IntentMode::Unsupported ); - let mac = catalog_for(revision(vec![tmpl("macos", "tool:m")]), linux_x64()); - let ctx = ctx - .platform(Platform::Macos) - .architecture(Architecture::Arm64); + assert_eq!(unsupported.policy, 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!( - fs_paths(&mac.resolve_sandbox_policy("m", &ctx).unwrap()), - j(r#"{"readonlyPaths":["/Tools"]}"#) + tool.matches[0].version_selection.status, + VersionStatus::VersionOutOfRange ); + let codes: Vec<&str> = both + .diagnostics + .warnings + .iter() + .filter_map(|w| match w { + Warning::Tool(t) => Some(t.code.as_str()), + Warning::Text(_) => None, + }) + .collect(); + assert_eq!(codes, ["version_out_of_range", "intent_unsupported"]); } #[test] -fn overlap_detection_follows_platform_casing() { - let make = |platform: &str| { - catalog_for( - revision(vec![entry( - "tool:m", - &format!( - r#"{{"platformVariants":[{{"when":{{"platform":"{platform}"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}"],"readwritePaths":["${{project_root}}"]}}}}}}]}}"# - ), - )]), - linux_x64(), - ) - }; - let ctx = weak() - .architecture(Architecture::X64) - .project_root("/tools/work") - .symbol("git_prefix", "/Tools"); +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!( - reason_of( - make("macos").resolve_sandbox_policy("m", &ctx.clone().platform(Platform::Macos)) - ), - ErrorReason::CompositionConflict + egress(&result), + Some(j(&format!( + "[{},{}]", + tcp("192.0.2.10/32", 443), + tcp("192.0.2.10/32", 22) + ))) ); assert_eq!( - fs_paths( - &make("linux") - .resolve_sandbox_policy("m", &ctx.platform(Platform::Linux)) - .unwrap() + result.diagnostics.tools[4].status, + ToolResolutionStatus::ToolUnmatched + ); + assert_eq!( + fs_json(&result.policy), + j(r#"{"readonlyPaths":["/g"],"readwritePaths":["/p"]}"#) + ); +} + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +fn paths_entry(id: &str, policy_fs: &str) -> Json { + entry( + id, + &with_default( + &format!(r#"{{"version":"{V}","filesystem":{policy_fs}}}"#), + "", ), - j(r#"{"readonlyPaths":["/Tools"],"readwritePaths":["/tools/work"]}"#) + ) +} + +fn compose(entries: Vec, names: Vec<&str>) -> SandboxConfigResolution { + catalog_for(revision(entries), linux_x64()) + .resolve_sandbox_policy_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.policy), 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.policy), + 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.policy), + 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.policy), + 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 invocation_name_casing_per_platform() { - let variants_all = ["linux", "macos", "windows"] - .iter() - .map(|p| format!(r#"{{"when":{{"platform":"{p}"}},"sandboxPolicy":{{"version":"{V}"}}}}"#)) - .collect::>() - .join(","); +fn unknown_case_sensitivity_keeps_case_variants_with_a_diagnostic() { let catalog = catalog_for( - revision(vec![entry( - "tool:gh", - &format!(r#"{{"platformVariants":[{variants_all}]}}"#), + revision(vec![paths_entry( + "tool:w", + r#"{"readonlyPaths":["${git_prefix}","${node_prefix}\\"]}"#, )]), linux_x64(), ); - let on = |platform: Platform, name: &str| -> Vec { - catalog - .resolve_sandbox_policy_with_diagnostics( - name, - &weak().platform(platform).architecture(Architecture::X64), - ) - .unwrap() - .diagnostics - .tools[0] - .matches - .iter() - .map(|m| m.entry_id.clone()) - .collect() - }; - assert_eq!(on(Platform::Linux, "gh"), ["tool:gh"]); - assert!(on(Platform::Linux, "GH").is_empty()); - assert_eq!(on(Platform::Macos, "GH"), ["tool:gh"]); - assert_eq!(on(Platform::Windows, "Gh"), ["tool:gh"]); + 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_sandbox_policy_with_diagnostics("w", &ctx) + .unwrap(); + assert_eq!( + fs_json(&result.policy), + 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_sandbox_policy( + "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 variant = |deps: &[&str], path: &str| { - let deps = if deps.is_empty() { - String::new() - } else { - format!( - r#","dependencies":[{}]"#, - deps.iter() - .map(|d| format!(r#"{{"entryId":"{d}"}}"#)) - .collect::>() - .join(",") - ) - }; + let node = |deps: &[&str], path: &str| { + let deps = deps + .iter() + .map(|d| format!(r#"{{"entryId":"{d}"}}"#)) + .collect::>() + .join(","); format!( - r#"{{"platformVariants":[{{"when":{{"platform":"linux"}}{deps},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{project_root}}/{path}"]}}}}}}]}}"# + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{project_root}}/{path}"]}}}},"dependencies":[{deps}]}}}}"# ) }; let catalog = catalog_for( revision(vec![ - entry("tool:top", &variant(&["tool:left", "tool:right"], "top")), - entry("tool:left", &variant(&["tool:leaf"], "left")), - entry("tool:right", &variant(&["tool:leaf"], "right")), - entry("tool:leaf", &variant(&[], "leaf")), + 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(), ); @@ -519,32 +907,28 @@ fn dependency_chain_composes_each_entry_once_in_order() { .collect(); assert_eq!(deps, ["tool:leaf", "tool:left", "tool:right"]); assert_eq!( - fs_paths(&result.policy), + fs_json(&result.policy), j(r#"{"readonlyPaths":["/r/top","/r/left","/r/leaf","/r/right"]}"#) ); } #[test] fn dependency_diagnostics_keep_distinct_ranges_sorted() { + let a = format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"tool:c","versionRange":"vers:semver/>=2.0.0"}}]}}}}"# + ); + let b = format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"tool:c"}}]}}}}"# + ); let catalog = catalog_for( revision(vec![ - entry( - "tool:a", - &variants(&[linux( - &format!(r#"{{"version":"{V}"}}"#), - r#""dependencies":[{"entryId":"tool:c","versionRange":">=2"}]"#, - )]), - ), - entry( - "tool:b", - &variants(&[linux( - &format!(r#"{{"version":"{V}"}}"#), - r#""dependencies":[{"entryId":"tool:c"}]"#, - )]), - ), + entry("tool:a", &a), + entry("tool:b", &b), entry( "tool:c", - &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")]), + &format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"intents":{{"run":{{}}}}}}}}"# + ), ), ]), linux_x64(), @@ -559,9 +943,9 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { .unwrap() .get("resolvedDependencies") .unwrap(), - &j( - r#"[{"entryId":"tool:c","entryRevision":1},{"entryId":"tool:c","entryRevision":1,"requiredVersionRange":">=2"}]"# - ) + &j(r#"[ + {"entryId":"tool:c","entryRevision":1,"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"all","selected":["run"]}}, + {"entryId":"tool:c","entryRevision":1,"requiredVersionRange":"vers:semver/>=2.0.0","versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"all","selected":["run"]}}]"#) ); assert_eq!( result.policy.unwrap().to_json(), @@ -570,46 +954,36 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { } #[test] -fn catalog_file_order_does_not_matter() { - let e = |id: &str, p: &str| { - entry( - id, - &format!( - r#"{{"identity":[{{"kind":"invocation-name","names":["x"]}}],"platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{project_root}}/{p}"]}}}}}}]}}"# - ), - ) - }; - let ctx = weak().project_root("/r"); - let forward = catalog_for( - revision(vec![e("tool:b", "b"), e("tool:a", "a")]), - linux_x64(), - ) - .resolve_sandbox_policy_with_diagnostics("x", &ctx) - .unwrap(); - let backward = catalog_for( - revision(vec![e("tool:a", "a"), e("tool:b", "b")]), - linux_x64(), - ) - .resolve_sandbox_policy_with_diagnostics("x", &ctx) - .unwrap(); - assert_eq!(forward, backward); - assert_eq!( - fs_paths(&forward.policy), - j(r#"{"readonlyPaths":["/r/a","/r/b"]}"#) - ); -} - -#[test] -fn mixed_versions_across_inputs_conflict() { +fn unsupported_combinations_fail_rather_than_broaden() { let catalog = catalog_for( revision(vec![ + entry("tool:a", &with_default(r#"{"version":"0.8.0-alpha"}"#, "")), entry( - "tool:a", - &variants(&[linux(r#"{"version":"0.8.0-alpha"}"#, "")]), + "tool:b", + &with_default(&format!(r#"{{"version":"{V}"}}"#), ""), ), entry( - "tool:b", - &variants(&[linux(&format!(r#"{{"version":"{V}"}}"#), "")]), + "tool:d", + &with_default( + &format!( + r#"{{"version":"{V}","network":{{"egress":{{"deny":[{{"ports":[{{"port":22}}]}}]}}}}}}"# + ), + "", + ), + ), + entry( + "tool:n", + &with_default( + &format!( + r#"{{"version":"{V}","network":{{"egress":{{"default":"deny","allow":[{}]}}}}}}"#, + tcp("192.0.2.1/32", 443) + ), + "", + ), + ), + entry( + "tool:u", + &with_default(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#), ""), ), ]), linux_x64(), @@ -621,13 +995,31 @@ fn mixed_versions_across_inputs_conflict() { error.message(), "[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)" ); + assert_eq!( + reason_of(catalog.resolve_sandbox_policy(vec!["d", "n"], &weak())), + ErrorReason::CompositionConflict + ); + assert_eq!( + reason_of(catalog.resolve_sandbox_policy(vec!["u", "b"], &weak())), + ErrorReason::CompositionConflict + ); + // A single selected policy without additions passes through whole. assert_eq!( catalog - .resolve_sandbox_policy("a", &weak()) + .resolve_sandbox_policy("u", &weak()) .unwrap() .unwrap() .to_json(), - j(r#"{"version":"0.8.0-alpha"}"#) + j(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#)) + ); + // Only one component needs network: its network passes through. + assert_eq!( + catalog + .resolve_sandbox_policy(vec!["d", "b"], &weak()) + .unwrap() + .unwrap() + .network, + Some(j(r#"{"egress":{"deny":[{"ports":[{"port":22}]}]}}"#)) ); } @@ -640,21 +1032,22 @@ fn inspection_over_the_bundled_catalog() { let info = get_catalog_info().unwrap(); assert_eq!( info.to_json(), - j(r#"{"catalogSchemaVersion":"1","catalogRevision":"2026-09-29.1"}"#) + j(r#"{"catalogSchemaVersion":"1","catalogRevision":"2026-10-02.1"}"#) ); 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"]); - let npm = entries + assert_eq!(ids, ["tool:git", "tool:node", "tool:npm", "tool:ssh"]); + let git = entries .iter() - .find(|e| e.entry_id == "tool:npm") + .find(|e| e.entry_id == "tool:git") .unwrap() .to_json(); + assert_eq!(git.get("versionScheme").unwrap(), &j(r#""intdot""#)); assert_eq!( - npm.get("platformVariants").unwrap().as_array().unwrap()[0], - j(&format!( - r#"{{"platform":"windows","dependencyEntryIds":["tool:node"],"sandboxPolicyVersion":"{V}"}}"# - )) + git.get("versionVariants").unwrap(), + &j(r#"[ + {"versionRange":"vers:intdot/>=2.40|<2.50","dependencyEntryIds":[],"intentAdditions":[{"name":"push","dependencyEntryIds":["tool:ssh"]}],"intents":[]}, + {"versionRange":"vers:intdot/>=2.50|<3","dependencyEntryIds":[],"intentAdditions":[],"intents":[{"name":"bundle-fetch","exampleSubcommands":["fetch --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("sandboxPolicy\"")); @@ -677,7 +1070,7 @@ fn module_level_functions_use_the_bundled_catalog() { assert_eq!(result.policy, None); assert_eq!( result.diagnostics.to_json().get("tools").unwrap(), - &j(r#"[{"inputIndex":0,"matches":[]}]"#) + &j(r#"[{"inputIndex":0,"status":"tool_unmatched","matches":[]}]"#) ); assert_eq!( reason_of(resolve_sandbox_policy( @@ -693,10 +1086,9 @@ fn module_level_functions_use_the_bundled_catalog() { // --------------------------------------------------------------------------- fn dep(id: &str) -> String { - variants(&[linux( - &format!(r#"{{"version":"{V}"}}"#), - &format!(r#""dependencies":[{{"entryId":"{id}"}}]"#), - )]) + format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"{id}"}}]}}}}"# + ) } #[test] @@ -710,26 +1102,56 @@ fn validation_accepts_minimal_and_rejects_cycles_and_missing_dependencies() { .unwrap_err(); assert_eq!( error.message(), - "[policy_validation] 'tool:a' on windows/x64: cycle (tool:a -> tool:b -> tool:c -> tool:a)" - .replace("windows", "linux") + "[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":{{"sandboxPolicy":{{"version":"{V}"}},"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", - &variants(&[linux( - &format!(r#"{{"version":"{V}"}}"#), - r#""dependencies":[{"entryId":"tool:b","versionRange":"^1.x"}]"#, - )]), - ), + entry("tool:a", &ranged("vers:npm/>=1.0.0")), entry("tool:b", ""), ], - "versionRange", + "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":{"sandboxPolicy":{"version":"0.9.0-alpha"},"versionRange":"vers:semver/>=1"}}"#, + )], + "unsupported field 'entries[0].default.versionRange'", ); } @@ -741,8 +1163,9 @@ fn validation_selectors() { } else { format!(r#","architecture":"{arch}""#) }; - format!(r#"{{"when":{{"platform":"linux"{a}}},"sandboxPolicy":{{"version":"{V}"}}}}"#) + 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", @@ -760,11 +1183,20 @@ fn validation_selectors() { 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", + &format!( + r#"{{"platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"{V}"}}}}]}}"# + ), + )], + "unsupported field 'entries[0].platformVariants[0].sandboxPolicy'", + ); } #[test] fn validation_policy_rules() { - let p = |policy: &str| vec![entry("tool:a", &variants(&[linux(policy, "")]))]; + let p = |policy: &str| vec![entry("tool:a", &with_default(policy, ""))]; validate_err( p(&format!(r#"{{"version":"{V}","processContainer":{{}}}}"#)), "containment backend", @@ -777,10 +1209,6 @@ fn validation_policy_rules() { p(r#"{"version":"0.9.0"}"#), "not a SandboxPolicy version registered", ); - validate_err( - p(r#"{"version":"0.10.0-alpha"}"#), - "not a SandboxPolicy version registered", - ); validate_err( vec![entry("tool:a", r#"{"extra":1}"#)], "unsupported field 'entries[0].extra'", @@ -791,16 +1219,6 @@ fn validation_policy_rules() { )), "unsupported field", ); - validate_err( - p(&format!( - r#"{{"version":"{V}","network":{{"allowOutbound":true}}}}"# - )), - "unsupported field", - ); - validate_err( - p(&format!(r#"{{"version":"{V}","telemetry":{{}}}}"#)), - "unsupported field", - ); let path = |value: &str| { p(&format!( r#"{{"version":"{V}","filesystem":{{"readonlyPaths":[{}]}}}}"#, @@ -815,7 +1233,7 @@ fn validation_policy_rules() { 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("${project_root}\\x")).unwrap(); + validate(path("${programData}\\Git")).unwrap(); let net = |n: &str| p(&format!(r#"{{"version":"{V}","network":{n}}}"#)); validate_err(net(r#"{"egress":{"default":"allow"}}"#), "default-allow"); validate_err(net(r#"{"ingress":{"default":"allow"}}"#), "default-allow"); @@ -827,10 +1245,6 @@ fn validation_policy_rules() { 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":0}]}]}}"#), - "must be an integer in 1..65535", - ); validate_err( net( r#"{"egress":{"allow":[{"to":[{"cidr":"10.0.0.0/8"}],"ports":[{"port":10,"endPort":5}]}]}}"#, @@ -838,7 +1252,6 @@ fn validation_policy_rules() { "requires a lower or equal 'port'", ); validate(net(r#"{"egress":{"deny":[{"ports":[{"port":22}]}]}}"#)).unwrap(); - validate(net(r#"{"egress":{"default":"deny","allow":[{"to":[{"cidr":"192.0.2.0/24"}],"ports":[{"protocol":"tcp","port":443}]}]}}"#)).unwrap(); validate_err( p(&format!(r#"{{"version":"{V}","timeoutMs":1.5}}"#)), "timeoutMs' must be a positive integer", @@ -859,17 +1272,6 @@ fn validation_identity_rules() { ), ]) .unwrap(); - validate(vec![ - entry( - "tool:a", - r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"}]}"#, - ), - entry( - "tool:b", - r#"{"identity":[{"kind":"purl","value":"pkg:NPM/x"}]}"#, - ), - ]) - .unwrap(); validate_err( vec![entry( "tool:a", @@ -877,13 +1279,6 @@ fn validation_identity_rules() { )], "repeats identity 'invocation-name:a'", ); - validate_err( - vec![entry( - "tool:a", - r#"{"identity":[{"kind":"invocation-name","names":["\u00c9","\u00e9"]}]}"#, - )], - "repeats identity", - ); validate_err( vec![entry( "tool:a", @@ -901,16 +1296,16 @@ fn validation_identity_rules() { validate_err( vec![entry( "tool:a", - r#"{"identity":[{"kind":"invocation-name","names":["bin/a"]}]}"#, + r#"{"identity":[{"kind":"purl","value":"pkg:npm/a","versionRange":">=1"}]}"#, )], - "bare invocation name", + "unsupported field 'entries[0].identity[0].versionRange'", ); validate_err( vec![entry( "tool:a", - r#"{"identity":[{"kind":"sha256","value":"x"}]}"#, + r#"{"identity":[{"kind":"invocation-name","names":["bin/a"]}]}"#, )], - "not a supported identity kind", + "bare invocation name", ); validate_err( vec![ @@ -929,56 +1324,119 @@ fn validation_identity_rules() { } #[test] -fn validation_composition_vocabulary() { - let with_dep = |policy: &str, dep_policy: &str| { - validate(vec![ - entry( - "tool:a", - &variants(&[linux(policy, r#""dependencies":[{"entryId":"tool:b"}]"#)]), +fn validation_overlays_are_additive_only() { + let overlay = |body: &str| { + vec![entry( + "tool:a", + &format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"intents":{{"run":{{}}}}}},"platformVariants":[{{"when":{{"platform":"linux"}},{body}}}]}}"# ), - entry("tool:b", &variants(&[linux(dep_policy, "")])), - ]) + )] }; - with_dep( - &format!(r#"{{"version":"{V}","filesystem":{{"readwritePaths":["${{project_root}}"],"readonlyPaths":["${{node_prefix}}"]}}}}"#), - &format!(r#"{{"version":"{V}","filesystem":{{"readonlyPaths":["${{node_prefix}}","${{git_prefix}}"],"deniedPaths":["${{user_home}}/.ssh"]}}}}"#), - ) + validate(overlay( + r#""policyAdditions":{"filesystem":{"readonlyPaths":["${git_prefix}"]}}"#, + )) .unwrap(); - let err = |r: Result<(), PolicyCatalogError>, needle: &str| { - let m = r.unwrap_err().message().to_string(); - assert!(m.contains(needle), "{m}"); + 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#""intents":{"run":{}}"#), + "redeclares an inherited intent", + ); + validate_err( + overlay(r#""intents":{"Run":{}}"#), + "is not a valid intent name", + ); + validate_err( + overlay(r#""intents":{"x":{"exampleSubcommands":[]}}"#), + "exampleSubcommands", + ); +} + +#[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}]}}"#), + )] }; - err( - with_dep( - &format!( - r#"{{"version":"{V}","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}"# - ), - &format!( - r#"{{"version":"{V}","filesystem":{{"deniedPaths":["${{project_root}}/secrets"]}}}}"# - ), - ), - "'${project_root}/secrets' (deniedPaths) overlaps '${project_root}' (readwritePaths)", + 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"},"intents":{"x":{}}}], + "versionVariants":[{"versionRange":"vers:semver/>=1.0.0","intents":{"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( - with_dep( - &format!(r#"{{"version":"{V}"}}"#), - r#"{"version":"0.8.0-alpha"}"#, - ), + vec![ + entry("tool:a", &dep("tool:b")), + entry("tool:b", &with_default(r#"{"version":"0.8.0-alpha"}"#, "")), + ], "mixed sandboxPolicy.version", ); err( - with_dep( - &format!(r#"{{"version":"{V}","network":{{"egress":{{"default":"deny"}}}}}}"#), - &format!(r#"{{"version":"{V}"}}"#), - ), - "'tool:a' uses 'network', which has no v1 cross-entry composition rule", + vec![ + entry("tool:a", &dep("tool:b")), + entry( + "tool:b", + &with_default(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#), ""), + ), + ], + "'tool:b' uses 'timeoutMs', which has no v1 cross-policy composition rule", ); err( - with_dep( - &format!(r#"{{"version":"{V}"}}"#), - &format!(r#"{{"version":"{V}","timeoutMs":5}}"#), - ), - "'timeoutMs'", + vec![entry( + "tool:a", + &format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","network":{{"egress":{{"deny":[{{"ports":[{{"port":22}}]}}]}}}}}}, + "intents":{{"net":{{"policyAdditions":{{"network":{{"egress":{{"allow":[{}]}}}}}}}}}}}}}}"#, + tcp("192.0.2.1/32", 443) + ), + )], + "'tool:a' uses 'network.egress.deny', which cannot be combined with other network requirements", ); } From 0aa91d3d2f2d806a119abdd7bf6b1996f523c4a3 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:44:24 -0700 Subject: [PATCH 30/50] mxc-sdk, mxc_ffi: expose policy store intents, statuses, and warnings Re-export the new resolution and metadata types from mxc_sdk::policy_store, update its docs for the intent-aware candidate, and cover version and intent selection through the SDK types and across the C ABI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/mxc-sdk/src/policy_store.rs | 27 +++++++++----- src/core/mxc-sdk/tests/policy_store.rs | 50 ++++++++++++++++++++++++-- src/ffi/mxc_ffi/tests/policy_store.rs | 21 +++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/core/mxc-sdk/src/policy_store.rs b/src/core/mxc-sdk/src/policy_store.rs index e7283cdcf..efcbb1cb4 100644 --- a/src/core/mxc-sdk/src/policy_store.rs +++ b/src/core/mxc-sdk/src/policy_store.rs @@ -11,17 +11,25 @@ //! access, launches a sandbox, contacts a network service, or writes state; //! the V1 catalog is compiled in and nothing is downloaded. //! +//! Each catalog entry has one unversioned default plus additive platform, +//! version (`vers` range), and intent overlays. A [`ToolCandidate`] names the +//! tool by package URL (strong) or invocation name (weak fallback), and may +//! carry a `detected_version` and an `intent` such as `fetch` or `push`. +//! //! The names and shapes here are proposed and may change before sign-off (for -//! example, the names may drop "Sandbox", and lookup may gain an intent such -//! as `git pull` versus `git push`). This API is not part of MXC 1.0. +//! example, the names may drop "Sandbox"). This API is not part of MXC 1.0. //! //! ```no_run -//! use mxc_sdk::policy_store::{resolve_sandbox_policy, ResolveContext}; +//! use mxc_sdk::policy_store::{resolve_sandbox_policy, ResolveContext, ToolCandidate}; //! //! let ctx = ResolveContext::new() //! .project_root("/work/repo") //! .symbol("git_prefix", "/usr/bin"); -//! if let Some(policy) = resolve_sandbox_policy("git", &ctx)? { +//! let git = ToolCandidate::new("git") +//! .with_package_url("pkg:generic/git") +//! .with_detected_version("2.45.1") +//! .with_intent("fetch"); +//! if let Some(policy) = resolve_sandbox_policy(git, &ctx)? { //! // Compose `policy` with the caller's own policy, then build a request. //! let _ = policy; //! } @@ -32,10 +40,13 @@ use crate::SandboxPolicy; use mxc_policy_store::tooling::Json; pub use mxc_policy_store::{ - Architecture, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, DependencyRecord, - Diagnostics, EntryMatchRecord, ErrorCode, ErrorReason, IdentityStrength, MatchedIdentity, - Platform, PlatformVariantMetadata, PolicyCatalogError, Provenance, ResolveContext, SymbolMap, - ToolCandidate, ToolInput, ToolInputs, ToolRecord, + Architecture, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, + CatalogInfo, CatalogIntentMetadata, DefaultMetadata, DependencyRecord, Diagnostics, + EntryMatchRecord, ErrorCode, ErrorReason, IdentityStrength, IntentMode, IntentSelection, + MatchedIdentity, Platform, PlatformVariantMetadata, PolicyCatalogError, Provenance, + ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs, ToolRecord, + ToolResolutionStatus, ToolResolutionWarning, ToolWarningCode, VersionScheme, VersionSelection, + VersionStatus, VersionVariantMetadata, Warning, }; /// Result of [`resolve_sandbox_policy_with_diagnostics`]: the same policy diff --git a/src/core/mxc-sdk/tests/policy_store.rs b/src/core/mxc-sdk/tests/policy_store.rs index 67c6e3f94..e4ece4bb9 100644 --- a/src/core/mxc-sdk/tests/policy_store.rs +++ b/src/core/mxc-sdk/tests/policy_store.rs @@ -7,8 +7,8 @@ use mxc_sdk::policy_store::{ binding, get_catalog_info, list_catalog_entries, resolve_sandbox_policy, - resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, Platform, ResolveContext, - ToolCandidate, + resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, IntentMode, Platform, + ResolveContext, ToolCandidate, ToolResolutionStatus, VersionStatus, Warning, }; use serde_json::Value; use std::path::PathBuf; @@ -60,6 +60,52 @@ fn diagnostics_carry_the_same_policy_and_attribution() { ); } +#[test] +fn version_and_intent_select_the_effective_policy() { + let ctx = linux_context().symbol("ssh_prefix", "/usr/lib/ssh"); + let git = |version: &str, intent: &str| { + ToolCandidate::new("git") + .with_package_url("pkg:generic/git") + .with_detected_version(version) + .with_intent(intent) + }; + let push = resolve_sandbox_policy_with_diagnostics(git("2.45.1", "push"), &ctx).unwrap(); + let tool = &push.diagnostics.tools[0]; + assert_eq!( + tool.status, + ToolResolutionStatus::Version(VersionStatus::MatchedVersion) + ); + assert_eq!( + tool.matches[0].intent_selection.as_ref().unwrap().mode, + IntentMode::Named + ); + assert_eq!( + push.diagnostics.resolved_dependencies[0].entry_id, + "tool:ssh" + ); + let policy = push.policy.unwrap(); + assert_eq!( + policy.filesystem.unwrap().readonly_paths, + ["/usr/bin", "/usr/lib/ssh"] + ); + assert_eq!( + policy.network.unwrap().egress.unwrap().allow.unwrap().len(), + 1 + ); + + let unsupported = + resolve_sandbox_policy_with_diagnostics(git("2.45.1", "bundle-fetch"), &ctx).unwrap(); + assert!(unsupported.policy.is_none()); + assert_eq!( + unsupported.diagnostics.tools[0].status, + ToolResolutionStatus::IntentUnsupported + ); + assert!(matches!( + &unsupported.diagnostics.warnings[0], + Warning::Tool(w) if w.code.as_str() == "intent_unsupported" + )); +} + #[test] fn absence_is_none_and_bad_context_is_an_error() { assert!(resolve_sandbox_policy("not-a-known-tool", &linux_context()) diff --git a/src/ffi/mxc_ffi/tests/policy_store.rs b/src/ffi/mxc_ffi/tests/policy_store.rs index 0812ce73d..34429c863 100644 --- a/src/ffi/mxc_ffi/tests/policy_store.rs +++ b/src/ffi/mxc_ffi/tests/policy_store.rs @@ -74,6 +74,27 @@ fn diagnostics_include_attribution() { assert!(json["policy"].is_object()); } +#[test] +fn intent_and_version_cross_the_boundary() { + let (status, json, _, message) = call( + mxc_resolve_sandbox_policy_with_diagnostics_json, + r#"{"tools":{"invocationName":"git","packageUrl":"pkg:generic/git","detectedVersion":"2.30.0","intent":"fetch"},"context":{"platform":"linux","architecture":"x64","projectRoot":"/w","symbols":{"git_prefix":"/usr/bin"}}}"#, + ); + 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["matches"][0]["intentSelection"]["requested"], "fetch"); + assert_eq!( + json["diagnostics"]["warnings"][0]["code"], + "version_out_of_range" + ); + assert_eq!( + json["policy"]["network"]["egress"]["allow"][0]["ports"][0]["port"], + 443 + ); +} + #[test] fn store_failures_carry_status_and_reason() { let (status, json, reason, message) = call( From ef31e011c59729f2ab84490388ade89011bdd5ed Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:46:13 -0700 Subject: [PATCH 31/50] Node SDK: policy store intents, statuses, and structured warnings Add ToolCandidate.intent, per-input status, version and intent selection, structured ToolResolutionWarning entries, and the new entry metadata (versionScheme, default, platform and version overlays) to the prototype policy store types, and cover version-range and intent selection, out-of-range / unparseable / unsupported pairs, and pair composition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/node/src/index.ts | 9 ++ sdk/node/src/policy-store.ts | 114 ++++++++++++++++++++--- sdk/node/tests/unit/policy-store.test.ts | 80 +++++++++++++++- 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index 622dcce04..50e200920 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -170,9 +170,18 @@ export { type ToolInput, type ResolveContext, type IdentityStrength, + type VersionStatus, + type ToolResolutionStatus, + type VersionSelection, + type IntentSelection, + type ToolResolutionWarning, + type PolicyResolutionWarning, type PolicyResolutionDiagnostics, type SandboxConfigResolution, type CatalogIdentityMetadata, + type CatalogVersionScheme, + type CatalogIntentMetadata, + type CatalogAdditionsMetadata, type CatalogEntryMetadata, type CatalogInfo, resolveSandboxPolicy, diff --git a/sdk/node/src/policy-store.ts b/sdk/node/src/policy-store.ts index ddb0e0eb6..0b3e6c10d 100644 --- a/sdk/node/src/policy-store.ts +++ b/sdk/node/src/policy-store.ts @@ -12,9 +12,14 @@ * access, launches a sandbox, contacts a network service, or writes state; the * V1 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 floor. + * * Names and shapes are proposed and may change before sign-off (for example, - * the names may drop "Sandbox", and lookup may gain an intent such as - * `git pull` versus `git push`). This API is not part of MXC 1.0. + * the names may drop "Sandbox"). This API is not part of MXC 1.0. * * All four functions run in-process through `mxc_ffi`, so they require the * native library that ships with this package. @@ -33,12 +38,28 @@ export type CatalogArchitecture = 'x64' | 'arm64'; /** A tool to look up, identified by invocation name and optional identity. */ export interface ToolCandidate { - /** The command name, for example `git`. */ + /** + * 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. */ + /** + * A Package URL without a version, for example `pkg:npm/npm`. A strong + * identity. A version embedded in the purl is ignored with a warning. + */ packageUrl?: string; - /** The detected tool version, used to filter `packageUrl` version ranges. */ + /** + * 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 policy plus every intent of the effective policy applies. An intent + * the effective policy does not define contributes nothing. + */ + intent?: string; } /** A string is shorthand for `{ invocationName: tool }`. */ @@ -63,23 +84,69 @@ export interface ResolveContext { /** Strength of a matched identity predicate. */ export type IdentityStrength = 'strong' | 'weak'; +/** How a detected version selected the effective policy. */ +export type VersionStatus = + | 'matched_default' + | 'matched_version' + | 'version_out_of_range' + | 'version_unparseable'; + +/** Per-input status: the version status, or why the input contributes nothing. */ +export type ToolResolutionStatus = VersionStatus | 'intent_unsupported' | 'tool_unmatched'; + +/** Version selection for one matched entry. */ +export interface VersionSelection { + status: VersionStatus; + detectedVersion?: string; + /** Present only for `matched_version`. */ + selectedVersionRange?: string; +} + +/** Intent selection for one matched entry. */ +export interface IntentSelection { + requested?: string; + mode: 'named' | 'all' | 'unsupported'; + /** Selected intent names, sorted; empty when unsupported. */ + selected: string[]; +} + +/** A structured per-input warning. */ +export interface ToolResolutionWarning { + code: 'version_out_of_range' | 'version_unparseable' | 'intent_unsupported' | 'tool_unmatched'; + inputIndex: number; + entryId?: string; + detectedVersion?: string; + intent?: string; + message: string; +} + +/** A diagnostics warning: structured per input, or free text. */ +export type PolicyResolutionWarning = string | ToolResolutionWarning; + /** Match attribution and warnings from one resolution pass. */ export interface PolicyResolutionDiagnostics { catalogRevision: string; tools: Array<{ inputIndex: number; + status: ToolResolutionStatus; + /** At most one entry; empty for `tool_unmatched`. */ matches: Array<{ entryId: string; entryRevision: number; matchedIdentities: Array<{ kind: string; strength: IdentityStrength }>; + versionSelection: VersionSelection; + /** Absent when the version could not be parsed. */ + intentSelection?: IntentSelection; }>; }>; resolvedDependencies: Array<{ entryId: string; entryRevision: number; requiredVersionRange?: string; + versionSelection: VersionSelection; + intentSelection: IntentSelection; }>; - warnings: string[]; + warnings: PolicyResolutionWarning[]; } /** Result of {@link resolveSandboxPolicyWithDiagnostics}. */ @@ -91,22 +158,45 @@ export interface SandboxConfigResolution { /** One identity predicate of a catalog entry. */ export type CatalogIdentityMetadata = - | { kind: 'purl'; value: string; versionRange?: string } + | { kind: 'purl'; value: string } | { kind: 'invocation-name'; names: string[] }; +/** The version scheme an entry's `vers` ranges use. */ +export type CatalogVersionScheme = 'npm' | 'semver' | 'pypi' | 'nuget' | 'intdot'; + +/** 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. */ + intents: CatalogIntentMetadata[]; +} + /** Inspection metadata for one catalog entry. It never exposes a policy body. */ export interface CatalogEntryMetadata { catalogRevision: string; entryId: string; entryRevision: number; displayName: string; + versionScheme: CatalogVersionScheme; identity: CatalogIdentityMetadata[]; - platformVariants: Array<{ - platform: CatalogPlatform; - architecture?: CatalogArchitecture; + default: { dependencyEntryIds: string[]; sandboxPolicyVersion: string; - }>; + intents: CatalogIntentMetadata[]; + }; + platformVariants: Array< + { platform: CatalogPlatform; architecture?: CatalogArchitecture } & CatalogAdditionsMetadata + >; + versionVariants: Array<{ versionRange: string } & CatalogAdditionsMetadata>; provenance: { method: string; sourceRevision: string }; } @@ -124,7 +214,7 @@ export interface CatalogInfo { * @throws {MxcError} with the stable failure reason in `details.reason`: * `malformed_request` (`invalid_context`) for invalid input or context, * `unsupported_containment` (`unsupported_host`), `policy_validation` - * (`invalid_catalog`, `composition_conflict`), or `backend_error` (`integrity`, + * (`invalid_catalog`, `composition_conflict`, `ambiguous_match`), or `backend_error` (`integrity`, * `revision_unavailable`). */ export function resolveSandboxPolicy( diff --git a/sdk/node/tests/unit/policy-store.test.ts b/sdk/node/tests/unit/policy-store.test.ts index 763e181b1..ee7c47455 100644 --- a/sdk/node/tests/unit/policy-store.test.ts +++ b/sdk/node/tests/unit/policy-store.test.ts @@ -40,8 +40,20 @@ describe('policy store (prototype)', { skip }, () => { assert.ok(ids.includes('tool:npm'), `entries: ${ids.join(', ')}`); for (const entry of entries) { assert.ok(!('policy' in entry)); - assert.ok(entry.platformVariants.length > 0); + assert.ok(!('sandboxPolicy' in entry.default)); + assert.match(entry.default.sandboxPolicyVersion, /\S/); } + const git = entries.find((entry) => entry.entryId === 'tool:git'); + assert.ok(git !== undefined); + assert.strictEqual(git.versionScheme, 'intdot'); + assert.deepStrictEqual( + git.default.intents.map((intent) => intent.name).sort(), + ['fetch', 'local', 'push'], + ); + assert.deepStrictEqual( + git.versionVariants.map((variant) => variant.versionRange), + ['vers:intdot/>=2.40|<2.50', 'vers:intdot/>=2.50|<3'], + ); }); it('resolves a strong identity to an SDK SandboxPolicy', () => { @@ -89,6 +101,66 @@ describe('policy store (prototype)', { skip }, () => { ); }); + const git = (detectedVersion: string | undefined, intent: string | undefined) => ({ + invocationName: 'git', + packageUrl: 'pkg:generic/git', + ...(detectedVersion === undefined ? {} : { detectedVersion }), + ...(intent === undefined ? {} : { intent }), + }); + const gitContext = { + ...context, + symbols: { git_prefix: '/usr/bin', ssh_prefix: '/usr/lib/ssh', temp_dir: '/tmp' }, + }; + + it('selects a version range and an intent', () => { + const push = resolveSandboxPolicyWithDiagnostics(git('2.45.1', 'push'), gitContext); + const tool = push.diagnostics.tools[0]; + assert.strictEqual(tool.status, 'matched_version'); + assert.strictEqual(tool.matches[0].versionSelection.selectedVersionRange, 'vers:intdot/>=2.40|<2.50'); + assert.deepStrictEqual(tool.matches[0].intentSelection, { + requested: 'push', + mode: 'named', + selected: ['push'], + }); + assert.strictEqual(push.diagnostics.resolvedDependencies[0].entryId, 'tool:ssh'); + assert.deepStrictEqual(push.policy?.filesystem?.readonlyPaths, ['/usr/bin', '/usr/lib/ssh']); + assert.strictEqual(push.policy?.network?.egress?.allow?.length, 1); + }); + + it('reports out-of-range, unparseable, and unsupported pairs with structured warnings', () => { + const outOfRange = resolveSandboxPolicyWithDiagnostics(git('2.30.0', 'fetch'), gitContext); + assert.strictEqual(outOfRange.diagnostics.tools[0].status, 'version_out_of_range'); + assert.ok(outOfRange.policy !== undefined); + const warning = outOfRange.diagnostics.warnings[0]; + assert.ok(typeof warning === 'object'); + assert.strictEqual(warning.code, 'version_out_of_range'); + assert.strictEqual(warning.entryId, 'tool:git'); + + const unparseable = resolveSandboxPolicyWithDiagnostics(git('banana', 'fetch'), gitContext); + assert.strictEqual(unparseable.diagnostics.tools[0].status, 'version_unparseable'); + assert.strictEqual(unparseable.policy, undefined); + + const unsupported = resolveSandboxPolicyWithDiagnostics( + git('2.45.1', 'bundle-fetch'), + gitContext, + ); + assert.strictEqual(unsupported.diagnostics.tools[0].status, 'intent_unsupported'); + assert.strictEqual( + unsupported.diagnostics.tools[0].matches[0].versionSelection.status, + 'matched_version', + ); + assert.strictEqual(unsupported.policy, undefined); + }); + + it('composes pairs: a tool without network does not veto another', () => { + const resolution = resolveSandboxPolicyWithDiagnostics( + [git(undefined, 'local'), git(undefined, 'fetch'), 'no-such-tool'], + gitContext, + ); + assert.strictEqual(resolution.policy?.network?.egress?.allow?.length, 1); + assert.strictEqual(resolution.diagnostics.tools[2].status, 'tool_unmatched'); + }); + it('throws MxcError with a stable reason for an invalid context', () => { assert.throws( () => @@ -100,8 +172,12 @@ describe('policy store (prototype)', { skip }, () => { assert.ok(error instanceof MxcError); assert.strictEqual(error.code, 'malformed_request'); assert.strictEqual(error.details?.reason, 'invalid_context'); - return true; + return true; }, ); + assert.throws( + () => resolveSandboxPolicy(git(undefined, ''), gitContext), + (error: unknown) => error instanceof MxcError && error.details?.reason === 'invalid_context', + ); }); }); From e09484fed60169daffc8668d269d88e0dd1a0a61 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:49:06 -0700 Subject: [PATCH 32/50] C# SDK: policy store intents, statuses, and structured warnings Add ToolInput.Intent, per-input Status, VersionSelection and IntentSelection, ResolutionWarning (a string or a structured per-input warning, read by a strict converter), and the new entry metadata (VersionScheme, Default, platform and version overlays). Failures still throw MxcException with Reason; ambiguous_match is documented. The tests replay the shared bundled-catalog conformance fixtures through the C# surface and cover version and intent selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Mxc.Sdk.Tests.csproj | 2 + .../MxcPolicyStoreTests.cs | 159 +++++++++++- sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs | 1 + .../Microsoft.Mxc.Sdk/MxcPolicyStore.cs | 233 ++++++++++++++++-- 4 files changed, 376 insertions(+), 19 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index ff77ddb95..fdb02e056 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -79,6 +79,8 @@ tests (src/ffi/mxc_ffi). They live outside this SDK because both languages assert against them; see tests/policy/README.md. --> + + diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs index e1c8ee0d7..44101cba7 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Mxc.Sdk; using Xunit; @@ -40,9 +42,25 @@ public void ListCatalogEntries_ReturnsMetadata() var npm = Assert.Single(entries, entry => entry.EntryId == "tool:npm"); Assert.Contains(npm.Identity, identity => identity.Kind == "purl" && identity.Value == "pkg:npm/npm"); - Assert.Contains(npm.PlatformVariants, variant => - variant.Platform == CatalogPlatforms.Linux - && variant.DependencyEntryIds.Contains("tool:node")); + Assert.Equal("npm", npm.VersionScheme); + Assert.Contains("tool:node", npm.Default.DependencyEntryIds); + + 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()); + var windows = Assert.Single(git.PlatformVariants); + Assert.Equal(CatalogPlatforms.Windows, windows.Platform); + Assert.Null(windows.Architecture); + Assert.Collection( + git.VersionVariants, + older => + { + Assert.Equal("vers:intdot/>=2.40|<2.50", older.VersionRange); + var push = Assert.Single(older.IntentAdditions); + Assert.Equal("push", push.Name); + Assert.Equal(new[] { "tool:ssh" }, push.DependencyEntryIds); + }, + newer => Assert.Equal("bundle-fetch", Assert.Single(newer.Intents).Name)); } [Fact] @@ -102,6 +120,141 @@ public void ResolveSandboxPolicyWithDiagnostics_ReportsDependencies() Assert.Contains(resolution.Diagnostics.ResolvedDependencies, dep => dep.EntryId == "tool:node"); } + private static readonly ResolveContext GitContext = LinuxContext with + { + Symbols = new Dictionary + { + ["git_prefix"] = "/usr/bin", + ["ssh_prefix"] = "/usr/lib/ssh", + ["temp_dir"] = "/tmp", + }, + }; + + private static ToolInput Git(string? version, string? intent) => + new("git") { PackageUrl = "pkg:generic/git", DetectedVersion = version, Intent = intent }; + + [Fact] + public void ResolveSandboxPolicyWithDiagnostics_SelectsAVersionRangeAndAnIntent() + { + var push = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics(Git("2.45.1", "push"), GitContext); + + var tool = Assert.Single(push.Diagnostics.Tools); + Assert.Equal("matched_version", tool.Status); + var match = Assert.Single(tool.Matches); + Assert.Equal("vers:intdot/>=2.40|<2.50", match.VersionSelection.SelectedVersionRange); + Assert.Equal("push", match.IntentSelection!.Requested); + Assert.Equal("named", match.IntentSelection.Mode); + Assert.Equal("tool:ssh", Assert.Single(push.Diagnostics.ResolvedDependencies).EntryId); + Assert.Equal(new[] { "/usr/bin", "/usr/lib/ssh" }, push.Policy!.Filesystem!.ReadonlyPaths); + Assert.Single(push.Policy.Network!.Egress!.Allow!); + } + + [Fact] + public void ResolveSandboxPolicyWithDiagnostics_ReportsStructuredWarnings() + { + var outOfRange = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics(Git("2.30.0", "bundle-fetch"), GitContext); + + Assert.Null(outOfRange.Policy); + var tool = Assert.Single(outOfRange.Diagnostics.Tools); + Assert.Equal("intent_unsupported", tool.Status); + Assert.Equal("version_out_of_range", Assert.Single(tool.Matches).VersionSelection.Status); + Assert.Collection( + outOfRange.Diagnostics.Warnings, + warning => + { + Assert.Equal("version_out_of_range", warning.Code); + Assert.Equal(0, warning.InputIndex); + Assert.Equal("tool:git", warning.EntryId); + Assert.Equal("2.30.0", warning.DetectedVersion); + }, + warning => Assert.Equal("intent_unsupported", warning.Code)); + + var weak = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics( + "git", + GitContext with { AllowWeakIdentityFallback = true }); + var text = Assert.Single(weak.Diagnostics.Warnings); + Assert.Null(text.Code); + Assert.Contains("weak identity", text.Message); + } + + [Fact] + public void ResolveSandboxPolicy_PairsCompose() + { + var policy = MxcPolicyStore.ResolveSandboxPolicy( + new[] { Git(null, "local"), Git(null, "fetch"), Git(null, "push") }, + GitContext); + + Assert.Equal(2, policy!.Network!.Egress!.Allow!.Count); + } + + [Fact] + public void ResolveSandboxPolicy_EmptyIntentIsInvalid() + { + var error = Assert.Throws(() => + MxcPolicyStore.ResolveSandboxPolicy(Git(null, string.Empty), GitContext)); + + Assert.Equal("invalid_context", error.Reason); + } + + private static readonly JsonSerializerOptions FixtureJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private static ToolInput FixtureTool(JsonElement tool) => tool.ValueKind == JsonValueKind.String + ? tool.GetString()! + : tool.Deserialize(FixtureJsonOptions)!; + + /// + /// Every host-independent bundled conformance case gives the same result + /// through the C# surface as through the Rust crates. + /// + [Fact] + public void ResolveSandboxPolicyWithDiagnostics_MatchesTheBundledConformanceFixtures() + { + using var stream = typeof(MxcPolicyStoreTests).Assembly + .GetManifestResourceStream("PolicyStore.bundled-catalog.json")!; + using var fixture = JsonDocument.Parse(stream); + var ran = 0; + foreach (var testCase in fixture.RootElement.GetProperty("cases").EnumerateArray()) + { + if (testCase.TryGetProperty("host", out _)) + { + continue; + } + + var name = testCase.GetProperty("name").GetString(); + var raw = testCase.GetProperty("tools"); + var tools = raw.ValueKind == JsonValueKind.Array + ? raw.EnumerateArray().Select(FixtureTool).ToArray() + : new[] { FixtureTool(raw) }; + var context = testCase.TryGetProperty("context", out var c) + ? c.Deserialize(FixtureJsonOptions) + : null; + + if (testCase.TryGetProperty("expectError", out var expectError)) + { + var error = Assert.Throws(() => + MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics(tools, context)); + Assert.Equal(expectError.GetProperty("reason").GetString(), error.Reason); + } + else + { + var expected = MxcPolicyStore.ParseResolution(testCase.GetProperty("expect").GetRawText()); + var actual = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics(tools, context); + Assert.True( + JsonSerializer.Serialize(expected, FixtureJsonOptions) == JsonSerializer.Serialize(actual, FixtureJsonOptions), + name); + } + + ran++; + } + + Assert.True(ran >= 15, $"ran {ran} cases"); + } + [Fact] public void ResolveSandboxPolicy_InvalidContextThrowsWithStableReason() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs index c361b6556..c226e2e90 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs @@ -41,6 +41,7 @@ public sealed class MxcException : Exception /// /// PROTOTYPE, pending API review. The policy store's stable failure /// reason: invalid_catalog, composition_conflict, + /// ambiguous_match, /// invalid_context, unsupported_host, integrity, or /// revision_unavailable. for every failure /// that did not come from . diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs index 30f84986a..2d02b8c6f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs @@ -22,9 +22,16 @@ namespace Microsoft.Mxc.Sdk; /// V1 catalog is compiled in and nothing is downloaded. /// /// +/// Each catalog entry has one unversioned default plus additive platform, +/// version (purl vers range), and intent overlays. A +/// may carry a +/// and an (for example fetch versus +/// push for git); each tool and intent pair resolves independently, and +/// the pairs compose into one floor. +/// +/// /// Names and shapes are proposed and may change before sign-off (for example, -/// the names may drop "Sandbox", and lookup may gain an intent such as -/// git pull versus git push). This API is not part of MXC 1.0. +/// the names may drop "Sandbox"). This API is not part of MXC 1.0. /// /// /// Failures throw : @@ -225,7 +232,7 @@ internal static SandboxConfigResolution ParseResolution(string json) } private static readonly ResolutionDiagnostics EmptyDiagnostics = - new(string.Empty, Array.Empty(), Array.Empty(), Array.Empty()); + new(string.Empty, Array.Empty(), Array.Empty(), Array.Empty()); private sealed record NativeRequest( [property: JsonPropertyName("tools")] IReadOnlyList Tools, @@ -267,14 +274,29 @@ public static class CatalogArchitectures public sealed record ToolInput( [property: JsonPropertyName("invocationName")] string InvocationName) { - /// A verified Package URL, for example pkg:npm/npm. A strong identity. + /// + /// A Package URL without a version, for example pkg:npm/npm. A strong + /// identity. A version embedded in the purl is ignored with a warning. + /// [JsonPropertyName("packageUrl")] public string? PackageUrl { get; init; } - /// The detected tool version, compared with reviewed version ranges. + /// + /// The tool version the caller detected. It selects at most one reviewed + /// version range; no other version evidence is inferred. + /// [JsonPropertyName("detectedVersion")] public string? DetectedVersion { get; init; } + /// + /// The intended operation, for example fetch or push. When + /// omitted, the base policy plus every intent of the effective policy + /// applies. An intent the effective policy does not define contributes + /// nothing. + /// + [JsonPropertyName("intent")] + public string? Intent { get; init; } + /// String shorthand for new ToolInput(name). public static implicit operator ToolInput(string invocationName) => new(invocationName); } @@ -312,27 +334,176 @@ public sealed record ResolveContext /// strong or weak. public sealed record MatchedIdentity(string Kind, string Strength); +/// How a detected version selected the effective policy. +/// +/// 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 of the effective policy were selected. +/// named, all, or unsupported. +/// Selected intent names, sorted; empty when unsupported. +public sealed record IntentSelection(string Mode, IReadOnlyList Selected) +{ + /// The requested intent, when one was supplied. + public string? Requested { get; init; } +} + /// One entry matched by one input. public sealed record ToolMatch( string EntryId, int EntryRevision, - IReadOnlyList MatchedIdentities); + IReadOnlyList MatchedIdentities, + VersionSelection VersionSelection, + IntentSelection? IntentSelection = null); /// Per-input attribution, in input order. -public sealed record ToolDiagnostics(int InputIndex, IReadOnlyList Matches); +/// The input's position. +/// +/// The pair's version status (see ), or +/// intent_unsupported / tool_unmatched when it contributes nothing. +/// +/// At most one entry; empty for tool_unmatched. +public sealed record ToolDiagnostics(int InputIndex, string Status, IReadOnlyList Matches); /// A dependency pulled in by a match. public sealed record ResolvedDependency( string EntryId, int EntryRevision, - string? RequiredVersionRange = null); + VersionSelection VersionSelection, + IntentSelection IntentSelection) +{ + /// The dependency's declared vers range, when one is declared. + public string? RequiredVersionRange { get; init; } +} + +/// +/// A diagnostics warning. Free-text warnings carry only +/// ; structured per-input warnings also carry +/// and . +/// +/// The human-readable warning. +[JsonConverter(typeof(ResolutionWarningConverter))] +public sealed record ResolutionWarning(string Message) +{ + /// + /// version_out_of_range, version_unparseable, + /// intent_unsupported, or tool_unmatched; + /// for a free-text warning. + /// + public string? Code { get; init; } + + /// The input the structured warning is about. + public int? InputIndex { get; init; } + + /// The matched entry, when there is one. + public string? EntryId { get; init; } + + /// The caller's detected version, when supplied. + public string? DetectedVersion { get; init; } + + /// The requested intent, when supplied. + public string? Intent { get; init; } +} + +/// Reads a warning that is either a string or a structured object. +internal sealed class ResolutionWarningConverter : JsonConverter +{ + public override ResolutionWarning Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new ResolutionWarning(reader.GetString()!); + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("A warning must be a string or an object."); + } + + using var document = JsonDocument.ParseValue(ref reader); + string? message = null, code = null, entryId = null, detectedVersion = null, intent = null; + int? inputIndex = null; + foreach (var property in document.RootElement.EnumerateObject()) + { + switch (property.Name) + { + case "message": message = property.Value.GetString(); break; + case "code": code = property.Value.GetString(); break; + case "inputIndex": inputIndex = property.Value.GetInt32(); break; + case "entryId": entryId = property.Value.GetString(); break; + case "detectedVersion": detectedVersion = property.Value.GetString(); break; + case "intent": intent = property.Value.GetString(); break; + default: throw new JsonException($"Unexpected warning field '{property.Name}'."); + } + } + + if (message is null || code is null || inputIndex is null) + { + throw new JsonException("A structured warning needs code, inputIndex, and message."); + } + + return new ResolutionWarning(message) + { + Code = code, + InputIndex = inputIndex, + EntryId = entryId, + DetectedVersion = detectedVersion, + Intent = intent, + }; + } + + public override void Write( + Utf8JsonWriter writer, + ResolutionWarning value, + JsonSerializerOptions options) + { + if (value.Code is null) + { + writer.WriteStringValue(value.Message); + return; + } + + writer.WriteStartObject(); + writer.WriteString("code", value.Code); + writer.WriteNumber("inputIndex", value.InputIndex ?? 0); + if (value.EntryId is not null) + { + writer.WriteString("entryId", value.EntryId); + } + + if (value.DetectedVersion is not null) + { + writer.WriteString("detectedVersion", value.DetectedVersion); + } + + if (value.Intent is not null) + { + writer.WriteString("intent", value.Intent); + } + + writer.WriteString("message", value.Message); + writer.WriteEndObject(); + } +} /// Match attribution and warnings from one resolution pass. public sealed record ResolutionDiagnostics( string CatalogRevision, IReadOnlyList Tools, IReadOnlyList ResolvedDependencies, - IReadOnlyList Warnings); + IReadOnlyList Warnings); /// /// PROTOTYPE, pending API review. Result of @@ -352,19 +523,46 @@ public sealed record CatalogIdentityMetadata(string Kind) /// The package URL, for purl. public string? Value { get; init; } - /// The reviewed version range, for purl, when declared. - public string? VersionRange { get; init; } - /// The names, for invocation-name. public IReadOnlyList? Names { get; init; } } -/// Platform variant metadata. -public sealed record CatalogVariantMetadata( +/// 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, + string SandboxPolicyVersion, + 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, - string SandboxPolicyVersion); + IReadOnlyList IntentAdditions, + IReadOnlyList Intents); + +/// 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 Intents); /// Entry provenance. public sealed record CatalogProvenance(string Method, string SourceRevision); @@ -375,6 +573,9 @@ public sealed record CatalogEntryMetadata( string EntryId, int EntryRevision, string DisplayName, + string VersionScheme, IReadOnlyList Identity, - IReadOnlyList PlatformVariants, + CatalogDefaultMetadata Default, + IReadOnlyList PlatformVariants, + IReadOnlyList VersionVariants, CatalogProvenance Provenance); From f95e525d69ee2d6cff1e05de5c9fc5690ee9d8d6 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Fri, 2 Oct 2026 15:54:19 -0700 Subject: [PATCH 33/50] Docs: policy store default + overlay model, intents, and statuses Update the policy store docs and SDK READMEs for the implemented model: one unversioned default per entry with additive platform, version (purl vers), and intent overlays; per tool-and-intent statuses; composition that satisfies every requested pair; ambiguous_match; build-time effective-policy validation and the generated reviewer view. Examples now show a git fetch and push with a detected version and intent. Still labeled a prototype pending API review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 87 +++- docs/policy-store/design.md | 615 +++++++++++++--------------- sdk/dotnet/README.md | 23 +- sdk/node/README.md | 12 +- src/core/mxc-sdk/README.md | 19 +- src/core/mxc_policy_store/README.md | 24 +- 6 files changed, 410 insertions(+), 370 deletions(-) diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index ceaaceca5..7a82da884 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -12,7 +12,9 @@ own policy and ceilings, and passes the result to the SDK's existing sandbox APIs. It is complementary to Learning Mode, not a replacement. - [design.md](design.md) — the design proposal, updated for the design review - outcome. + outcome and the entry model below. +- [`catalog/views/`](../../src/core/mxc_policy_store/catalog/views/) — a + generated reviewer view of every effective policy in each revision. ## Where it lives @@ -32,35 +34,68 @@ into `mxc_sdk::SandboxPolicy`. The Node and C# SDKs send a JSON request `SandboxPolicy` types. Resolution stays outside `mxc_engine`: it never selects a backend or launches a sandbox. +## Entry model + +Each catalog entry has one unversioned **default** (a `sandboxPolicy`, 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 +can add paths, outbound allow rules, dependencies, and intents; 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. See [design §4](design.md#4-data-model). + ## Resolution pipeline -One call resolves one input or an array of inputs in a single pass: +One call resolves one input or an array of inputs in a single pass. Each +input is a tool candidate (`invocationName`, optional `packageUrl`, +`detectedVersion`, and `intent`), or a bare name. 1. **Validate inputs and context.** Invalid input fails as `malformed_request` (`invalid_context`), never as absence. 2. **Load the revision** — the requested `catalogRevision`, or the bundled default. A missing revision is `backend_error` (`revision_unavailable`); unreadable bundled data is `backend_error` (`integrity`). -3. **Match each input additively** in `entryId` order. A `purl` match is - strong; an `invocation-name` match is weak and needs - `allowWeakIdentityFallback`. Names and paths fold case on Windows and macOS - and are exact on Linux. -4. **Select a variant.** The exact architecture wins over the - architecture-neutral variant; another architecture is never a fallback. An - omitted architecture uses the device's native system architecture, not - the process architecture. -5. **Close over dependencies**, depth-first, rejecting cycles. -6. **Apply the composition limits**: one `sandboxPolicy.version`, and only - filesystem lists when more than one entry is selected. +3. **Match each input to at most one entry.** A `purl` match is strong; an + `invocation-name` match is weak and needs `allowWeakIdentityFallback`. + Names compare case-insensitively on Windows and macOS and exactly on Linux. + A version embedded in `packageUrl` is ignored with a warning. Candidates + are ranked by strength, then intent support, then architecture + specificity; a tie fails as `policy_validation` (`ambiguous_match`). +4. **Build the effective policy.** The exact architecture overlay wins over + the neutral one; another architecture is never a fallback. An omitted + architecture uses the device's native system architecture, with a warning. + `detectedVersion` selects at most one version overlay. +5. **Select the intent** and record a per-input status: + + | 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 policy plus every intent. Non-default outcomes + also appear as structured warnings. Other inputs still resolve. +6. **Close over dependencies**, depth-first, rejecting cycles. Dependencies + contribute their default, platform overlay, and all intents. 7. **Resolve symbols.** `project_root` comes only from `projectRoot`. A missing required symbol yields no policy plus a warning naming it; the resolver never returns a partial policy. -8. **Compose.** Paths are substituted, normalized, and de-duplicated per - access class. Overlapping paths in different classes fail as - `policy_validation` (`composition_conflict`). +8. **Compose** to satisfy every contributing pair, and never more. Paths are + substituted, normalized, and de-duplicated; read-write supersedes + read-only; a catalog deny that overlaps a grant is removed; outbound allow + rules are unioned, so a tool without network needs never vetoes another's. + Each adjustment produces a warning. Mixed policy versions and anything the + model cannot express fail as `policy_validation` + (`composition_conflict`). Verifying that the executable really carries the strong identity passed in -`packageUrl` is the caller's job. +`packageUrl`, and that `detectedVersion` is accurate, is the caller's job. +The caller's own restrictions always win over the floor. ## Failures @@ -71,9 +106,10 @@ on the Node `MxcError`, `MxcException.Reason` in C#, and | Code | Reason | Meaning | |---|---|---| | `policy_validation` | `invalid_catalog` | Catalog data violates the contract, or a resolved field does not fit the SDK `SandboxPolicy`. | -| `policy_validation` | `composition_conflict` | The selected entries cannot be composed under the V1 rules. | +| `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 native architecture cannot be determined. | +| `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. | @@ -87,13 +123,20 @@ Published revisions are immutable. Never edit a file under 1. Copy the latest revision to `catalog/revisions/.json` and set its `catalogRevision`. -2. Make the change. Bump `entryRevision` for every entry that changes. +2. Make the change. 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. Append `{ catalogRevision, file }` to the manifest and point `defaultRevision` at it. There is no separate catalog digest: the data is compiled into the native library and inherits MXC package signing. 4. Add or update a case in `conformance/fixtures/bundled-catalog.json`. Every - entry in the default revision needs one. -5. Run the checks (from `src/`): + 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 into the + fixtures by hand. +5. Regenerate the reviewer view with `MXC_POLICY_STORE_UPDATE_VIEWS=1` + (`cargo test -p mxc_policy_store --test catalog_validation`) and review it. +6. Run the checks (from `src/`): ```text cargo test -p mxc_policy_store diff --git a/docs/policy-store/design.md b/docs/policy-store/design.md index f0a9c546e..d29095e58 100644 --- a/docs/policy-store/design.md +++ b/docs/policy-store/design.md @@ -10,8 +10,14 @@ standalone library, or CLI; it is not part of MXC 1.0; V1 data is bundled statically; a resolved policy is a best-effort floor; Learning Mode is complementary; and the API remains pending API review. Changed sections: Status, §1 (feature impact), §2, §5 (introduction), §6, §7, §8, §11, §12, -§13, and §14. The data model and lookup semantics (§4, §5.1–§5.3, §9, §10) -are unchanged. +§13, and §14. + +Updated again on 2026-10-02 for the entry model the prototype now implements: +one unversioned default per entry with additive platform, version, and intent +overlays; per tool-and-intent resolution statuses; and composition that +satisfies every requested pair. Changed sections: §4, §5.1, §5.2, §7, §9, +§12, and §13. The canonical design document remains the source of truth; this +copy summarizes the prototype's behavior and is not a substitute for it. --> # Feature Spec: Known-tool Policy Floors @@ -96,7 +102,7 @@ Defaults and omission behavior are: - Omitted `ResolveContext.architecture` uses the device's native system architecture, not the architecture of the calling process or a detected tool build. Explicit caller selection takes precedence. See the selection - rules and emulation risk in [§4.4](#44-platform-variants). + rules and emulation risk in [§4.4](#44-platform-and-architecture). - Omitted `ResolveContext.catalogRevision` uses the catalog revision bundled with the SDK. - Omitted `ResolveContext.allowWeakIdentityFallback` is `false`. @@ -105,7 +111,8 @@ Defaults and omission behavior are: values. A selected entry with an unresolved required symbol is not resolvable. - Omitted `packageUrl` or `detectedVersion` supplies no matching evidence. The - resolver does not fabricate either value. + resolver does not fabricate either value or infer a version from anything + else. Omitted `intent` selects the base policy plus every intent. - If no policy can be resolved, `resolveSandboxPolicy` returns `undefined`. `resolveSandboxPolicyWithDiagnostics` instead returns a result whose `policy` is `undefined`, preserving the diagnostics. The consumer's restrictive baseline @@ -113,7 +120,7 @@ Defaults and omission behavior are: ## 2. Ownership boundary -MXC owns an integrity-validated, versioned, read-only data set of known-tool +MXC owns a validated, versioned, read-only data set of known-tool sandbox requirements, the resolver that reads it, and the SDK APIs that expose it, alongside the existing `SandboxPolicy` contract. The data is reviewed and released with the MXC SDKs. @@ -145,10 +152,10 @@ required to grant. | #779 (config floors) | This document (policy store) | |---|---| -| One `schemaVersion` for the whole table | Four separate version dimensions: `catalogSchemaVersion`, `catalogRevision`, per-entry `entryRevision`, and per-variant `sandboxPolicy.version` ([§4.1](#41-versions)) | -| Strongest satisfied identity predicate describes a match | All eligible matching entries contribute; identity evidence is retained without stronger matches suppressing weaker ones ([§4.3](#43-identity)) | -| One `sandboxPolicy` per entry; `when.platform` only conditions dependencies | One complete `SandboxPolicy` per platform variant; a variant cannot name a containment backend ([§4.4](#44-platform-variants)) | -| `requires` composition unspecified beyond "union" | Composition limited to a small, explicit, field-by-field set for the first contract version; everything else is rejected until a rule exists ([§4.5](#45-dependencies-and-composition)) | +| One `schemaVersion` for the whole table | Separate version dimensions: `catalogSchemaVersion`, `catalogRevision`, per-entry `entryRevision`, the default's `sandboxPolicy.version`, and a per-entry tool `versionScheme` ([§4.1](#41-versions)) | +| Strongest satisfied identity predicate describes a match | Each input selects at most one ranked entry; a tie fails rather than guessing ([§4.3](#43-identity)) | +| One `sandboxPolicy` per entry; `when.platform` only conditions dependencies | One unversioned default per entry plus additive platform, version, and intent overlays; nothing can name a containment backend ([§4.2](#42-entry-shape)) | +| `requires` composition unspecified beyond "union" | Explicit composition rules that satisfy every requested pair; anything inexpressible fails ([§4.6](#46-composition)) | | `getSandboxConfigForTool(tools: string[])` returns one composed policy | `resolveSandboxPolicy` accepts one tool or an array and returns one policy; `resolveSandboxPolicyWithDiagnostics` adds attribution, with catalog inspection kept separate ([§5](#5-api-surface)) | | No revision/publication model | Immutable published catalog revisions; corrections publish a new revision ([§10](#10-immutable-revisions)) | @@ -166,205 +173,176 @@ are stated here rather than implied by that reference. | `catalogSchemaVersion` | Version of the catalog JSON shape itself. | | `catalogRevision` | Immutable identifier for one published, fully reviewed catalog. | | `entryRevision` | Monotonic revision of a single entry, for cache invalidation and audit comparison. | -| `sandboxPolicy.version` | The exact registered `SandboxPolicy` contract version used by one platform variant. | +| `sandboxPolicy.version` | The exact registered `SandboxPolicy` contract version of an entry's default. | +| `versionScheme` | Required per entry: how its tool versions are parsed and compared (`npm`, `semver`, `pypi`, `nuget`, or `intdot`). | These identifiers serve separate purposes and do not advance in lockstep. Registering a new `SandboxPolicy` contract does not change existing catalog -data and therefore does not require a new catalog revision. Migrating a -platform variant to that contract changes the entry's content, so publication -of that migration must increment both `entryRevision` and `catalogRevision`. -A catalog revision may still change without incrementing unaffected entries. -Tool version constraints (`versionRange`, below) are a fifth, orthogonal axis. -They describe which builds of a tool an entry was observed against, not -anything about the catalog. +data. Migrating an entry to that contract changes its content, so the +migration increments both `entryRevision` and `catalogRevision`. Tool +version ranges are an orthogonal axis: they describe which builds of a tool +the reviewed evidence covers, not anything about the catalog. ### 4.2 Entry shape +An entry has exactly one unversioned **default** and any number of +**overlays**. Overlays only add; none can remove or narrow what the default or +another overlay grants. Abbreviated from the bundled `tool:git` entry: + ```json { - "entryId": "tool:npm", - "entryRevision": 3, - "displayName": "npm / npx", + "entryId": "tool:git", + "entryRevision": 1, + "displayName": "Git", + "versionScheme": "intdot", "identity": [ - { "kind": "purl", "value": "pkg:npm/npm", "versionRange": ">=10 <12" }, - { "kind": "invocation-name", "names": ["npm", "npm.cmd", "npx", "npx.cmd"] } + { "kind": "purl", "value": "pkg:generic/git" }, + { "kind": "invocation-name", "names": ["git", "git.exe"] } ], - "platformVariants": [ - { - "when": { "platform": "windows" }, - "dependencies": [{ "entryId": "tool:node", "versionRange": ">=22" }], - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { - "readonlyPaths": ["${npm_prefix}"], - "readwritePaths": ["${project_root}", "${npm_cache}"] - } - } + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "filesystem": { "readonlyPaths": ["${git_prefix}"], "readwritePaths": ["${project_root}"] } + }, + "intents": { + "local": { "exampleSubcommands": ["status", "commit", "log"] }, + "fetch": { "policyAdditions": { "network": { "egress": { "allow": ["…tcp/443…"] } } } }, + "push": { "policyAdditions": { "network": { "egress": { "allow": ["…tcp/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", + "intents": { "bundle-fetch": { "policyAdditions": { "…": "…" } } } } ], - "provenance": { "method": "reviewed-observation", "sourceRevision": "opaque-review-reference" } + "provenance": { "method": "design-example", "sourceRevision": "…" } } ``` -Invariants: - -- `entryId` is stable, unique, namespaced, and is the only key `requires`/ - dependency edges may reference. -- `entryRevision` increases on every semantic change to the entry. -- Variant selection follows the deterministic rules in - [§4.4](#44-platform-variants). No selected variant means the tool is - unsupported on that platform, not that it needs an empty policy, and not - `undefined` conflated with "requires nothing" (see [#779, "Defaults and - omission"](https://github.com/microsoft/mxc/pull/779)). -- Symbols (`${project_root}`, `${npm_cache}`, OS well-known folders) are - resolved by the resolver before a policy is returned; catalog data never - ships a literal, machine-specific path. This is unchanged from #779. -- An embedded `sandboxPolicy` is validated against the real `SandboxPolicy` - schema for its declared `version`. The catalog schema does not duplicate - that validation. +The bundled revision (`2026-10-02.1`) carries `tool:git`, `tool:node`, +`tool:npm`, and `tool:ssh`. Its network addresses are documentation ranges +(RFC 5737), not real endpoints. + +- **Default.** One complete `sandboxPolicy`, the default's dependencies, and + its named intents. It applies whenever the entry matches. +- **Platform overlays** (`platformVariants`) add filesystem paths, outbound + allow rules, dependencies, intent extensions (`intentAdditions`), or new + intents for one platform, optionally one architecture. An exact + architecture overlay is preferred over the platform's architecture-neutral + overlay; another architecture's overlay is never used. Selectors must be + unique, with at most one neutral overlay per platform. +- **Version overlays** (`versionVariants`) carry a purl `vers` range in the + entry's `versionScheme`. Ranges within an entry must not overlap, so at most + one applies. A version overlay adds the same kinds of things a platform + overlay can. +- **Intents** are lower-case names (`[a-z][a-z0-9_-]*`). An intent adds + access and dependencies on top of the base policy. `intentAdditions` may + extend only intents the default declares; `intents` may introduce only + names no other applicable overlay declares. +- **Not additive**, and therefore rejected in an overlay: `deniedPaths`, + `network.egress.deny`, `network.egress.default`, ingress, `ui`, and + `timeoutMs`. Those may appear only in the default's `sandboxPolicy`. + +The **effective policy** for one platform, architecture, and version is the +default plus the selected platform overlay plus at most one version overlay; +an intent then selects from the effective policy's intents. Symbols +(`${project_root}`, `${npm_cache}`, …) are resolved before a policy is +returned; catalog data never ships a literal, machine-specific path. ### 4.3 Identity -`identity` describes the predicates a candidate can satisfy for an entry, -using the layering #779 §3.1 establishes (invocation name vs. launcher artifact -vs. executing image; falsifiable-against-a-local-artifact as the admission test -for a new kind). - -Matching is additive across entries. For each input tool, the resolver collects -every entry with a satisfied, eligible identity predicate and an applicable -platform/architecture variant. It does not choose a single winning entry: - -- A package-identity match does not suppress another entry's eligible - invocation-name match. Equal-strength matches to different entries also - contribute; they are not ambiguity errors. -- Identity strength describes the matching evidence, not precedence between - entries. Diagnostics retain all satisfied identity predicates for each - contributing entry. -- Multiple predicates matching the same entry do not add its policy multiple - times. Entries shared across input tools or dependency chains likewise - contribute once, while diagnostics preserve the per-input matches. -- Multiple matching entries for one input produce a diagnostic warning, not - a refusal. Their policies must still satisfy the composition rules in - [§4.5](#45-dependencies-and-composition). -- Diagnostic tool records follow input order, matches are ordered by - `entryId`, and matched predicates follow their declaration order within the - entry. Catalog file order does not select or exclude a match. - -The existing matching qualifications remain: - -- A version range on an identity predicate is advisory matching evidence, not - a gate. A detected mismatch returns a diagnostic alongside the resolved - policy rather than silently degrading precision, and the consumer decides - what to do with the mismatch. -- Invocation-name-only identity is the always-available fallback, not the - default outcome. Whether a consumer accepts an invocation-name-only match - automatically, or requires opt-in, is unresolved. See - [§13](#13-open-questions). Under the current proposed default, an entry - matched only by invocation name participates when - `allowWeakIdentityFallback` is `true`. Compose-all-matches does not bypass - that option. - -### 4.4 Platform variants - -Supported platforms are `windows`, `linux`, and `macos`. Supported architecture -selectors are `x64` and `arm64`. A variant selector has this closed shape: +A purl predicate is a **strong** identity; an invocation-name predicate is a +**weak** fallback that participates only with `allowWeakIdentityFallback`. +Catalog purls never pin a version, and a version embedded in a caller's +`packageUrl` is ignored with a warning: the only version evidence is +`detectedVersion`. Invocation names compare case-insensitively on Windows and +macOS and exactly on Linux. -```ts -interface PlatformVariantSelector { - platform: "windows" | "linux" | "macos"; - architecture?: "x64" | "arm64"; -} -``` +Each input selects at most one entry. Among eligible entries the resolver +prefers a strong match, then an entry whose applicable overlays declare the +requested intent, then an exact-architecture overlay over a neutral one over +none. A tie at the top rank fails as `policy_validation` +(`ambiguous_match`) rather than guessing. The resolver does not verify that +the executable really carries the identity the caller passed. -A platform variant is a complete requirement statement: one full -`SandboxPolicy`, not a patch applied to a base policy, plus any -platform-specific dependencies. Variants are never merged. Architecture is a -catalog selector, not a field added to the embedded `SandboxPolicy`. Omitting -`when.architecture` makes a catalog variant architecture-neutral; omitting the -caller's `ResolveContext.architecture` instead requests the host default. +### 4.4 Platform and architecture -Selection first filters by platform, then uses the following precedence: +Supported platforms are `windows`, `linux`, and `macos`; architectures are +`x64` and `arm64`. An omitted `ResolveContext.architecture` uses the device's +native system architecture (not the process architecture), detected only when +an entry has architecture-specific overlays for the platform; when it is +used, a warning says the tool's architecture was not verified. Falling back +to a platform's neutral overlay because no exact overlay exists also produces +a warning. A failure to detect the native architecture when it is needed is +an error, not a guess. -| Caller context | Preferred variant | Fallback | +An x64 tool running under emulation on an ARM64 device may need the x64 +overlay. The resolver never inspects or runs the tool; callers that know the +tool's architecture should pass it explicitly. + +### 4.5 Resolution per tool and intent + +Each input is one tool-and-intent pair, resolved independently: + +| Situation | Status | Contribution | |---|---|---| -| Explicit `architecture: "x64"` | x64 for the selected platform | Architecture-neutral for that platform | -| Explicit `architecture: "arm64"` | ARM64 for the selected platform | Architecture-neutral for that platform | -| Architecture omitted | Device's native system architecture for the selected platform | Architecture-neutral for that platform | - -The native system architecture is the architecture reported by the host OS, -not the architecture of the process hosting the SDK. For example, on an -ARM64 device with both x64 and ARM64 catalog variants and no neutral variant, -omitting architecture selects ARM64. An explicit `architecture: "x64"` selects -x64 on that same device. The resolver does not require a neutral variant to -return a result when the effective architecture has an exact match. - -Catalog validation rejects duplicate exact selectors and more than one -architecture-neutral variant for the same platform. If neither an exact nor -architecture-neutral variant exists, that entry contributes no match, not an -empty policy or a variant for a different architecture. A failure to determine -the native system architecture when it is needed is an SDK error, not a -guessed selection. - -**Emulation risk:** A host-derived default does not establish the architecture -of the installed tool. An x64 tool running under emulation on an ARM64 device -may need the x64 variant rather than the default ARM64 variant. The resolver -does not inspect or run the tool to discover its architecture. Callers that -know the relevant tool and runtime requirements should select architecture -explicitly and remain responsible for deciding whether the result applies. -Neither explicit selection nor a host default guarantees that the returned -policy is sufficient or minimal. Host-derived selection and neutral fallback -are surfaced through `diagnostics.warnings` by the diagnostics API -([§5.1](#51-runtime-lookup)). - -A platform variant must not name a specific MXC containment backend. Policies -stay backend-neutral; the selected backend still decides whether a stated -requirement can be realized on that host. - -### 4.5 Dependencies and composition - -Dependencies reference another entry's `entryId` and live inside the platform -variant when platform-specific. An optional `versionRange` records which -dependency versions supplied the reviewed evidence. The v1 resolver has no -dependency inventory, so it does not evaluate that range or use it for -matching. It returns the range as unevaluated metadata for consumer inspection. -Catalog validation checks only that the range is syntactically valid. -Resolution is otherwise transitive, cycle-rejecting, and deterministic, and -the diagnostics API returns the resolved dependency metadata alongside the -policy. - -The same composition rules apply to all entries matched by one tool, entries -matched by different tools in an array, and their transitive dependencies. -Each selected entry contributes its policy once, even when reached through -multiple inputs or dependency edges. Repeated contribution is de-duplicated -by entry ID within the selected catalog revision, not by discarding match -attribution. A shared `ResolveContext` applies to the whole lookup. - -Unlike #779, this document does not treat "union the policies" as sufficient -composition. Silently unioning arbitrary `SandboxPolicy` objects across a -dependency chain hides exactly the kind of conflicting-field problem that -made #779 exclude `proxy` from the embedded object. For the first contract -version, cross-entry composition is limited to the exact -`filesystem.deniedPaths`, `filesystem.readonlyPaths`, and -`filesystem.readwritePaths` fields: - -1. Every policy in the selected entries and their dependency closure must - declare the same `sandboxPolicy.version`. -2. Paths are resolved, normalized using the selected platform's path rules, - and de-duplicated within the same access class. -3. Catalog validation rejects equal or ancestor/descendant paths that occur in - different access classes. It never chooses between denied, read-only, and - read-write access implicitly. -4. The non-conflicting, normalized lists are merged into the returned policy. - -The v1 contract does not compose `network`. In particular, it defines no merge -for `network.egress.default`, `network.egress.allow`, -`network.egress.deny`, `network.ingress.default`, or -`network.ingress.hostLoopback`. Composition rejects a selected set of entries -where policies from more than one entry would require composing any `network` -field. The same rejection applies to timeout, clipboard, lifecycle, UI, proxy, -and every other policy field without an explicit cross-entry rule. A lookup -resolving to only one entry without dependencies may still use -catalog-supported policy fields because no cross-entry merge occurs. +| No detected version | `matched_default` | the effective policy without a version overlay | +| Version inside a range | `matched_version` | default plus that version overlay | +| Valid version in no range (including an entry with no ranges) | `version_out_of_range` | as `matched_default`, with a structured warning | +| Version the scheme cannot parse | `version_unparseable` | nothing | +| Intent not defined by the effective policy | `intent_unsupported` | nothing; the version status stays in `versionSelection` | +| No intent | (version status) | base policy plus every intent of the effective policy | +| No eligible entry | `tool_unmatched` | nothing | + +There is no wildcard fallback and no "require all" option: other inputs still +resolve, so a result may cover only some of the requested tools. +Dependencies are materialized with their default, the platform overlay, and +all intents; a dependency's `versionRange` (in the target entry's scheme) is +recorded as unevaluated metadata. + +### 4.6 Composition + +All contributing pairs and their dependency closure compose into one policy: + +1. Every contribution must declare the same `sandboxPolicy.version`. +2. Paths are substituted and normalized with the selected platform's rules, + then de-duplicated exactly. Case-only differences are kept and warned + about, because the filesystem's case sensitivity is not determined. +3. Read-write supersedes read-only: a read-only path at or under a read-write + path is omitted, with a warning. +4. A catalog deny that overlaps any required read-only or read-write path is + removed entirely, with a warning naming its scope. Non-conflicting denies + are kept. +5. Outbound allow rules from every pair are unioned under a default-deny + egress posture, so a pair without network needs never vetoes another + pair's. If only one contribution uses network, its section passes through + unchanged. +6. A single contribution with no additions passes through whole, including + fields such as `ui` and `timeoutMs`. Anything else the model cannot + express across contributions (for example a catalog egress deny alongside + another contribution's network, or `timeoutMs` in a composed set) fails as + `policy_validation` (`composition_conflict`) rather than granting broader + access. + +The caller's own restrictions still win: the floor is input to the caller's +composition, never a ceiling override. + +### 4.7 Build-time validation + +Catalog validation (run by `cargo test -p mxc_policy_store`) checks the +contract and identity rules, the required `versionScheme` and single +default, unique selectors, non-overlapping ranges in the entry's scheme, +additive-only overlays, dependency targets and their ranges, and cycles. It +then materializes every platform × architecture × version × intent effective +policy (including the "all intents" case) with its dependency closure, +validates each composed policy, and checks that the default is a subset of +each. A Markdown reviewer view of every effective policy is generated into +`catalog/views/.md` and must stay current. ## 5. API surface @@ -387,8 +365,9 @@ absent policy is `undefined` in TypeScript/JavaScript, `None` in Rust, and ```ts interface ToolCandidate { invocationName: string; - packageUrl?: string; - detectedVersion?: string; + packageUrl?: string; // strong identity; any embedded version is ignored + detectedVersion?: string; // the only version evidence + intent?: string; // for example "fetch" or "push" } type ToolInput = string | ToolCandidate; @@ -402,100 +381,86 @@ interface ResolveContext { allowWeakIdentityFallback?: boolean; } +type VersionStatus = + | "matched_default" | "matched_version" + | "version_out_of_range" | "version_unparseable"; + +interface VersionSelection { + status: VersionStatus; + detectedVersion?: string; + selectedVersionRange?: string; // matched_version only +} + +interface IntentSelection { + requested?: string; + mode: "named" | "all" | "unsupported"; + selected: string[]; +} + +interface ToolResolutionWarning { + code: "version_out_of_range" | "version_unparseable" + | "intent_unsupported" | "tool_unmatched"; + inputIndex: number; + entryId?: string; + detectedVersion?: string; + intent?: string; + message: string; +} + interface SandboxConfigResolution { policy: SandboxPolicy | undefined; diagnostics: { catalogRevision: string; tools: Array<{ inputIndex: number; - matches: Array<{ + status: VersionStatus | "intent_unsupported" | "tool_unmatched"; + matches: Array<{ // at most one entryId: string; entryRevision: number; - matchedIdentities: Array<{ - kind: string; - strength: "strong" | "weak"; - }>; + matchedIdentities: Array<{ kind: string; strength: "strong" | "weak" }>; + versionSelection: VersionSelection; + intentSelection?: IntentSelection; // absent when unparseable }>; }>; resolvedDependencies: Array<{ entryId: string; entryRevision: number; requiredVersionRange?: string; + versionSelection: VersionSelection; + intentSelection: IntentSelection; }>; - warnings: string[]; + warnings: Array; }; } declare function resolveSandboxPolicy( - tool: ToolInput, - ctx?: ResolveContext -): SandboxPolicy | undefined; - -declare function resolveSandboxPolicy( - tools: readonly ToolInput[], - ctx?: ResolveContext -): SandboxPolicy | undefined; - + tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxPolicy | undefined; declare function resolveSandboxPolicyWithDiagnostics( - tool: ToolInput, - ctx?: ResolveContext -): SandboxConfigResolution; - -declare function resolveSandboxPolicyWithDiagnostics( - tools: readonly ToolInput[], - ctx?: ResolveContext -): SandboxConfigResolution; + tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution; ``` -A string input is shorthand for `{ invocationName: tool }`; it supplies no -package or version evidence and follows the same weak-identity option as an -object input. For example, name-only lookup under the current proposed opt-in -rule is: +C# exposes single-tool and list overloads; Rust takes an idiomatic +one-or-many input. A string input is shorthand for `{ invocationName }` and +carries no package, version, or intent evidence. A one-element array is +equivalent to a single input, with `inputIndex` `0`. ```ts -const ctx = { allowWeakIdentityFallback: true }; -const policy = resolveSandboxPolicy("npm", ctx); -const combinedPolicy = resolveSandboxPolicy(["git", "npm"], ctx); -const result = resolveSandboxPolicyWithDiagnostics("npm", ctx); -const combinedResult = - resolveSandboxPolicyWithDiagnostics(["git", "npm"], ctx); +const ctx = { projectRoot: "/work/repo", symbols: { git_prefix: "/usr/bin" } }; +const git = { invocationName: "git", packageUrl: "pkg:generic/git", detectedVersion: "2.45.1" }; +// One floor for a fetch followed by a push. +const policy = resolveSandboxPolicy( + [{ ...git, intent: "fetch" }, { ...git, intent: "push" }], ctx); ``` -Single-tool lookup is equivalent to a one-element array; its diagnostic -`inputIndex` is `0`. A caller retaining separate policies per tool can use -single-tool calls. A caller wanting one sandbox for several tools passes an -array. Both forms compose every eligible matching entry, not just the -strongest match, and the selected dependencies. - -`resolveSandboxPolicy` returns the composed `SandboxPolicy` directly, not a wrapper -or a `ContainerConfig`. `resolveSandboxPolicyWithDiagnostics` returns that same -policy with attribution and warnings from the same resolution pass. Callers -choose one operation; retrieving diagnostics does not require a second lookup -or process-global "last result" state. - -Following #779, an unmatched input contributes no requirements while matched -inputs still contribute. Each input has a diagnostic record; an unmatched -input has an empty `matches` list and a warning. An empty input array or an -all-unmatched lookup produces no policy, not an empty policy: -`resolveSandboxPolicy` returns `undefined`, while the diagnostics operation returns -a `SandboxConfigResolution` with `policy: undefined`. An empty array has no -per-input records. Unresolved required symbols in selected entries prevent a -policy from being returned and produce diagnostics; they are not grounds for -silently omitting a selected requirement to produce a partial policy. - -Multiple matching entries for one input are listed in `matches`, with a -warning identifying that input and the contributing entry IDs. Shared entries -remain attributed to every matching input even though their policy is -composed once. Dependency diagnostics retain each distinct -entry/revision/required-version-range combination, ordered by those fields; -repeated metadata does not mean repeated policy contribution. - -When architecture is omitted, diagnostics include a warning naming the -effective native system architecture and stating that the tool's architecture -was not verified. Architecture-neutral fallback is also identified. These -diagnostics describe selection; they do not attest to the installed tool's -architecture. The policy-only operation does not expose warnings or -attribution; consumers needing them use `resolveSandboxPolicyWithDiagnostics`. +`resolveSandboxPolicy` returns the composed `SandboxPolicy` directly; +`resolveSandboxPolicyWithDiagnostics` returns the same policy with +attribution and warnings from the same pass. An empty or entirely +non-contributing lookup returns no policy, not an empty policy. An unresolved +required symbol in a contributing entry prevents a policy and names the +symbol in a warning; the resolver never returns a partial policy. Free-text +warnings cover weak matches, host-derived architecture and symbols, +neutral-overlay fallback, ignored purl versions, and composition adjustments; +per-input outcomes use structured warnings. ### 5.2 Setup and inspection @@ -504,21 +469,36 @@ type CatalogPlatform = "windows" | "linux" | "macos"; type CatalogArchitecture = "x64" | "arm64"; type CatalogIdentityMetadata = - | { kind: "purl"; value: string; versionRange?: string } + | { kind: "purl"; value: string } | { kind: "invocation-name"; names: string[] }; +interface CatalogIntentMetadata { + name: string; + exampleSubcommands?: string[]; + dependencyEntryIds: string[]; +} + +interface CatalogAdditionsMetadata { + dependencyEntryIds: string[]; + intentAdditions: CatalogIntentMetadata[]; + intents: CatalogIntentMetadata[]; +} + interface CatalogEntryMetadata { catalogRevision: string; entryId: string; entryRevision: number; displayName: string; + versionScheme: "npm" | "semver" | "pypi" | "nuget" | "intdot"; identity: CatalogIdentityMetadata[]; - platformVariants: Array<{ - platform: CatalogPlatform; - architecture?: CatalogArchitecture; + default: { dependencyEntryIds: string[]; sandboxPolicyVersion: string; - }>; + intents: CatalogIntentMetadata[]; + }; + platformVariants: Array< + { platform: CatalogPlatform; architecture?: CatalogArchitecture } & CatalogAdditionsMetadata>; + versionVariants: Array<{ versionRange: string } & CatalogAdditionsMetadata>; provenance: { method: string; sourceRevision: string; @@ -531,8 +511,9 @@ getCatalogInfo(): { catalogSchemaVersion: string; catalogRevision: string }; This supports setup UI, catalog browsing, and update decisions without paying the cost of policy resolution, and keeps "give me everything" out of the -runtime lookup path entirely. Metadata exposes selectors, dependency IDs, and -provenance, but not an unresolved or resolved policy body. +runtime lookup path entirely. Metadata exposes selectors, version ranges, +intent names, dependency IDs, and provenance, but not an unresolved or +resolved policy body. ### 5.3 Consumer obligations @@ -624,10 +605,13 @@ each SDK's own test suite exercises its binding against them. - Every entry change includes identity evidence, supported tool version range(s), platform evidence, a minimized requirement set, test fixtures, and provenance. -- CI validates schema conformance, exact `SandboxPolicy` version registration, - entry-ID uniqueness, dependency closure and cycle-freedom, symbol validity, - absence of unsafe user-specific literal paths, unsupported-field rejection, - deterministic resolution, and package inclusion. +- CI validates the catalog contract, exact `SandboxPolicy` version + registration, entry-ID uniqueness, one default per entry, non-overlapping + version ranges, additive-only overlays, dependency closure and + cycle-freedom, symbol validity, absence of unsafe user-specific literal + paths, unsupported-field rejection, and deterministic resolution. It + materializes and validates every effective policy and checks the rendered + reviewer view ([§4.7](#47-build-time-validation)). - A new entry or a requirement expansion requires one catalog-owner approval and one security/policy-reviewer approval, plus tool- or scenario-owner evidence where available. @@ -673,7 +657,8 @@ outcomes: What changes from #779 is the review bar. #779 described community-contributed, unsigned, unwarranted data. This contract requires named-role approval ([§7](#7-contribution-and-review)) before an entry publishes, and publishes -under an immutable, integrity-validated revision ([§10](#10-immutable-revisions)). +under an immutable revision that CI checks against published history +([§10](#10-immutable-revisions)). That raises confidence in the data; it does not change what the data *is*. The catalog still carries no security guarantee or independent authority. A consumer must review the requirement and intersect it with its own policy @@ -703,83 +688,61 @@ have cached or recorded in an audit trail. **Resolver (Rust, exercised directly and through each SDK binding)** -- shared conformance fixtures produce equivalent results and failure - categories in all three languages -- one-tool and one-element-array overloads produce equivalent policies and - diagnostics; the simple API returns the same policy as the diagnostic API -- multiple input tools compose all matching entries and dependencies into one - policy; repeated inputs or shared dependencies do not duplicate contributions -- known and unknown inputs compose the known requirements and report each - unmatched input; empty and all-unmatched arrays return no policy, never an - empty policy, while the diagnostic API preserves the resolution metadata -- omitted context uses host platform and native system architecture, the - installed catalog revision, no caller symbol overrides, and no weak-identity - fallback -- an unresolved required symbol prevents policy output, with diagnostics, - rather than silently omitting selected requirements -- one input matching several entries composes all eligible matches, including - equal-strength matches; a stronger match does not suppress a weaker eligible - match, and diagnostics preserve all matching entries and identity evidence -- multiple predicates matching the same entry contribute that policy once; - file order does not change matching or composition -- string shorthand and object inputs obey the same weak-identity fallback - option; additive matching does not bypass it -- version-range mismatch produces a warning, not a refusal -- exact-architecture variant precedes the platform-only variant; duplicate - selectors are rejected; no matching variant produces `undefined` -- on an ARM64 host with both architecture-specific variants and no neutral - variant, omitted architecture selects ARM64; explicit x64 selects x64 -- an SDK process running as x64 under emulation on an ARM64 host still - defaults to the native ARM64 system architecture, not its process - architecture -- a missing exact variant falls back to the platform's neutral variant; - a different architecture's variant is never used as a fallback -- successful host-derived selection and neutral fallback produce the - diagnostics specified in [§5.1](#51-runtime-lookup); host-architecture - detection failure produces an SDK error, not a guessed match -- dependency chain resolution, including cycles (terminate, no duplication) -- dependency `versionRange` is returned as unevaluated metadata and never used - for v1 resolver matching -- restricted composition rules ([§4.5](#45-dependencies-and-composition)): - same-class path de-duplication; cross-class path overlap, mixed policy - versions, network fields, and other unsupported composed fields are rejected - at validation time rather than resolved -- symbol resolution on Windows, Linux, and macOS +- shared conformance fixtures produce the same results and failure reasons + across bindings (`mxc_policy_store` and the C# tests replay them; the + `mxc-sdk`, `mxc_ffi`, and Node tests cover the same paths through each + binding) +- a single input and a one-element array are equivalent; the policy-only API + returns the diagnostic API's policy +- every row of the per-pair table in [§4.5](#45-resolution-per-tool-and-intent), + including an out-of-range version with an unsupported intent +- `vers` parsing and comparison for all five schemes +- an embedded purl version is ignored with a warning +- strong identity outranks weak, intent support breaks ties, exact + architecture outranks neutral, and remaining ties fail as `ambiguous_match` +- invocation-name casing per platform +- native-architecture default with its warning; explicit architecture; no + cross-architecture fallback; neutral fallback warning +- unresolved symbols prevent policy output; host-derived symbols only for the + current host platform, overridable by the caller +- composition: read-write supersedes read-only, conflicting denies removed + with diagnostics, outbound allows unioned without a network veto, mixed + policy versions and inexpressible combinations rejected +- dependency chains compose each entry once; dependency records keep distinct + required ranges **Data (CI)** -- every entry and platform variant validates against the catalog schema and - the `SandboxPolicy` schema for its declared version -- `dependencies[].entryId` references resolve within the same catalog revision -- no literal absolute user-specific paths; no wildcard filesystem/network grants -- catalog/entry revision monotonicity across a proposed change +- the contract and every entry validate, and every effective policy is + materialized and validated ([§4.7](#47-build-time-validation)) +- the reviewer view is current; every entry has a bundled conformance case +- catalog and entry revision monotonicity; published revisions unchanged when + `MXC_POLICY_STORE_BASE_REF` is set **Integration** - each SDK performs lookup without launching a sandbox or selecting a backend; lookup requires no network access - the bundled catalog revision matches `getCatalogInfo()`; selecting an - unavailable revision fails explicitly, without falling back to another - revision + unavailable revision fails explicitly - an SDK update leaves previously accepted consumer policies unchanged - a representative tool that fails under a minimal consumer policy succeeds - once its resolved entry is composed in -- the same tool still fails when the consumer's policy forbids what the entry - requests (the floor never widens the consumer's ceiling) + once its resolved floor is composed in, and still fails when the consumer's + policy forbids what the floor requests ## 13. Open questions Recommended answers are proposals for review, not decisions. -| Question | Recommended answer | +| Question | Status | |---|---| -| Final API names? | Pending API review. Current names are kept for now; the names may drop "Sandbox" (for example, `resolvePolicy`). | -| Does lookup need an intent, such as `git pull` versus `git push`? | Open. One floor per tool can over-grant a read-only intent; an optional intent input could select a narrower variant. | +| Final API names? | Pending API review. Current names are kept; they may drop "Sandbox" (for example, `resolvePolicy`). | +| Exact policy validator and `SandboxPolicy` version mapping used at build time | Open. The prototype validates composed effective policies with the catalog contract's own subset validator. | +| Purl normalization and equality, including purl-embedded versions | Open. The prototype lower-cases the type, compares namespace and name as written, and ignores an embedded version with a warning. | +| `vers` conformance for `pypi` and `nuget` | The prototype implements practical subsets of PEP 440 and NuGet versioning. Exact conformance is open. | | How does the catalog schema evolve? | Keep it compatible within MXC 1.x; a breaking change waits for a major version. | -| Is invocation-name-only identity accepted automatically, or does it require explicit consumer opt-in? | Treat it as a fallback requiring explicit opt-in (`allowWeakIdentityFallback`), not the default. | -| What happens on a detected tool-version mismatch: `undefined`, or a warning-bearing result the consumer may still use? | Return the resolved result with a warning; refusing outright removes information the consumer needs to decide for itself. | -| Are private or enterprise catalog overlays in scope, and if so with what precedence? | Defer until the shared catalog contract and its API are stable; define precedence explicitly before the SDKs add overlay support. | -| Should the first contract version's composition vocabulary expand beyond [§4.5](#45-dependencies-and-composition) before implementation? | No. Start with conflict-rejecting filesystem composition and expand only with an explicit, reviewed rule per field. | +| Is invocation-name-only identity accepted automatically? | No: it requires `allowWeakIdentityFallback`. | +| Are private or enterprise catalog overlays in scope? | Deferred until the shared catalog contract and API are stable. | | Who owns catalog schema, data, and API review? | MXC, through its normal API review, with named catalog and security reviewers. | ## 14. Related work diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index a3de0d5a8..5a1ea8887 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -577,13 +577,28 @@ var context = new ResolveContext SandboxPolicy? floor = MxcPolicyStore.ResolveSandboxPolicy( new ToolInput("npm") { PackageUrl = "pkg:npm/npm" }, context); -// Name-only matches need an explicit opt-in; diagnostics report what matched. +// One floor for a git fetch and push. DetectedVersion selects a version +// overlay; Intent narrows the floor to what that intent needs. +var gitContext = context with +{ + Symbols = new Dictionary(context.Symbols!) { ["git_prefix"] = "/usr" }, +}; var resolution = MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics( - new ToolInput[] { "git", "npm" }, - context with { AllowWeakIdentityFallback = true }); + new[] + { + new ToolInput("git") { PackageUrl = "pkg:generic/git", DetectedVersion = "2.45.1", Intent = "fetch" }, + new ToolInput("git") { PackageUrl = "pkg:generic/git", DetectedVersion = "2.45.1", Intent = "push" }, + }, + gitContext); +// resolution.Diagnostics.Tools[i].Status: "matched_default", "matched_version", +// "version_out_of_range", "version_unparseable", "intent_unsupported", or "tool_unmatched". ``` -`GetCatalogInfo()` and `ListCatalogEntries()` inspect the bundled catalog. +Name-only inputs (`"git"`) need `AllowWeakIdentityFallback = true`. Each input +resolves independently, so a result may cover only some of the requested +tools. Each `ResolutionWarning` in `Diagnostics.Warnings` has a `Message`; +structured per-input warnings also carry `Code`, `InputIndex`, and related +fields, while `Code` is `null` for free-text warnings. `GetCatalogInfo()` and `ListCatalogEntries()` inspect the bundled catalog. Failures throw `MxcException`: `Code` carries the `ErrorCode` and `Reason` carries the stable store reason (for example `invalid_context`). `Reason` is `null` for failures outside the policy store. See [`docs/policy-store/`](../../docs/policy-store/README.md). diff --git a/sdk/node/README.md b/sdk/node/README.md index 17a233c93..52f614b79 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -519,14 +519,18 @@ const context = { // A SandboxPolicy, or undefined when nothing resolves. const floor = resolveSandboxPolicy({ invocationName: 'npm', packageUrl: 'pkg:npm/npm' }, context); -// Name-only matches need an explicit opt-in; diagnostics report what matched. +// One floor for a git fetch and push. detectedVersion selects a version overlay; +// intent narrows the floor to what that intent needs. +const git = { invocationName: 'git', packageUrl: 'pkg:generic/git', detectedVersion: '2.45.1' }; const { policy, diagnostics } = resolveSandboxPolicyWithDiagnostics( - ['git', 'npm'], - { ...context, symbols: { ...context.symbols, git_prefix: '/usr' }, allowWeakIdentityFallback: true }, + [{ ...git, intent: 'fetch' }, { ...git, intent: 'push' }], + { ...context, symbols: { ...context.symbols, git_prefix: '/usr' } }, ); +// diagnostics.tools[i].status: 'matched_default' | 'matched_version' | 'version_out_of_range' +// | 'version_unparseable' | 'intent_unsupported' | 'tool_unmatched' ``` -`getCatalogInfo()` and `listCatalogEntries()` inspect the bundled catalog without resolving a policy. Failures throw `MxcError` with the stable store reason in `details.reason` (for example `invalid_context`). See [`docs/policy-store/`](../../docs/policy-store/README.md). +Name-only inputs (`'git'`) need `allowWeakIdentityFallback: true`. Each input resolves independently, so a result may cover only some of the requested tools; non-default outcomes also appear in `diagnostics.warnings` as structured `ToolResolutionWarning` objects alongside free-text warnings. `getCatalogInfo()` and `listCatalogEntries()` inspect the bundled catalog without resolving a policy. Failures throw `MxcError` with the stable store reason in `details.reason` (for example `invalid_context`). See [`docs/policy-store/`](../../docs/policy-store/README.md). --- diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 8d27d898d..d1d4609ef 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -738,17 +738,26 @@ let floor = resolve_sandbox_policy( &ctx, )?; -// Name-only matches need an explicit opt-in; diagnostics report what matched. +// One floor for a git fetch and push. The detected version selects a version +// overlay; the intent narrows the floor to what that intent needs. +let git = ToolCandidate::new("git") + .with_package_url("pkg:generic/git") + .with_detected_version("2.45.1"); let resolution = resolve_sandbox_policy_with_diagnostics( - vec![ToolInput::from("git"), ToolInput::from("npm")], - &ctx.clone() - .symbol("git_prefix", "/usr") - .allow_weak(true), + vec![ + ToolInput::from(git.clone().with_intent("fetch")), + ToolInput::from(git.with_intent("push")), + ], + &ctx.clone().symbol("git_prefix", "/usr"), )?; +// Each resolution.diagnostics.tools[i].status is a ToolResolutionStatus. # let _ = (floor, resolution); # Ok::<(), mxc_sdk::policy_store::PolicyCatalogError>(()) ``` +Name-only inputs need `.allow_weak(true)`. Each input resolves independently, +so a result may cover only some of the requested tools; non-default outcomes +also appear as `Warning::Tool` entries in `diagnostics.warnings`. `get_catalog_info` and `list_catalog_entries` inspect the bundled catalog. Errors carry an MXC `ErrorCode` and a stable `reason()`. See [`docs/policy-store/`](../../../docs/policy-store/README.md). diff --git a/src/core/mxc_policy_store/README.md b/src/core/mxc_policy_store/README.md index 8f4d6bb66..2b8db6b89 100644 --- a/src/core/mxc_policy_store/README.md +++ b/src/core/mxc_policy_store/README.md @@ -2,9 +2,8 @@ > **Prototype.** This API is proposed and pending API review and sign-off > before check-in. Names, shapes, and catalog contents may change (for -> example, the names may drop "Sandbox", and lookup may gain an intent such -> as `git pull` versus `git push`). It is not part of MXC 1.0; a later SDK -> release is targeted. +> example, the names may drop "Sandbox"). It is not part of MXC 1.0; a later +> SDK release is targeted. The MXC policy store resolves known tools to a **best-effort floor** `SandboxPolicy`: the access a known tool typically needs, which a caller @@ -12,8 +11,13 @@ composes with its own policy. It is not a guarantee, and it is complementary to Learning Mode rather than a replacement for it. The V1 catalog in `catalog/` is the single source of truth. `build.rs` embeds -it with `include_str!`, and the store checks each revision's published -SHA-256 on first use. Nothing is downloaded. Every MXC SDK uses this crate: +it with `include_str!`; nothing is downloaded. Each entry has one unversioned +default plus additive platform, version (purl `vers` ranges in the entry's +`versionScheme`), and intent overlays. A lookup takes tool candidates with an +optional `detectedVersion` and `intent`, and reports a per-input status +(`matched_default`, `matched_version`, `version_out_of_range`, +`version_unparseable`, `intent_unsupported`, or `tool_unmatched`). Every +MXC SDK uses this crate: | SDK | Entry point | |-----|-------------| @@ -29,10 +33,10 @@ Node and C# SDKs. | Path | Contents | |------|----------| -| `catalog/` | Contract, manifest, and immutable published revisions (V1 data) | +| `catalog/` | Contract, manifest, immutable published revisions (V1 data), and generated reviewer views (`views/`) | | `schema/` | JSON Schemas for the catalog and manifest (editor validation) | | `conformance/` | Language-neutral fixtures and path/canonical-JSON vectors | -| `src/` | Resolver, store, integrity, validation, and path rules | +| `src/` | Resolver, effective-policy builder, composition, `vers` schemes, validation, and path rules | | `tests/` | Library, conformance, vector, and catalog contribution tests | ## Build and test @@ -44,8 +48,10 @@ cargo test -p mxc_policy_store cargo clippy -p mxc_policy_store --all-targets -- -D warnings ``` -`tests/catalog_validation.rs` is the contribution gate: integrity, contract, -entry-revision history, deterministic resolution, and conformance coverage. +`tests/catalog_validation.rs` is the contribution gate: contract, +entry-revision history, every materialized effective policy, deterministic +resolution, conformance coverage, and a current reviewer view +(`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. From 98c0beb2229b25f8c33f91daffe6884ed045c1c5 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Mon, 5 Oct 2026 10:57:17 -0700 Subject: [PATCH 34/50] mxc_policy_store: align model with spec 5a2c52c Dependencies, overlays and composition now follow the pushed spec (microsoft/mxc#1309 at 5a2c52c, docs/mxc-policy-store.md): - Dependencies contribute the referenced entry's default plus platform base additions only. A reference may name intents ({ entryId, intents: [...] }); a named intent the dependency's default does not define fails catalog validation. Dependency diagnostics report intentSelection mode "none" or "named". - Overlays use policyAdditions, intentAdditions (default intents only) and newIntents. The former overlay `intents` field is renamed to `newIntents` in the model, JSON schema, metadata and fixtures. - A catalog egress deny that overlaps another requested pair's required egress allow is removed in full, with a diagnostic naming the full destination/port scope and the contributing entries. Non-overlapping denies stay. Overlapping denies are no longer composition_conflict. - Network rule validation parses CIDRs, requires `except` blocks inside their peer, and rejects ports on icmp. - The reviewer view adds an "Added to default" column per row. - Catalog revision 2026-10-05.1 replaces 2026-10-02.1: git entryRevision 2 moves the >=2.50 bundle-fetch intent to newIntents. - New composition-catalog conformance fixture and library tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mxc_policy_store/catalog/manifest.json | 6 +- .../{2026-10-02.1.json => 2026-10-05.1.json} | 17 +- .../{2026-10-02.1.md => 2026-10-05.1.md} | 126 ++-- .../conformance/fixtures/bundled-catalog.json | 74 +- .../fixtures/composition-catalog.json | 688 ++++++++++++++++++ .../fixtures/synthetic-catalog.json | 24 +- .../schema/catalog.v1.schema.json | 18 +- src/core/mxc_policy_store/src/catalog.rs | 68 +- src/core/mxc_policy_store/src/compose.rs | 150 ++-- src/core/mxc_policy_store/src/effective.rs | 112 ++- src/core/mxc_policy_store/src/lib.rs | 1 + src/core/mxc_policy_store/src/model.rs | 7 +- src/core/mxc_policy_store/src/netrule.rs | 337 +++++++++ src/core/mxc_policy_store/src/resolver.rs | 2 +- src/core/mxc_policy_store/src/view.rs | 51 +- src/core/mxc_policy_store/tests/library.rs | 249 ++++++- 16 files changed, 1695 insertions(+), 235 deletions(-) rename src/core/mxc_policy_store/catalog/revisions/{2026-10-02.1.json => 2026-10-05.1.json} (96%) rename src/core/mxc_policy_store/catalog/views/{2026-10-02.1.md => 2026-10-05.1.md} (57%) create mode 100644 src/core/mxc_policy_store/conformance/fixtures/composition-catalog.json create mode 100644 src/core/mxc_policy_store/src/netrule.rs diff --git a/src/core/mxc_policy_store/catalog/manifest.json b/src/core/mxc_policy_store/catalog/manifest.json index 8700a63c4..1552464b7 100644 --- a/src/core/mxc_policy_store/catalog/manifest.json +++ b/src/core/mxc_policy_store/catalog/manifest.json @@ -1,10 +1,10 @@ { "catalogSchemaVersion": "1", - "defaultRevision": "2026-10-02.1", + "defaultRevision": "2026-10-05.1", "revisions": [ { - "catalogRevision": "2026-10-02.1", - "file": "revisions/2026-10-02.1.json" + "catalogRevision": "2026-10-05.1", + "file": "revisions/2026-10-05.1.json" } ] } diff --git a/src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json b/src/core/mxc_policy_store/catalog/revisions/2026-10-05.1.json similarity index 96% rename from src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json rename to src/core/mxc_policy_store/catalog/revisions/2026-10-05.1.json index 15f086d66..cef2c09d3 100644 --- a/src/core/mxc_policy_store/catalog/revisions/2026-10-02.1.json +++ b/src/core/mxc_policy_store/catalog/revisions/2026-10-05.1.json @@ -1,10 +1,10 @@ { "catalogSchemaVersion": "1", - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "entries": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "displayName": "Git", "versionScheme": "intdot", "identity": [ @@ -36,15 +36,14 @@ "local": { "exampleSubcommands": [ "status", - "commit", - "log" - ] + "diff" + ], + "policyAdditions": {} }, "fetch": { "exampleSubcommands": [ "fetch", - "pull", - "clone" + "pull" ], "policyAdditions": { "network": { @@ -125,10 +124,10 @@ }, { "versionRange": "vers:intdot/>=2.50|<3", - "intents": { + "newIntents": { "bundle-fetch": { "exampleSubcommands": [ - "fetch --bundle-uri" + "clone --bundle-uri=" ], "policyAdditions": { "filesystem": { diff --git a/src/core/mxc_policy_store/catalog/views/2026-10-02.1.md b/src/core/mxc_policy_store/catalog/views/2026-10-05.1.md similarity index 57% rename from src/core/mxc_policy_store/catalog/views/2026-10-02.1.md rename to src/core/mxc_policy_store/catalog/views/2026-10-05.1.md index 70259d236..7fdc85e54 100644 --- a/src/core/mxc_policy_store/catalog/views/2026-10-02.1.md +++ b/src/core/mxc_policy_store/catalog/views/2026-10-05.1.md @@ -1,91 +1,91 @@ -# Catalog revision 2026-10-02.1 — reviewer view +# Catalog revision 2026-10-05.1 — reviewer view -Every effective policy each entry can produce: the default, plus platform additions, at most one version variant, and the selected intent. `(all)` means no intent was requested. Paths are unsubstituted templates. +Every effective policy each entry can produce: 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 1, `intdot` versions) +## `tool:git` — Git (revision 2, `intdot` versions) ### Default (no detected version or out of range) -| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | -|---|---|---|---|---|---|---|---| -| windows | any | (all) | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443`
`192.0.2.10/32 tcp/22` | — | -| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | -| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | -| windows | any | push | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | -| 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` | — | +| 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 | -|---|---|---|---|---|---|---|---| -| 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` | -| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | -| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | -| windows | any | push | `${git_prefix}`
`${programData}\Git`
`${ssh_prefix}` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | `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` | -| 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` | -| 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` | -| 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` | +| 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 | -|---|---|---|---|---|---|---|---| -| 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` | — | -| windows | any | bundle-fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}`
`${temp_dir}\git-bundles` | — | `198.51.100.20/32 tcp/443` | — | -| windows | any | fetch | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/443` | — | -| windows | any | local | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | — | — | -| windows | any | push | `${git_prefix}`
`${programData}\Git` | `${project_root}` | — | `192.0.2.10/32 tcp/22` | — | -| 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` | — | -| linux | any | bundle-fetch | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `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` | — | -| macos | any | bundle-fetch | `${git_prefix}` | `${project_root}`
`${temp_dir}/git-bundles` | — | `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` | — | +| 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 1, `semver` versions) ### Default (no detected version or out of range) -| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | -|---|---|---|---|---|---|---|---| -| windows | any | (all) | `${node_prefix}` | — | — | — | — | -| linux | any | (all) | `${node_prefix}` | — | — | — | — | -| macos | any | (all) | `${node_prefix}` | — | — | — | — | +| 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 1, `npm` versions) ### Default (no detected version or out of range) -| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | -|---|---|---|---|---|---|---|---| -| 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` | +| 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 1, `intdot` versions) ### Default (no detected version or out of range) -| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies | -|---|---|---|---|---|---|---|---| -| windows | any | (all) | `${ssh_prefix}` | — | — | — | — | -| linux | any | (all) | `${ssh_prefix}` | — | — | — | — | -| macos | any | (all) | `${ssh_prefix}` | — | — | — | — | +| 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/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json index 9b1aca8db..8316a0943 100644 --- a/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json +++ b/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json @@ -62,7 +62,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -70,7 +70,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -127,7 +127,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -191,7 +191,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -229,7 +229,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -308,7 +308,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -316,7 +316,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -415,7 +415,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -490,7 +490,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -503,7 +503,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -553,7 +553,7 @@ "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -582,7 +582,7 @@ "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [], "resolvedDependencies": [], "warnings": [] @@ -603,7 +603,7 @@ "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -611,7 +611,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -732,7 +732,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -740,7 +740,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -844,7 +844,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -852,7 +852,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -934,7 +934,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -942,7 +942,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -977,7 +977,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -1038,7 +1038,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1046,7 +1046,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1099,7 +1099,7 @@ "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1107,7 +1107,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1167,7 +1167,7 @@ "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1175,7 +1175,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1242,7 +1242,7 @@ "expect": { "policy": null, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1250,7 +1250,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1360,7 +1360,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1368,7 +1368,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1432,7 +1432,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1440,7 +1440,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1546,7 +1546,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-02.1", + "catalogRevision": "2026-10-05.1", "tools": [ { "inputIndex": 0, @@ -1554,7 +1554,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1584,7 +1584,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -1614,7 +1614,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", diff --git a/src/core/mxc_policy_store/conformance/fixtures/composition-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/composition-catalog.json new file mode 100644 index 000000000..8fc5c5055 --- /dev/null +++ b/src/core/mxc_policy_store/conformance/fixtures/composition-catalog.json @@ -0,0 +1,688 @@ +{ + "$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", + "entries": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "displayName": "Credential helper", + "versionScheme": "semver", + "identity": [ + { + "kind": "invocation-name", + "names": [ + "helper" + ] + } + ], + "default": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "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": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "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": { + "sandboxPolicy": { + "version": "0.9.0-alpha", + "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": { + "policy": { + "version": "0.9.0-alpha", + "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, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "named", + "selected": [ + "sync" + ] + } + } + ], + "warnings": [ + "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + ] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "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, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + } + ], + "warnings": [ + "input 0 ('locker') matched tool:locker only by invocation name (weak identity)" + ] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "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, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:helper", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "store", + "sync" + ] + } + } + ] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "named", + "selected": [ + "sync" + ] + } + } + ], + "warnings": [ + "input 0 ('helper') matched tool:helper only by invocation name (weak identity)", + "input 1 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + ] + } + } + }, + { + "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": { + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readonlyPaths": [ + "/g/fetcher", + "/g/locker", + "/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 + } + ] + } + ], + "deny": [ + { + "to": [ + { + "cidr": "198.51.100.0/24" + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 25 + } + ] + } + ] + } + } + }, + "diagnostics": { + "catalogRevision": "2000-01-02.1", + "tools": [ + { + "inputIndex": 0, + "status": "matched_default", + "matches": [ + { + "entryId": "tool:fetcher", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "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, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "none", + "selected": [] + } + }, + { + "entryId": "tool:helper", + "entryRevision": 1, + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "named", + "selected": [ + "sync" + ] + } + } + ], + "warnings": [ + "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)", + "input 1 ('locker') matched tool:locker only by invocation name (weak identity)", + "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/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json b/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json index 699d84598..4468b848c 100644 --- a/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json +++ b/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json @@ -225,7 +225,7 @@ }, { "versionRange": "vers:npm/>=3.0.0", - "intents": { + "newIntents": { "publish": { "exampleSubcommands": [ "publish" @@ -325,7 +325,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } }, @@ -337,7 +337,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -437,7 +437,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -539,7 +539,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -693,7 +693,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -876,7 +876,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } }, @@ -888,7 +888,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -969,7 +969,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -1198,7 +1198,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -1373,7 +1373,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } @@ -1449,7 +1449,7 @@ "status": "matched_default" }, "intentSelection": { - "mode": "all", + "mode": "none", "selected": [] } } diff --git a/src/core/mxc_policy_store/schema/catalog.v1.schema.json b/src/core/mxc_policy_store/schema/catalog.v1.schema.json index b0eb5a92a..71267b977 100644 --- a/src/core/mxc_policy_store/schema/catalog.v1.schema.json +++ b/src/core/mxc_policy_store/schema/catalog.v1.schema.json @@ -202,8 +202,8 @@ "description": "Additions to intents the default declares.", "$ref": "#/$defs/intents" }, - "intents": { - "description": "New intents.", + "newIntents": { + "description": "Intents the default does not declare.", "$ref": "#/$defs/intents" } } @@ -230,8 +230,8 @@ "description": "Additions to intents the default declares.", "$ref": "#/$defs/intents" }, - "intents": { - "description": "New intents.", + "newIntents": { + "description": "Intents the default does not declare.", "$ref": "#/$defs/intents" } } @@ -318,6 +318,16 @@ "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_-]*$" + } } } } diff --git a/src/core/mxc_policy_store/src/catalog.rs b/src/core/mxc_policy_store/src/catalog.rs index a99198ccf..f8ab2af5a 100644 --- a/src/core/mxc_policy_store/src/catalog.rs +++ b/src/core/mxc_policy_store/src/catalog.rs @@ -131,6 +131,9 @@ pub struct Dependency { /// 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 @@ -165,8 +168,8 @@ pub struct Overlay { pub dependencies: Vec, /// Extensions of intents the default declares. pub intent_additions: Vec<(String, IntentDefinition)>, - /// New intents. - pub intents: Vec<(String, IntentDefinition)>, + /// Intents the default does not declare (`newIntents`). + pub new_intents: Vec<(String, IntentDefinition)>, } /// The unversioned default every entry has exactly once. @@ -441,11 +444,26 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> }; only_fields(peer, &["cidr", "except"], &peer_at)?; let cidr = non_empty_string(peer.get("cidr"), &format!("{peer_at}.cidr"))?; + let Some(block) = crate::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") { - string_array(Some(except), &format!("{peer_at}.except"), 0)?; + let except_at = format!("{peer_at}.except"); + for (i, value) in string_array(Some(except), &except_at, 0)? + .iter() + .enumerate() + { + let inside = crate::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}'" + )); + } + } } } } @@ -464,6 +482,11 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> 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) { @@ -704,7 +727,7 @@ fn validate_dependencies(raw: Option<&Json>, at: &str) -> Result let Some(dependency) = dependency.as_object() else { return fail(format!("'{dep_at}' must be an object")); }; - only_fields(dependency, &["entryId", "versionRange"], &dep_at)?; + 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")); @@ -719,9 +742,28 @@ fn validate_dependencies(raw: Option<&Json>, at: &str) -> Result } 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) @@ -872,15 +914,19 @@ fn validate_overlay( 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 'intents' to declare a new one" + "'{at}.intentAdditions.{name}' extends an intent the default does not declare; use 'newIntents' to declare a new one" )); } } - let intents = validate_intents(item.get("intents"), &format!("{at}.intents"), contract)?; - for (name, _) in &intents { + 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}.intents.{name}' redeclares an inherited intent; use 'intentAdditions' to extend it" + "'{at}.newIntents.{name}' redeclares an inherited intent; use 'intentAdditions' to extend it" )); } } @@ -895,7 +941,7 @@ fn validate_overlay( &format!("{at}.dependencies"), )?, intent_additions, - intents, + new_intents, }) } @@ -903,7 +949,7 @@ const OVERLAY_FIELDS: [&str; 4] = [ "policyAdditions", "dependencies", "intentAdditions", - "intents", + "newIntents", ]; fn validate_default( @@ -1169,7 +1215,7 @@ impl CatalogEntry { for overlay in overlays { out.extend(&overlay.dependencies); out.extend(intents(&overlay.intent_additions)); - out.extend(intents(&overlay.intents)); + out.extend(intents(&overlay.new_intents)); } out } diff --git a/src/core/mxc_policy_store/src/compose.rs b/src/core/mxc_policy_store/src/compose.rs index df01c46af..23a90d573 100644 --- a/src/core/mxc_policy_store/src/compose.rs +++ b/src/core/mxc_policy_store/src/compose.rs @@ -8,9 +8,9 @@ use crate::catalog::{Additions, CatalogPolicy}; use crate::json::{canonical_json, cmp_utf16, Json, JsonObject}; use crate::model::{FilesystemPolicy, Platform, SandboxPolicy}; +use crate::netrule::{describe_rule, rules_overlap}; use crate::paths::{normalize_path, path_exact_segments, path_key_segments}; use crate::text::symbol_matches; -use std::collections::HashSet; /// One selected (entry, base, additions) contribution. #[derive(Clone, Debug)] @@ -125,7 +125,7 @@ pub fn compose_check(components: &[Component<'_>]) -> Result<(), String> { } } } - if verbatim_network(components).is_some() { + if components.iter().filter(|c| c.needs_network()).count() <= 1 { return Ok(()); } for component in components { @@ -140,7 +140,7 @@ pub fn compose_check(components: &[Component<'_>]) -> Result<(), String> { )); } for (field, _) in value.as_object().into_iter().flat_map(|o| o.iter()) { - if !matches!(field, "default" | "allow") { + if !matches!(field, "default" | "allow" | "deny") { return Err(format!( "'{}' uses 'network.egress.{field}', which cannot be combined with other network requirements", component.entry_id @@ -158,17 +158,6 @@ fn is_passthrough(components: &[Component<'_>]) -> bool { components.len() == 1 && !components[0].has_additions() } -/// The one component whose base network passes through unchanged: the only -/// component needing network, contributing no outbound additions. -fn verbatim_network<'c, 'a>(components: &'c [Component<'a>]) -> Option<&'c Component<'a>> { - let mut needing = components.iter().filter(|c| c.needs_network()); - let only = needing.next()?; - if needing.next().is_some() || only.additions.iter().any(|a| !a.egress_allow.is_empty()) { - return None; - } - Some(only) -} - /// The composed policy and the composition diagnostics. #[derive(Clone, Debug)] pub struct Composed { @@ -322,13 +311,7 @@ pub fn compose_policy( let root = components[0].base; let passthrough = is_passthrough(components); - let network = if passthrough { - root.field("network").cloned() - } else if let Some(only) = verbatim_network(components) { - only.base.field("network").cloned() - } else { - compose_network(components) - }; + let network = compose_network(components, &mut warnings); Composed { policy: SandboxPolicy { version: root.version().to_string(), @@ -343,42 +326,115 @@ pub fn compose_policy( } } -/// Unions the outbound allow rules under a deny-by-default egress. -fn compose_network(components: &[Component<'_>]) -> Option { - let mut seen = HashSet::new(); - let mut allow = Vec::new(); - let mut any = false; - for component in components { - if let Some(network) = component.base.field("network") { - any = true; - let rules = network - .get("egress") - .and_then(|e| e.get("allow")) - .and_then(Json::as_array); - for rule in rules.into_iter().flatten() { - if seen.insert(canonical_json(rule)) { - allow.push(rule.clone()); +/// Outbound rules keyed by canonical JSON, with the entries contributing each. +#[derive(Default)] +struct Rules { + items: Vec<(String, Json, Vec)>, +} + +impl Rules { + fn add(&mut self, rule: &Json, entry_id: &str) { + let key = canonical_json(rule); + match self.items.iter_mut().find(|(k, _, _)| *k == key) { + Some((_, _, ids)) => { + if !ids.iter().any(|id| id == entry_id) { + ids.push(entry_id.to_string()); } } + None => self + .items + .push((key, rule.clone(), vec![entry_id.to_string()])), + } + } + + fn json(&self) -> Option { + (!self.items.is_empty()) + .then(|| Json::Array(self.items.iter().map(|(_, rule, _)| 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 component needing +/// network keeps its own network section, plus its outbound additions. Several +/// such components 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(components: &[Component<'_>], warnings: &mut Vec) -> Option { + let needing: Vec<&Component<'_>> = components.iter().filter(|c| c.needs_network()).collect(); + if needing.is_empty() { + return None; + } + let mut allow = Rules::default(); + let mut deny = Rules::default(); + for component in &needing { + let network = component.base.field("network"); + for rule in egress_rules(network, "allow") { + allow.add(rule, component.entry_id); } for additions in &component.additions { for rule in &additions.egress_allow { - any = true; - if seen.insert(canonical_json(rule)) { - allow.push(rule.clone()); - } + allow.add(rule, component.entry_id); } } + for rule in egress_rules(network, "deny") { + deny.add(rule, component.entry_id); + } } - if !any { - return None; - } + deny.items.retain(|(_, rule, deny_ids)| { + let Some((_, required, allow_ids)) = allow + .items + .iter() + .find(|(_, a, _)| rules_overlap(a, rule)) + else { + return true; + }; + warnings.push(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(rule), + deny_ids.join(", "), + describe_rule(required), + allow_ids.join(", "), + describe_rule(rule) + )); + false + }); + + let single_base = match needing.as_slice() { + [only] => only.base.field("network").and_then(Json::as_object), + _ => None, + }; let mut egress = JsonObject::new(); - egress.insert("default", "deny".into()); - if !allow.is_empty() { - egress.insert("allow", Json::Array(allow)); + 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(); - network.insert("egress", Json::Object(egress)); + 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/core/mxc_policy_store/src/effective.rs b/src/core/mxc_policy_store/src/effective.rs index ad35637b9..d1aaf8c7a 100644 --- a/src/core/mxc_policy_store/src/effective.rs +++ b/src/core/mxc_policy_store/src/effective.rs @@ -17,7 +17,7 @@ use crate::model::{ Architecture, DependencyRecord, IntentMode, IntentSelection, Platform, SandboxPolicy, VersionSelection, }; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; /// The platform overlay selected for an entry. #[derive(Clone, Copy, Debug)] @@ -98,8 +98,10 @@ pub enum IntentChoice<'s> { All, /// Base plus one named intent. Named(&'s str), - /// Base only (used to compare a default with a new intent). + /// 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. @@ -188,7 +190,7 @@ pub fn materialize<'a>( true, &origin, )?; - add_intents(&mut effective.intents, &overlay.intents, false, &origin)?; + add_intents(&mut effective.intents, &overlay.new_intents, false, &origin)?; } effective .intents @@ -211,7 +213,17 @@ impl<'a> Effective<'a> { 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 additions = self.base_additions.clone(); let mut dependencies = self.base_dependencies.clone(); for intent in &chosen { @@ -236,13 +248,17 @@ pub struct Node<'a> { } /// 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 (design +/// §4.5). Each (entry, base or intent) component contributes once. pub struct Closure<'a, 'm> { pub by_id: &'m EntryIndex<'a>, pub platform: Platform, pub architecture: &'m dyn Fn() -> Result, pub nodes: Vec>, pub records: Vec, - done: HashSet<&'a str>, + /// Node index per dependency entry, and the intents already added to it. + done: HashMap<&'a str, (usize, HashSet)>, } impl<'a, 'm> Closure<'a, 'm> { @@ -257,7 +273,7 @@ impl<'a, 'm> Closure<'a, 'm> { architecture, nodes: Vec::new(), records: Vec::new(), - done: HashSet::new(), + done: HashMap::new(), } } @@ -295,9 +311,14 @@ impl<'a, 'm> Closure<'a, 'm> { 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 selected = effective - .select(IntentChoice::All) - .expect("all intents always select"); + let mut named: Vec = dependency.intents.clone().unwrap_or_default(); + named.sort_by(|a, b| cmp_utf16(a, b)); + if let Some(missing) = named.iter().find(|n| !effective.has_intent(n)) { + 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, @@ -305,28 +326,69 @@ impl<'a, 'm> Closure<'a, 'm> { version_selection: VersionSelection::default_match(), intent_selection: IntentSelection { requested: None, - mode: IntentMode::All, - selected: selected.intent_names.clone(), + mode: if dependency.intents.is_some() { + IntentMode::Named + } else { + IntentMode::None + }, + selected: named.clone(), }, }; if !self.records.contains(&record) { self.records.push(record); } - if !self.done.insert(&target.entry_id) { + let (new_base, new_intents): (bool, Vec) = + match self.done.get(target.entry_id.as_str()) { + None => (true, named), + Some((_, added)) => ( + false, + named.into_iter().filter(|n| !added.contains(n)).collect(), + ), + }; + if !new_base && new_intents.is_empty() { continue; } - self.nodes.push(Node { - component: Component { - entry_id: &target.entry_id, - base: &target.default.sandbox_policy, - additions: selected.additions, - }, - neutral_fallback: selection - .filter(|s| !s.exact && has_arch_specific(target, self.platform)) - .map(|_| self.platform), - }); + let base = effective + .select(IntentChoice::BaseOnly) + .expect("the base always selects"); + let chosen = effective + .select(IntentChoice::Set(&new_intents)) + .expect("named intents were checked"); + // `chosen` repeats the base; keep only what is new. + let base_len = base.additions.len(); + let base_dep_len = base.dependencies.len(); + let mut additions: Vec<&'a Additions> = Vec::new(); + let mut next: Vec<&'a Dependency> = Vec::new(); + if new_base { + additions.extend(&base.additions); + next.extend(&base.dependencies); + } + additions.extend(&chosen.additions[base_len..]); + next.extend(&chosen.dependencies[base_dep_len..]); + match self.done.get_mut(target.entry_id.as_str()) { + Some((index, added)) => { + added.extend(new_intents); + self.nodes[*index].component.additions.extend(additions); + } + None => { + self.done.insert( + &target.entry_id, + (self.nodes.len(), new_intents.into_iter().collect()), + ); + self.nodes.push(Node { + component: Component { + entry_id: &target.entry_id, + base: &target.default.sandbox_policy, + additions, + }, + neutral_fallback: selection + .filter(|s| !s.exact && has_arch_specific(target, self.platform)) + .map(|_| self.platform), + }); + } + } stack.push(&target.entry_id); - self.visit_all(&selected.dependencies, stack)?; + self.visit_all(&next, stack)?; stack.pop(); } Ok(()) @@ -378,6 +440,10 @@ pub struct Materialized { pub policy: SandboxPolicy, 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_policy: SandboxPolicy, + pub default_dependency_entry_ids: Vec, } fn compose_symbolic( @@ -541,6 +607,8 @@ pub fn materialize_entry( policy, dependency_entry_ids: deps, intent_names: selected.intent_names, + default_policy, + default_dependency_entry_ids: default_deps, }); } } diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/core/mxc_policy_store/src/lib.rs index 955efc857..83efd3bcb 100644 --- a/src/core/mxc_policy_store/src/lib.rs +++ b/src/core/mxc_policy_store/src/lib.rs @@ -31,6 +31,7 @@ pub mod history; pub mod host; pub mod json; pub mod model; +pub mod netrule; pub mod paths; pub mod purl; pub mod request; diff --git a/src/core/mxc_policy_store/src/model.rs b/src/core/mxc_policy_store/src/model.rs index 8ea2c1aa0..eceadd46d 100644 --- a/src/core/mxc_policy_store/src/model.rs +++ b/src/core/mxc_policy_store/src/model.rs @@ -442,6 +442,8 @@ impl VersionSelection { pub enum IntentMode { Named, All, + /// A dependency contributing its base only. + None, Unsupported, } @@ -450,6 +452,7 @@ impl IntentMode { match self { IntentMode::Named => "named", IntentMode::All => "all", + IntentMode::None => "none", IntentMode::Unsupported => "unsupported", } } @@ -732,14 +735,14 @@ fn intents_json(intents: &[CatalogIntentMetadata]) -> Json { pub struct CatalogAdditionsMetadata { pub dependency_entry_ids: Vec, pub intent_additions: Vec, - pub intents: 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("intents", intents_json(&self.intents)); + o.insert("newIntents", intents_json(&self.new_intents)); } } diff --git a/src/core/mxc_policy_store/src/netrule.rs b/src/core/mxc_policy_store/src/netrule.rs new file mode 100644 index 000000000..84301ca81 --- /dev/null +++ b/src/core/mxc_policy_store/src/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::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/core/mxc_policy_store/src/resolver.rs b/src/core/mxc_policy_store/src/resolver.rs index 123c52519..bb735107a 100644 --- a/src/core/mxc_policy_store/src/resolver.rs +++ b/src/core/mxc_policy_store/src/resolver.rs @@ -96,7 +96,7 @@ fn additions_metadata(overlay: &Overlay) -> CatalogAdditionsMetadata { CatalogAdditionsMetadata { dependency_entry_ids: dependency_ids(&overlay.dependencies), intent_additions: intent_metadata(&overlay.intent_additions), - intents: intent_metadata(&overlay.intents), + new_intents: intent_metadata(&overlay.new_intents), } } diff --git a/src/core/mxc_policy_store/src/view.rs b/src/core/mxc_policy_store/src/view.rs index 35ddcd3be..a44897770 100644 --- a/src/core/mxc_policy_store/src/view.rs +++ b/src/core/mxc_policy_store/src/view.rs @@ -99,13 +99,54 @@ fn egress(m: &Materialized) -> Vec { } } -fn row_cells(m: &Materialized) -> [String; 5] { +type PathField = fn(&FilesystemPolicy) -> &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 { + policy: m.default_policy.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)), ] } @@ -120,7 +161,7 @@ pub fn render_reviewer_view( 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: the default, plus platform additions, at most one version variant, and the selected intent. `(all)` means no intent was requested. Paths are unsubstituted templates.\n", + "\n\n# Catalog revision {} — reviewer view\n\nEvery effective policy each entry can produce: 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 { @@ -141,7 +182,7 @@ pub fn render_reviewer_view( ); for option in options { out.push_str(&format!( - "\n### {}\n\n| Platform | Arch | Intent | Read-only | Read-write | Denied | Outbound allow | Dependencies |\n|---|---|---|---|---|---|---|---|\n", + "\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| { @@ -160,7 +201,7 @@ pub fn render_reviewer_view( } } for intent in intents { - let per_arch: Vec<(&Materialized, [String; 5])> = Architecture::ALL + let per_arch: Vec<(&Materialized, [String; 6])> = Architecture::ALL .iter() .filter_map(|arch| { rows.iter() @@ -170,7 +211,7 @@ pub fn render_reviewer_view( .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; 5])> = if same { + let shown: Vec<(String, &[String; 6])> = if same { vec![("any".to_string(), &per_arch[0].1)] } else { per_arch diff --git a/src/core/mxc_policy_store/tests/library.rs b/src/core/mxc_policy_store/tests/library.rs index 15bde32c9..387be56de 100644 --- a/src/core/mxc_policy_store/tests/library.rs +++ b/src/core/mxc_policy_store/tests/library.rs @@ -944,8 +944,8 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { .get("resolvedDependencies") .unwrap(), &j(r#"[ - {"entryId":"tool:c","entryRevision":1,"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"all","selected":["run"]}}, - {"entryId":"tool:c","entryRevision":1,"requiredVersionRange":"vers:semver/>=2.0.0","versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"all","selected":["run"]}}]"#) + {"entryId":"tool:c","entryRevision":1,"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"none","selected":[]}}, + {"entryId":"tool:c","entryRevision":1,"requiredVersionRange":"vers:semver/>=2.0.0","versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"none","selected":[]}}]"#) ); assert_eq!( result.policy.unwrap().to_json(), @@ -985,6 +985,13 @@ fn unsupported_combinations_fail_rather_than_broaden() { "tool:u", &with_default(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#), ""), ), + entry( + "tool:i", + &with_default( + &format!(r#"{{"version":"{V}","network":{{"ingress":{{"default":"deny"}}}}}}"#), + "", + ), + ), ]), linux_x64(), ); @@ -995,8 +1002,21 @@ fn unsupported_combinations_fail_rather_than_broaden() { error.message(), "[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)" ); + // A deny that does not overlap another pair's required allow stays. assert_eq!( - reason_of(catalog.resolve_sandbox_policy(vec!["d", "n"], &weak())), + catalog + .resolve_sandbox_policy(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_sandbox_policy(vec!["i", "n"], &weak())), ErrorReason::CompositionConflict ); assert_eq!( @@ -1032,7 +1052,7 @@ fn inspection_over_the_bundled_catalog() { let info = get_catalog_info().unwrap(); assert_eq!( info.to_json(), - j(r#"{"catalogSchemaVersion":"1","catalogRevision":"2026-10-02.1"}"#) + j(r#"{"catalogSchemaVersion":"1","catalogRevision":"2026-10-05.1"}"#) ); let entries = list_catalog_entries().unwrap(); let ids: Vec<&str> = entries.iter().map(|e| e.entry_id.as_str()).collect(); @@ -1046,8 +1066,8 @@ fn inspection_over_the_bundled_catalog() { assert_eq!( git.get("versionVariants").unwrap(), &j(r#"[ - {"versionRange":"vers:intdot/>=2.40|<2.50","dependencyEntryIds":[],"intentAdditions":[{"name":"push","dependencyEntryIds":["tool:ssh"]}],"intents":[]}, - {"versionRange":"vers:intdot/>=2.50|<3","dependencyEntryIds":[],"intentAdditions":[],"intents":[{"name":"bundle-fetch","exampleSubcommands":["fetch --bundle-uri"],"dependencyEntryIds":[]}]}]"#) + {"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("sandboxPolicy\"")); @@ -1354,17 +1374,22 @@ fn validation_overlays_are_additive_only() { "extends an intent the default does not declare", ); validate_err( - overlay(r#""intents":{"run":{}}"#), + overlay(r#""newIntents":{"run":{}}"#), "redeclares an inherited intent", ); validate_err( - overlay(r#""intents":{"Run":{}}"#), + overlay(r#""newIntents":{"Run":{}}"#), "is not a valid intent name", ); validate_err( - overlay(r#""intents":{"x":{"exampleSubcommands":[]}}"#), + 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] @@ -1397,8 +1422,8 @@ fn validation_version_variants() { validate_err( vec![entry( "tool:a", - r#"{"platformVariants":[{"when":{"platform":"linux"},"intents":{"x":{}}}], - "versionVariants":[{"versionRange":"vers:semver/>=1.0.0","intents":{"x":{}}}]}"#, + 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", ); @@ -1427,16 +1452,202 @@ fn validation_materializes_composition_limits() { ], "'tool:b' uses 'timeoutMs', which has no v1 cross-policy composition rule", ); + // An overlapping catalog deny is removed during materialization, not rejected. + validate(vec![entry( + "tool:a", + &format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","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", - &format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","network":{{"egress":{{"deny":[{{"ports":[{{"port":22}}]}}]}}}}}}, - "intents":{{"net":{{"policyAdditions":{{"network":{{"egress":{{"allow":[{}]}}}}}}}}}}}}}}"#, - tcp("192.0.2.1/32", 443) + vec![ + entry("tool:a", &dep("tool:b")), + entry( + "tool:a2", + &format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","network":{{"egress":{{"allow":[{}]}}}}}},"dependencies":[{{"entryId":"tool:b"}}]}}}}"#, + tcp("192.0.2.1/32", 443) + ), ), - )], - "'tool:a' uses 'network.egress.deny', which cannot be combined with other network requirements", + entry( + "tool:b", + &with_default( + &format!( + r#"{{"version":"{V}","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":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"{id}","intents":{intents}}}]}}}}"# + ) +} + +fn helper() -> Json { + entry( + "tool:h", + &format!( + r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","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_sandbox_policy_with_diagnostics("a", &ctx) + .unwrap(); + assert_eq!( + fs_json(&base_only.policy), + j(r#"{"readonlyPaths":["/g/h"]}"#) + ); + assert_eq!(egress(&base_only), None); + let named = catalog + .resolve_sandbox_policy_with_diagnostics("b", &ctx) + .unwrap(); + assert_eq!( + fs_json(&named.policy), + j(r#"{"readonlyPaths":["/g/h"],"readwritePaths":["/t/h"]}"#) + ); + assert_eq!( + named.policy.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,"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#"{{"version":"{V}","network":{{"egress":{{"deny":[{rules}]}}}}}}"#), + "", + ) + }; + let catalog = catalog_for( + revision(vec![ + entry( + "tool:n", + &with_default( + &format!( + r#"{{"version":"{V}","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_sandbox_policy_with_diagnostics(vec!["n", "d"], &weak()) + .unwrap(); + assert_eq!( + result.policy.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]" ); } From f91acd3485edecb52489605d718886bec6e50927 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Mon, 5 Oct 2026 10:58:32 -0700 Subject: [PATCH 35/50] mxc-sdk, mxc_ffi: cover dependency intent mode and newIntents metadata The Rust SDK and C ABI re-export the core changes unchanged. Their tests now assert that a plain dependency reports intentSelection mode "none" with an empty selection, and that version-variant metadata exposes newIntents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/mxc-sdk/tests/policy_store.rs | 16 ++++++++++++---- src/ffi/mxc_ffi/tests/policy_store.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/core/mxc-sdk/tests/policy_store.rs b/src/core/mxc-sdk/tests/policy_store.rs index e4ece4bb9..1669860da 100644 --- a/src/core/mxc-sdk/tests/policy_store.rs +++ b/src/core/mxc-sdk/tests/policy_store.rs @@ -79,10 +79,11 @@ fn version_and_intent_select_the_effective_policy() { tool.matches[0].intent_selection.as_ref().unwrap().mode, IntentMode::Named ); - assert_eq!( - push.diagnostics.resolved_dependencies[0].entry_id, - "tool:ssh" - ); + let ssh = &push.diagnostics.resolved_dependencies[0]; + assert_eq!(ssh.entry_id, "tool:ssh"); + // A dependency contributes its base only unless the reference names intents. + assert_eq!(ssh.intent_selection.mode, IntentMode::None); + assert!(ssh.intent_selection.selected.is_empty()); let policy = push.policy.unwrap(); assert_eq!( policy.filesystem.unwrap().readonly_paths, @@ -137,6 +138,13 @@ fn inspection_reports_the_bundled_catalog() { .map(|e| e.entry_id) .collect(); assert!(ids.contains(&"tool:git".to_string()), "{ids:?}"); + let git = list_catalog_entries() + .unwrap() + .into_iter() + .find(|e| e.entry_id == "tool:git") + .unwrap(); + let newer = &git.version_variants[1]; + assert_eq!(newer.additions.new_intents[0].name, "bundle-fetch"); } fn fixture() -> Value { diff --git a/src/ffi/mxc_ffi/tests/policy_store.rs b/src/ffi/mxc_ffi/tests/policy_store.rs index 34429c863..47f34ede9 100644 --- a/src/ffi/mxc_ffi/tests/policy_store.rs +++ b/src/ffi/mxc_ffi/tests/policy_store.rs @@ -95,6 +95,23 @@ fn intent_and_version_cross_the_boundary() { ); } +#[test] +fn dependency_intent_selection_crosses_the_boundary() { + let (status, json, _, message) = call( + mxc_resolve_sandbox_policy_with_diagnostics_json, + r#"{"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"}}}"#, + ); + 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["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( From 6cdbd7e8d1a423788e396fa8b03727c86b0b44e8 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Mon, 5 Oct 2026 10:59:38 -0700 Subject: [PATCH 36/50] sdk/node: IntentSelection mode "none" and newIntents metadata IntentSelection.mode adds "none" for dependencies that contribute their base only. Overlay metadata renames `intents` to `newIntents` to match the catalog field. Tests assert both over the bundled catalog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/node/src/policy-store.ts | 12 ++++++++---- sdk/node/tests/unit/policy-store.test.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/sdk/node/src/policy-store.ts b/sdk/node/src/policy-store.ts index 0b3e6c10d..df21f37bf 100644 --- a/sdk/node/src/policy-store.ts +++ b/sdk/node/src/policy-store.ts @@ -102,11 +102,15 @@ export interface VersionSelection { selectedVersionRange?: string; } -/** Intent selection for one matched entry. */ +/** + * 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' | 'unsupported'; - /** Selected intent names, sorted; empty when unsupported. */ + mode: 'named' | 'all' | 'none' | 'unsupported'; + /** Selected intent names, sorted; empty when unsupported or none. */ selected: string[]; } @@ -177,7 +181,7 @@ export interface CatalogAdditionsMetadata { /** Extensions of intents the default declares. */ intentAdditions: CatalogIntentMetadata[]; /** Intents the overlay introduces. */ - intents: CatalogIntentMetadata[]; + newIntents: CatalogIntentMetadata[]; } /** Inspection metadata for one catalog entry. It never exposes a policy body. */ diff --git a/sdk/node/tests/unit/policy-store.test.ts b/sdk/node/tests/unit/policy-store.test.ts index ee7c47455..29907f0a3 100644 --- a/sdk/node/tests/unit/policy-store.test.ts +++ b/sdk/node/tests/unit/policy-store.test.ts @@ -54,6 +54,11 @@ describe('policy store (prototype)', { skip }, () => { git.versionVariants.map((variant) => variant.versionRange), ['vers:intdot/>=2.40|<2.50', 'vers:intdot/>=2.50|<3'], ); + assert.deepStrictEqual( + git.versionVariants[1].newIntents.map((intent) => intent.name), + ['bundle-fetch'], + ); + assert.ok(!('intents' in git.versionVariants[1])); }); it('resolves a strong identity to an SDK SandboxPolicy', () => { @@ -122,7 +127,10 @@ describe('policy store (prototype)', { skip }, () => { mode: 'named', selected: ['push'], }); - assert.strictEqual(push.diagnostics.resolvedDependencies[0].entryId, 'tool:ssh'); + const [ssh] = push.diagnostics.resolvedDependencies; + assert.strictEqual(ssh.entryId, 'tool:ssh'); + // A plain dependency reference contributes the dependency's base only. + assert.deepStrictEqual(ssh.intentSelection, { mode: 'none', selected: [] }); assert.deepStrictEqual(push.policy?.filesystem?.readonlyPaths, ['/usr/bin', '/usr/lib/ssh']); assert.strictEqual(push.policy?.network?.egress?.allow?.length, 1); }); From 95f6f458750e6bb72610a3dceb7f337ca160aecb Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Mon, 5 Oct 2026 11:01:09 -0700 Subject: [PATCH 37/50] sdk/dotnet: IntentSelection mode "none" and NewIntents metadata Overlay metadata records rename Intents to NewIntents (JSON newIntents). IntentSelection documents mode "none" for dependencies that contribute their base only. Tests assert both over the bundled catalog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MxcPolicyStoreTests.cs | 8 ++++++-- sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs | 18 +++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs index 44101cba7..8cfc2252c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcPolicyStoreTests.cs @@ -60,7 +60,7 @@ public void ListCatalogEntries_ReturnsMetadata() Assert.Equal("push", push.Name); Assert.Equal(new[] { "tool:ssh" }, push.DependencyEntryIds); }, - newer => Assert.Equal("bundle-fetch", Assert.Single(newer.Intents).Name)); + newer => Assert.Equal("bundle-fetch", Assert.Single(newer.NewIntents).Name)); } [Fact] @@ -144,7 +144,11 @@ public void ResolveSandboxPolicyWithDiagnostics_SelectsAVersionRangeAndAnIntent( Assert.Equal("vers:intdot/>=2.40|<2.50", match.VersionSelection.SelectedVersionRange); Assert.Equal("push", match.IntentSelection!.Requested); Assert.Equal("named", match.IntentSelection.Mode); - Assert.Equal("tool:ssh", Assert.Single(push.Diagnostics.ResolvedDependencies).EntryId); + var ssh = Assert.Single(push.Diagnostics.ResolvedDependencies); + Assert.Equal("tool:ssh", ssh.EntryId); + // A plain dependency reference contributes the dependency's base only. + Assert.Equal("none", ssh.IntentSelection.Mode); + Assert.Empty(ssh.IntentSelection.Selected); Assert.Equal(new[] { "/usr/bin", "/usr/lib/ssh" }, push.Policy!.Filesystem!.ReadonlyPaths); Assert.Single(push.Policy.Network!.Egress!.Allow!); } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs index 2d02b8c6f..7186151b5 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs @@ -348,9 +348,13 @@ public sealed record VersionSelection(string Status) public string? SelectedVersionRange { get; init; } } -/// Which intents of the effective policy were selected. -/// named, all, or unsupported. -/// Selected intent names, sorted; empty when unsupported. +/// +/// Which intents of the effective policy were selected. A resolved dependency +/// reports none (base only) unless its reference names intents, which +/// report named. +/// +/// named, all, none, or unsupported. +/// Selected intent names, sorted; empty when unsupported or none. public sealed record IntentSelection(string Mode, IReadOnlyList Selected) { /// The requested intent, when one was supplied. @@ -545,24 +549,24 @@ public sealed record CatalogDefaultMetadata( /// A value, or for every architecture. /// Added dependencies. /// Extensions of intents the default declares. -/// Intents the overlay introduces. +/// Intents the overlay introduces. public sealed record CatalogPlatformVariantMetadata( string Platform, string? Architecture, IReadOnlyList DependencyEntryIds, IReadOnlyList IntentAdditions, - IReadOnlyList Intents); + 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. +/// Intents the overlay introduces. public sealed record CatalogVersionVariantMetadata( string VersionRange, IReadOnlyList DependencyEntryIds, IReadOnlyList IntentAdditions, - IReadOnlyList Intents); + IReadOnlyList NewIntents); /// Entry provenance. public sealed record CatalogProvenance(string Method, string SourceRevision); From 0e73d6799ccba8838f47b28cc939626b7cc071f8 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Mon, 5 Oct 2026 11:02:42 -0700 Subject: [PATCH 38/50] docs: align policy store docs with spec 5a2c52c Document newIntents, base-only dependencies with optional named intents (mode "none"/"named"), full removal of overlapping catalog egress denies, the reviewer view's "Added to default" column, catalog revision 2026-10-05.1, and the remaining gap for symbol defaults and discovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 26 ++++++++++---- docs/policy-store/design.md | 72 +++++++++++++++++++++++++------------ 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index 7a82da884..b630e0431 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -80,18 +80,28 @@ input is a tool candidate (`invocationName`, optional `packageUrl`, No intent selects the base policy plus every intent. Non-default outcomes also appear as structured warnings. Other inputs still resolve. -6. **Close over dependencies**, depth-first, rejecting cycles. Dependencies - contribute their default, platform overlay, and all intents. +6. **Close over dependencies**, depth-first, rejecting cycles. A dependency + contributes only its default base plus its platform overlay's base + additions; it never selects a version overlay. A reference that names + intents (`{ "entryId": "tool:ssh", "intents": ["connect"] }`) adds those + intents too, and catalog validation rejects a named intent the dependency + does not define. Dependency diagnostics report intent mode `none` or + `named`. 7. **Resolve symbols.** `project_root` comes only from `projectRoot`. A missing required symbol yields no policy plus a warning naming it; the resolver never returns a partial policy. 8. **Compose** to satisfy every contributing pair, and never more. Paths are substituted, normalized, and de-duplicated; read-write supersedes - read-only; a catalog deny that overlaps a grant is removed; outbound allow - rules are unioned, so a tool without network needs never vetoes another's. - Each adjustment produces a warning. Mixed policy versions and anything the - model cannot express fail as `policy_validation` - (`composition_conflict`). + read-only; a catalog filesystem deny that overlaps a required path is + removed; outbound allow rules are unioned, so a tool without network needs + never vetoes another's; a catalog egress deny that overlaps any required + allow rule (destinations intersect after `except`, and protocol/port + selectors intersect) is removed in full, and non-overlapping denies stay. + Each adjustment produces a warning naming the full removed scope and the + contributing entries; a conflicting deny never fails the request. Mixed + policy versions, non-egress network settings across several network + requirements, and other fields the model cannot express fail as + `policy_validation` (`composition_conflict`). Verifying that the executable really carries the strong identity passed in `packageUrl`, and that `detectedVersion` is accurate, is the caller's job. @@ -136,6 +146,8 @@ Published revisions are immutable. Never edit a file under fixtures by hand. 5. Regenerate the reviewer view with `MXC_POLICY_STORE_UPDATE_VIEWS=1` (`cargo test -p mxc_policy_store --test catalog_validation`) and review it. + Its *Added to default* column shows what each platform, version, and + intent row adds to the common default. 6. Run the checks (from `src/`): ```text diff --git a/docs/policy-store/design.md b/docs/policy-store/design.md index d29095e58..b8265c393 100644 --- a/docs/policy-store/design.md +++ b/docs/policy-store/design.md @@ -18,6 +18,13 @@ overlays; per tool-and-intent resolution statuses; and composition that satisfies every requested pair. Changed sections: §4, §5.1, §5.2, §7, §9, §12, and §13. The canonical design document remains the source of truth; this copy summarizes the prototype's behavior and is not a substitute for it. + +Updated again on 2026-10-05 to align with the pushed spec (microsoft/mxc#1309 +at 5a2c52c): overlays use `newIntents` for new intents; dependencies +contribute their base only unless the reference names intents; overlapping +catalog egress denies are removed in full instead of failing; and the reviewer +view shows additions relative to the default. Changed sections: §4.2, §4.5, +§4.6, §4.7, §5.1, §5.2, §12, and §13. --> # Feature Spec: Known-tool Policy Floors @@ -205,7 +212,7 @@ another overlay grants. Abbreviated from the bundled `tool:git` entry: "filesystem": { "readonlyPaths": ["${git_prefix}"], "readwritePaths": ["${project_root}"] } }, "intents": { - "local": { "exampleSubcommands": ["status", "commit", "log"] }, + "local": { "exampleSubcommands": ["status", "diff"] }, "fetch": { "policyAdditions": { "network": { "egress": { "allow": ["…tcp/443…"] } } } }, "push": { "policyAdditions": { "network": { "egress": { "allow": ["…tcp/22…"] } } } } } @@ -218,13 +225,13 @@ another overlay grants. Abbreviated from the bundled `tool:git` entry: { "versionRange": "vers:intdot/>=2.40|<2.50", "intentAdditions": { "push": { "dependencies": [{ "entryId": "tool:ssh" }] } } }, { "versionRange": "vers:intdot/>=2.50|<3", - "intents": { "bundle-fetch": { "policyAdditions": { "…": "…" } } } } + "newIntents": { "bundle-fetch": { "policyAdditions": { "…": "…" } } } } ], "provenance": { "method": "design-example", "sourceRevision": "…" } } ``` -The bundled revision (`2026-10-02.1`) carries `tool:git`, `tool:node`, +The bundled revision (`2026-10-05.1`) carries `tool:git`, `tool:node`, `tool:npm`, and `tool:ssh`. Its network addresses are documentation ranges (RFC 5737), not real endpoints. @@ -242,7 +249,7 @@ The bundled revision (`2026-10-02.1`) carries `tool:git`, `tool:node`, overlay can. - **Intents** are lower-case names (`[a-z][a-z0-9_-]*`). An intent adds access and dependencies on top of the base policy. `intentAdditions` may - extend only intents the default declares; `intents` may introduce only + extend only intents the default declares; `newIntents` may introduce only names no other applicable overlay declares. - **Not additive**, and therefore rejected in an overlay: `deniedPaths`, `network.egress.deny`, `network.egress.default`, ingress, `ui`, and @@ -301,9 +308,16 @@ Each input is one tool-and-intent pair, resolved independently: There is no wildcard fallback and no "require all" option: other inputs still resolve, so a result may cover only some of the requested tools. -Dependencies are materialized with their default, the platform overlay, and -all intents; a dependency's `versionRange` (in the target entry's scheme) is -recorded as unevaluated metadata. +A dependency contributes only its unversioned default base plus its +applicable platform overlay's base additions; it never selects a version +overlay or includes intents. This differs from a requested tool with no +intent, which includes all effective intents. A reference may name dependency +intents (`{ "entryId": "tool:ssh", "intents": ["connect"] }`); those intents, +including their applicable platform `intentAdditions`, are then added. +Catalog validation rejects a named dependency intent that is missing from any +materialized platform combination where the reference applies. A +dependency's `versionRange` (in the target entry's scheme) is recorded as +unevaluated metadata. ### 4.6 Composition @@ -318,13 +332,20 @@ All contributing pairs and their dependency closure compose into one policy: 4. A catalog deny that overlaps any required read-only or read-write path is removed entirely, with a warning naming its scope. Non-conflicting denies are kept. -5. Outbound allow rules from every pair are unioned under a default-deny - egress posture, so a pair without network needs never vetoes another - pair's. If only one contribution uses network, its section passes through - unchanged. -6. A single contribution with no additions passes through whole, including +5. Outbound allow rules and catalog egress deny rules from every pair are + unioned (whole rules, never a cross-product) under a default-deny egress + posture, so a pair without network needs never vetoes another pair's. If + only one contribution uses network, its other network settings are kept. +6. A catalog egress deny that overlaps any required allow rule (destination + CIDRs intersect after `except` exclusions, and protocol/port selectors + intersect) is removed in full, not narrowed, within one entry or across + entries. The warning names the full removed scope, notes that other grants + may now apply throughout it, and lists the contributing entries. + Non-overlapping denies are kept, and a conflicting deny never fails the + request. +7. A single contribution with no additions passes through whole, including fields such as `ui` and `timeoutMs`. Anything else the model cannot - express across contributions (for example a catalog egress deny alongside + express across contributions (for example ingress settings alongside another contribution's network, or `timeoutMs` in a composed set) fails as `policy_validation` (`composition_conflict`) rather than granting broader access. @@ -341,8 +362,10 @@ additive-only overlays, dependency targets and their ranges, and cycles. It then materializes every platform × architecture × version × intent effective policy (including the "all intents" case) with its dependency closure, validates each composed policy, and checks that the default is a subset of -each. A Markdown reviewer view of every effective policy is generated into -`catalog/views/.md` and must stay current. +each, and that every dependency reference's named intents exist. A Markdown +reviewer view of every effective policy, with an *Added to default* column +showing each row's additions relative to the common default, is generated +into `catalog/views/.md` and must stay current. ## 5. API surface @@ -393,7 +416,7 @@ interface VersionSelection { interface IntentSelection { requested?: string; - mode: "named" | "all" | "unsupported"; + mode: "named" | "all" | "none" | "unsupported"; // dependencies: none or named selected: string[]; } @@ -481,7 +504,7 @@ interface CatalogIntentMetadata { interface CatalogAdditionsMetadata { dependencyEntryIds: string[]; intentAdditions: CatalogIntentMetadata[]; - intents: CatalogIntentMetadata[]; + newIntents: CatalogIntentMetadata[]; } interface CatalogEntryMetadata { @@ -705,11 +728,15 @@ have cached or recorded in an audit trail. cross-architecture fallback; neutral fallback warning - unresolved symbols prevent policy output; host-derived symbols only for the current host platform, overridable by the caller -- composition: read-write supersedes read-only, conflicting denies removed - with diagnostics, outbound allows unioned without a network veto, mixed - policy versions and inexpressible combinations rejected +- composition: read-write supersedes read-only, conflicting filesystem + denies removed with diagnostics, outbound allows unioned without a network + veto, overlapping catalog egress denies removed in full (within one entry + and across entries) while non-overlapping denies stay, mixed policy versions + and inexpressible combinations rejected - dependency chains compose each entry once; dependency records keep distinct - required ranges + required ranges; a plain dependency contributes its base only (mode + `none`), a reference naming intents adds them (mode `named`), and a + missing named intent fails validation **Data (CI)** @@ -736,9 +763,10 @@ Recommended answers are proposals for review, not decisions. | Question | Status | |---|---| -| Final API names? | Pending API review. Current names are kept; they may drop "Sandbox" (for example, `resolvePolicy`). | +| Final API names? | Pending API review. Follow MXC's policy-type rename that drops "Sandbox" and align the resolver names with it; current names are kept until then. | | Exact policy validator and `SandboxPolicy` version mapping used at build time | Open. The prototype validates composed effective policies with the catalog contract's own subset validator. | | Purl normalization and equality, including purl-embedded versions | Open. The prototype lower-cases the type, compares namespace and name as written, and ignores an embedded version with a warning. | +| Symbol `defaults` metadata and local discovery (caller, then discovery, then documented default) | Specified by the canonical design; not yet implemented in the prototype, which uses caller-supplied symbols and a small set of host-derived ones. | | `vers` conformance for `pypi` and `nuget` | The prototype implements practical subsets of PEP 440 and NuGet versioning. Exact conformance is open. | | How does the catalog schema evolve? | Keep it compatible within MXC 1.x; a breaking change waits for a major version. | | Is invocation-name-only identity accepted automatically? | No: it requires `allowWeakIdentityFallback`. | From 59df13b8e6dcfa4dc3e3bd7d745f111a0a9f4ba1 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 11:16:57 -0700 Subject: [PATCH 39/50] Move the policy store into mxc-sdk as an internal module Spec f7a450c makes the policy store an internal `policy_store` module of the consolidated mxc-sdk crate rather than a separate crate. Move the sources to src/mxc-sdk/src/policy_store/, the bundled catalog, schemas and conformance data to src/mxc-sdk/policy_store/, the catalog embedding to build/build_policy_store.rs, and the tests to tests/policy_store_*.rs. A doc-hidden `__policy_store` facade serves the C ABI and the contribution tests. No behavior change; the requirements API follows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/mxc_policy_store/Cargo.toml | 16 ---------- src/mxc-sdk/build.rs | 3 ++ .../build/build_policy_store.rs} | 11 ++++--- .../policy_store}/README.md | 6 ++-- .../policy_store}/catalog/contract.v1.json | 0 .../policy_store}/catalog/manifest.json | 0 .../catalog/revisions/2026-10-05.1.json | 0 .../catalog/views/2026-10-05.1.md | 2 +- .../conformance/fixtures/bundled-catalog.json | 0 .../fixtures/composition-catalog.json | 0 .../fixtures/synthetic-catalog.json | 0 .../conformance/vectors/canonical-json.json | 0 .../conformance/vectors/paths.json | 0 .../schema/catalog.v1.schema.json | 0 .../schema/manifest.v1.schema.json | 0 src/mxc-sdk/src/lib.rs | 8 +++++ .../src/policy_store}/catalog.rs | 24 +++++++-------- .../src/policy_store}/compose.rs | 12 ++++---- .../src/policy_store}/effective.rs | 16 +++++----- .../src/policy_store}/errors.rs | 0 .../src/policy_store}/history.rs | 8 ++--- .../src => mxc-sdk/src/policy_store}/host.rs | 12 ++++---- .../src => mxc-sdk/src/policy_store}/json.rs | 0 .../src/policy_store/mod.rs} | 29 +++++++++---------- .../src => mxc-sdk/src/policy_store}/model.rs | 4 +-- .../src/policy_store}/netrule.rs | 2 +- .../src => mxc-sdk/src/policy_store}/paths.rs | 4 +-- .../src => mxc-sdk/src/policy_store}/purl.rs | 0 .../src/policy_store}/request.rs | 8 ++--- .../src/policy_store}/resolver.rs | 22 +++++++------- .../src => mxc-sdk/src/policy_store}/store.rs | 8 ++--- .../src => mxc-sdk/src/policy_store}/text.rs | 0 .../src/policy_store}/validate.rs | 20 ++++++------- .../src => mxc-sdk/src/policy_store}/vers.rs | 0 .../src => mxc-sdk/src/policy_store}/view.rs | 14 ++++----- .../tests/policy_store_catalog_validation.rs} | 9 +++--- .../tests/policy_store_common}/mod.rs | 14 ++++----- .../tests/policy_store_conformance.rs} | 5 ++-- .../tests/policy_store_library.rs} | 21 +++++++------- .../tests/policy_store_vectors.rs} | 7 +++-- 40 files changed, 141 insertions(+), 144 deletions(-) delete mode 100644 src/core/mxc_policy_store/Cargo.toml rename src/{core/mxc_policy_store/build.rs => mxc-sdk/build/build_policy_store.rs} (90%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/README.md (94%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/catalog/contract.v1.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/catalog/manifest.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/catalog/revisions/2026-10-05.1.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/catalog/views/2026-10-05.1.md (98%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/conformance/fixtures/bundled-catalog.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/conformance/fixtures/composition-catalog.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/conformance/fixtures/synthetic-catalog.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/conformance/vectors/canonical-json.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/conformance/vectors/paths.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/schema/catalog.v1.schema.json (100%) rename src/{core/mxc_policy_store => mxc-sdk/policy_store}/schema/manifest.v1.schema.json (100%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/catalog.rs (98%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/compose.rs (97%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/effective.rs (98%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/errors.rs (100%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/history.rs (96%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/host.rs (95%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/json.rs (100%) rename src/{core/mxc_policy_store/src/lib.rs => mxc-sdk/src/policy_store/mod.rs} (78%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/model.rs (99%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/netrule.rs (99%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/paths.rs (99%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/purl.rs (100%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/request.rs (96%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/resolver.rs (97%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/store.rs (98%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/text.rs (100%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/validate.rs (93%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/vers.rs (100%) rename src/{core/mxc_policy_store/src => mxc-sdk/src/policy_store}/view.rs (89%) rename src/{core/mxc_policy_store/tests/catalog_validation.rs => mxc-sdk/tests/policy_store_catalog_validation.rs} (97%) rename src/{core/mxc_policy_store/tests/common => mxc-sdk/tests/policy_store_common}/mod.rs (87%) rename src/{core/mxc_policy_store/tests/conformance.rs => mxc-sdk/tests/policy_store_conformance.rs} (97%) rename src/{core/mxc_policy_store/tests/library.rs => mxc-sdk/tests/policy_store_library.rs} (98%) rename src/{core/mxc_policy_store/tests/vectors.rs => mxc-sdk/tests/policy_store_vectors.rs} (94%) diff --git a/src/core/mxc_policy_store/Cargo.toml b/src/core/mxc_policy_store/Cargo.toml deleted file mode 100644 index f8a3c5966..000000000 --- a/src/core/mxc_policy_store/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "mxc_policy_store" -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false -description = "PROTOTYPE, pending API review: the MXC policy store. Resolves known tools to a best-effort candidate floor SandboxPolicy from a statically bundled policy catalog." -build = "build.rs" - -[lib] -name = "mxc_policy_store" -path = "src/lib.rs" - -[target.'cfg(unix)'.dependencies] -# uname(2) for the kernel machine type, Linux/macOS only. -libc.workspace = true diff --git a/src/mxc-sdk/build.rs b/src/mxc-sdk/build.rs index a3c26529d..6c5809e3e 100644 --- a/src/mxc-sdk/build.rs +++ b/src/mxc-sdk/build.rs @@ -10,6 +10,8 @@ mod build_mxc_build_common; mod build_mxc_common; #[path = "build/build_mxc_telemetry.rs"] mod build_mxc_telemetry; +#[path = "build/build_policy_store.rs"] +mod build_policy_store; #[path = "build/build_nanvix_binaries.rs"] mod build_nanvix_binaries; #[cfg(feature = "link-wslcsdk")] @@ -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/core/mxc_policy_store/build.rs b/src/mxc-sdk/build/build_policy_store.rs similarity index 90% rename from src/core/mxc_policy_store/build.rs rename to src/mxc-sdk/build/build_policy_store.rs index 5a644e600..9bbe8a991 100644 --- a/src/core/mxc_policy_store/build.rs +++ b/src/mxc-sdk/build/build_policy_store.rs @@ -11,13 +11,12 @@ use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; -fn main() { +pub fn run() { let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); - let source = manifest_dir.join("catalog"); + let source = manifest_dir.join("policy_store").join("catalog"); - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed={}", source.display()); + println!("cargo:rerun-if-changed={}", source.display()); assert!( source.join("manifest.json").is_file(), @@ -73,8 +72,8 @@ fn main() { } writeln!(code, "];").unwrap(); - let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")).join("bundled_catalog.rs"); - fs::write(out, code).expect("write bundled_catalog.rs"); + 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 { diff --git a/src/core/mxc_policy_store/README.md b/src/mxc-sdk/policy_store/README.md similarity index 94% rename from src/core/mxc_policy_store/README.md rename to src/mxc-sdk/policy_store/README.md index 2b8db6b89..d05789ce2 100644 --- a/src/core/mxc_policy_store/README.md +++ b/src/mxc-sdk/policy_store/README.md @@ -1,4 +1,4 @@ -# mxc_policy_store — PROTOTYPE, pending API review +# 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 (for @@ -10,7 +10,7 @@ The MXC policy store resolves known tools to a **best-effort floor** composes with its own policy. It is not a guarantee, and it is complementary to Learning Mode rather than a replacement for it. -The V1 catalog in `catalog/` is the single source of truth. `build.rs` embeds +The V1 catalog in `catalog/` is the single source of truth. `build/build_policy_store.rs` embeds it with `include_str!`; nothing is downloaded. Each entry has one unversioned default plus additive platform, version (purl `vers` ranges in the entry's `versionScheme`), and intent overlays. A lookup takes tool candidates with an @@ -48,7 +48,7 @@ cargo test -p mxc_policy_store cargo clippy -p mxc_policy_store --all-targets -- -D warnings ``` -`tests/catalog_validation.rs` is the contribution gate: contract, +`tests/policy_store_catalog_validation.rs` is the contribution gate: contract, entry-revision history, every materialized effective policy, deterministic resolution, conformance coverage, and a current reviewer view (`MXC_POLICY_STORE_UPDATE_VIEWS=1` regenerates `catalog/views/`). diff --git a/src/core/mxc_policy_store/catalog/contract.v1.json b/src/mxc-sdk/policy_store/catalog/contract.v1.json similarity index 100% rename from src/core/mxc_policy_store/catalog/contract.v1.json rename to src/mxc-sdk/policy_store/catalog/contract.v1.json diff --git a/src/core/mxc_policy_store/catalog/manifest.json b/src/mxc-sdk/policy_store/catalog/manifest.json similarity index 100% rename from src/core/mxc_policy_store/catalog/manifest.json rename to src/mxc-sdk/policy_store/catalog/manifest.json diff --git a/src/core/mxc_policy_store/catalog/revisions/2026-10-05.1.json b/src/mxc-sdk/policy_store/catalog/revisions/2026-10-05.1.json similarity index 100% rename from src/core/mxc_policy_store/catalog/revisions/2026-10-05.1.json rename to src/mxc-sdk/policy_store/catalog/revisions/2026-10-05.1.json diff --git a/src/core/mxc_policy_store/catalog/views/2026-10-05.1.md b/src/mxc-sdk/policy_store/catalog/views/2026-10-05.1.md similarity index 98% rename from src/core/mxc_policy_store/catalog/views/2026-10-05.1.md rename to src/mxc-sdk/policy_store/catalog/views/2026-10-05.1.md index 7fdc85e54..b686ce876 100644 --- a/src/core/mxc_policy_store/catalog/views/2026-10-05.1.md +++ b/src/mxc-sdk/policy_store/catalog/views/2026-10-05.1.md @@ -1,4 +1,4 @@ - + # Catalog revision 2026-10-05.1 — reviewer view diff --git a/src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json similarity index 100% rename from src/core/mxc_policy_store/conformance/fixtures/bundled-catalog.json rename to src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json diff --git a/src/core/mxc_policy_store/conformance/fixtures/composition-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json similarity index 100% rename from src/core/mxc_policy_store/conformance/fixtures/composition-catalog.json rename to src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json diff --git a/src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json similarity index 100% rename from src/core/mxc_policy_store/conformance/fixtures/synthetic-catalog.json rename to src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json diff --git a/src/core/mxc_policy_store/conformance/vectors/canonical-json.json b/src/mxc-sdk/policy_store/conformance/vectors/canonical-json.json similarity index 100% rename from src/core/mxc_policy_store/conformance/vectors/canonical-json.json rename to src/mxc-sdk/policy_store/conformance/vectors/canonical-json.json diff --git a/src/core/mxc_policy_store/conformance/vectors/paths.json b/src/mxc-sdk/policy_store/conformance/vectors/paths.json similarity index 100% rename from src/core/mxc_policy_store/conformance/vectors/paths.json rename to src/mxc-sdk/policy_store/conformance/vectors/paths.json diff --git a/src/core/mxc_policy_store/schema/catalog.v1.schema.json b/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json similarity index 100% rename from src/core/mxc_policy_store/schema/catalog.v1.schema.json rename to src/mxc-sdk/policy_store/schema/catalog.v1.schema.json diff --git a/src/core/mxc_policy_store/schema/manifest.v1.schema.json b/src/mxc-sdk/policy_store/schema/manifest.v1.schema.json similarity index 100% rename from src/core/mxc_policy_store/schema/manifest.v1.schema.json rename to src/mxc-sdk/policy_store/schema/manifest.v1.schema.json diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index 99e3d1c2c..18bfb9ff4 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; @@ -374,6 +375,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/core/mxc_policy_store/src/catalog.rs b/src/mxc-sdk/src/policy_store/catalog.rs similarity index 98% rename from src/core/mxc_policy_store/src/catalog.rs rename to src/mxc-sdk/src/policy_store/catalog.rs index f8ab2af5a..b687169ca 100644 --- a/src/core/mxc_policy_store/src/catalog.rs +++ b/src/mxc-sdk/src/policy_store/catalog.rs @@ -3,14 +3,14 @@ //! Catalog contract, entry model (one unversioned default plus additive //! platform and version overlays), and contract/revision validation. The -//! effective-policy materialization lives in [`crate::effective`]. - -use crate::errors::{invalid_catalog, Result}; -use crate::json::{js_number_to_string, js_to_string, Json, JsonObject}; -use crate::model::{Architecture, IdentityStrength, Platform}; -use crate::purl::parse_purl; -use crate::text::{is_symbol_name, js_to_lower, replace_symbols, symbol_matches}; -use crate::vers::{VersRange, VersionScheme}; +//! 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::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}; @@ -444,7 +444,7 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> }; only_fields(peer, &["cidr", "except"], &peer_at)?; let cidr = non_empty_string(peer.get("cidr"), &format!("{peer_at}.cidr"))?; - let Some(block) = crate::netrule::parse_cidr(&cidr) else { + 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") { @@ -456,7 +456,7 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> .iter() .enumerate() { - let inside = crate::netrule::parse_cidr(value) + let inside = crate::policy_store::netrule::parse_cidr(value) .is_some_and(|excluded| block.contains(excluded)); if !inside { return fail(format!( @@ -1299,7 +1299,7 @@ pub fn validate_catalog_revision( } } - crate::effective::validate_materializations(&entries, &by_id, contract)?; + crate::policy_store::effective::validate_materializations(&entries, &by_id, contract)?; Ok(CatalogRevision { catalog_schema_version: contract.catalog_schema_version.clone(), @@ -1314,7 +1314,7 @@ pub(crate) fn entry_semantic_key(entry: &CatalogEntry) -> String { if let Json::Object(object) = &mut raw { object.remove("entryRevision"); } - crate::json::canonical_json(&raw) + crate::policy_store::json::canonical_json(&raw) } /// Formats an entry revision number as TypeScript would interpolate it. diff --git a/src/core/mxc_policy_store/src/compose.rs b/src/mxc-sdk/src/policy_store/compose.rs similarity index 97% rename from src/core/mxc_policy_store/src/compose.rs rename to src/mxc-sdk/src/policy_store/compose.rs index 23a90d573..3c1be01ff 100644 --- a/src/core/mxc_policy_store/src/compose.rs +++ b/src/mxc-sdk/src/policy_store/compose.rs @@ -5,12 +5,12 @@ //! base, its selected additions, and its dependencies combine into one //! `SandboxPolicy` that preserves the access each requested pair needs. -use crate::catalog::{Additions, CatalogPolicy}; -use crate::json::{canonical_json, cmp_utf16, Json, JsonObject}; -use crate::model::{FilesystemPolicy, Platform, SandboxPolicy}; -use crate::netrule::{describe_rule, rules_overlap}; -use crate::paths::{normalize_path, path_exact_segments, path_key_segments}; -use crate::text::symbol_matches; +use crate::policy_store::catalog::{Additions, CatalogPolicy}; +use crate::policy_store::json::{canonical_json, cmp_utf16, Json, JsonObject}; +use crate::policy_store::model::{FilesystemPolicy, Platform, SandboxPolicy}; +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 (entry, base, additions) contribution. #[derive(Clone, Debug)] diff --git a/src/core/mxc_policy_store/src/effective.rs b/src/mxc-sdk/src/policy_store/effective.rs similarity index 98% rename from src/core/mxc_policy_store/src/effective.rs rename to src/mxc-sdk/src/policy_store/effective.rs index d1aaf8c7a..6dc2dd3c1 100644 --- a/src/core/mxc_policy_store/src/effective.rs +++ b/src/mxc-sdk/src/policy_store/effective.rs @@ -6,14 +6,14 @@ //! dependency closure; and the build-time materialization of every effective //! policy the catalog can produce. -use crate::catalog::{ +use crate::policy_store::catalog::{ fail, validate_sandbox_policy, Additions, CatalogContract, CatalogEntry, Dependency, EntryIndex, IntentDefinition, Overlay, PlatformVariant, VersionVariant, }; -use crate::compose::{compose_check, compose_policy, Component}; -use crate::errors::{invalid_catalog, Result}; -use crate::json::{cmp_utf16, Json}; -use crate::model::{ +use crate::policy_store::compose::{compose_check, compose_policy, Component}; +use crate::policy_store::errors::{invalid_catalog, Result}; +use crate::policy_store::json::{cmp_utf16, Json}; +use crate::policy_store::model::{ Architecture, DependencyRecord, IntentMode, IntentSelection, Platform, SandboxPolicy, VersionSelection, }; @@ -481,13 +481,13 @@ fn rule_keys(policy: &SandboxPolicy) -> HashSet { .and_then(Json::as_array) .into_iter() .flatten() - .map(crate::json::canonical_json) + .map(crate::policy_store::json::canonical_json) .collect() } fn fs_paths( policy: &SandboxPolicy, - field: fn(&crate::model::FilesystemPolicy) -> &Option>, + field: fn(&crate::policy_store::model::FilesystemPolicy) -> &Option>, ) -> Vec { policy .filesystem @@ -504,7 +504,7 @@ fn subset_violation( effective_deps: &[String], platform: Platform, ) -> Option { - use crate::paths::path_exact_segments as seg; + 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]| { diff --git a/src/core/mxc_policy_store/src/errors.rs b/src/mxc-sdk/src/policy_store/errors.rs similarity index 100% rename from src/core/mxc_policy_store/src/errors.rs rename to src/mxc-sdk/src/policy_store/errors.rs diff --git a/src/core/mxc_policy_store/src/history.rs b/src/mxc-sdk/src/policy_store/history.rs similarity index 96% rename from src/core/mxc_policy_store/src/history.rs rename to src/mxc-sdk/src/policy_store/history.rs index a3bc98613..8eabef5b5 100644 --- a/src/core/mxc_policy_store/src/history.rs +++ b/src/mxc-sdk/src/policy_store/history.rs @@ -3,11 +3,11 @@ //! Entry-revision monotonicity and published-revision immutability -use crate::catalog::{ +use crate::policy_store::catalog::{ compare_catalog_revisions, entry_semantic_key, revision_number, CatalogRevision, }; -use crate::json::{canonical_json, Json}; -use crate::store::CatalogStore; +use crate::policy_store::json::{canonical_json, Json}; +use crate::policy_store::store::CatalogStore; use std::cmp::Ordering; use std::collections::HashMap; @@ -115,7 +115,7 @@ fn file_key(value: &Json) -> String { fn describe_js(value: &Option) -> String { match value { None => "undefined".to_string(), - Some(v) => crate::json::js_to_string(v), + Some(v) => crate::policy_store::json::js_to_string(v), } } diff --git a/src/core/mxc_policy_store/src/host.rs b/src/mxc-sdk/src/policy_store/host.rs similarity index 95% rename from src/core/mxc_policy_store/src/host.rs rename to src/mxc-sdk/src/policy_store/host.rs index 763496d6d..33396fd19 100644 --- a/src/core/mxc_policy_store/src/host.rs +++ b/src/mxc-sdk/src/policy_store/host.rs @@ -13,8 +13,8 @@ //! under Rosetta; the key is absent on Intel Macs), else the machine type. //! - Linux: the kernel machine type (`uname(2)`). -use crate::errors::{ErrorReason, PolicyCatalogError, Result}; -use crate::model::{Architecture, Platform}; +use crate::policy_store::errors::{ErrorReason, PolicyCatalogError, Result}; +use crate::policy_store::model::{Architecture, Platform}; use std::process::{Command, Stdio}; use std::sync::OnceLock; @@ -30,7 +30,7 @@ pub trait HostEnvironment: Send + Sync { /// Maps an OS-reported machine/architecture string to a catalog selector. pub fn architecture_from_machine(machine: &str) -> Option { - match crate::text::js_trim(machine).to_lowercase().as_str() { + 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, @@ -116,15 +116,15 @@ fn parse_reg_output(output: &str) -> Option { 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::text::js_is_space); + 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::text::js_is_space); + let value = after.trim_start_matches(crate::policy_store::text::js_is_space); if value.len() < after.len() { - let end = value.find(crate::text::js_is_space).unwrap_or(value.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()); } diff --git a/src/core/mxc_policy_store/src/json.rs b/src/mxc-sdk/src/policy_store/json.rs similarity index 100% rename from src/core/mxc_policy_store/src/json.rs rename to src/mxc-sdk/src/policy_store/json.rs diff --git a/src/core/mxc_policy_store/src/lib.rs b/src/mxc-sdk/src/policy_store/mod.rs similarity index 78% rename from src/core/mxc_policy_store/src/lib.rs rename to src/mxc-sdk/src/policy_store/mod.rs index 83efd3bcb..7e5f8c216 100644 --- a/src/core/mxc_policy_store/src/lib.rs +++ b/src/mxc-sdk/src/policy_store/mod.rs @@ -3,7 +3,7 @@ //! **PROTOTYPE, pending API review.** The MXC policy store: resolves known //! tools to a candidate floor [`SandboxPolicy`] from a policy catalog that is -//! bundled statically in this crate. +//! bundled statically in `mxc-sdk`. //! //! The API names, shapes, and catalog contents are proposed and may change //! before sign-off (for example, the names may drop "Sandbox"). The feature @@ -18,10 +18,9 @@ //! contacts a network service, or writes consumer state. The V1 catalog is //! compiled in and validated on first use; nothing is downloaded. //! -//! Most callers reach this crate through an MXC SDK: `mxc_sdk::policy_store` -//! in Rust, and the Node and C# SDKs through `mxc_ffi`. [`tooling`] exposes -//! the catalog contribution rules (validation, history, canonical JSON, -//! paths) that the crate's own tests enforce. +//! 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 catalog; pub mod compose; @@ -64,32 +63,32 @@ pub use store::{ /// Contribution and CI tooling (not part of the runtime lookup API). pub mod tooling { - pub use crate::catalog::{ + 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::effective::{ + pub use crate::policy_store::effective::{ materialize, materialize_entry, select_platform_variant, Effective, IntentChoice, Materialized, PlatformSelection, }; - pub use crate::history::{ + pub use crate::policy_store::history::{ check_entry_revisions, check_published_immutability, check_store_history, PublishedRevision, PublishedState, }; - pub use crate::host::architecture_from_machine; - pub use crate::json::{canonical_json, js_number_to_string, Json, JsonObject}; - pub use crate::paths::{ + pub use crate::policy_store::host::architecture_from_machine; + 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::purl::{parse_purl, ParsedPurl}; - pub use crate::store::{bundled_catalog_files, bundled_catalog_source, validate_manifest}; - pub use crate::validate::{ + 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::view::render_reviewer_view; + pub use crate::policy_store::view::render_reviewer_view; } pub use vers::{VersRange, Version, VersionScheme}; diff --git a/src/core/mxc_policy_store/src/model.rs b/src/mxc-sdk/src/policy_store/model.rs similarity index 99% rename from src/core/mxc_policy_store/src/model.rs rename to src/mxc-sdk/src/policy_store/model.rs index eceadd46d..5e61ebb83 100644 --- a/src/core/mxc_policy_store/src/model.rs +++ b/src/mxc-sdk/src/policy_store/model.rs @@ -6,8 +6,8 @@ //! SDK bindings and conformance vectors rely on (`to_json`), with absent optional fields //! omitted rather than written as `null`. -use crate::json::{Json, JsonObject}; -use crate::vers::VersionScheme; +use crate::policy_store::json::{Json, JsonObject}; +use crate::policy_store::vers::VersionScheme; use std::fmt; /// Catalog platform selector (design §4.4). diff --git a/src/core/mxc_policy_store/src/netrule.rs b/src/mxc-sdk/src/policy_store/netrule.rs similarity index 99% rename from src/core/mxc_policy_store/src/netrule.rs rename to src/mxc-sdk/src/policy_store/netrule.rs index 84301ca81..92c792931 100644 --- a/src/core/mxc_policy_store/src/netrule.rs +++ b/src/mxc-sdk/src/policy_store/netrule.rs @@ -5,7 +5,7 @@ //! parsing and the overlap test that decides whether a catalog egress deny //! conflicts with a required allow rule. -use crate::json::Json; +use crate::policy_store::json::Json; use std::net::IpAddr; /// One parsed CIDR: the address as a 128-bit value, its family, and prefix. diff --git a/src/core/mxc_policy_store/src/paths.rs b/src/mxc-sdk/src/policy_store/paths.rs similarity index 99% rename from src/core/mxc_policy_store/src/paths.rs rename to src/mxc-sdk/src/policy_store/paths.rs index 85f7d402d..1e2bf27ba 100644 --- a/src/core/mxc_policy_store/src/paths.rs +++ b/src/mxc-sdk/src/policy_store/paths.rs @@ -9,8 +9,8 @@ //! The algorithms run over UTF-16 code units, exactly like JavaScript, so //! index arithmetic and slicing match Node for every input. -use crate::model::Platform; -use crate::text::js_to_lower; +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; diff --git a/src/core/mxc_policy_store/src/purl.rs b/src/mxc-sdk/src/policy_store/purl.rs similarity index 100% rename from src/core/mxc_policy_store/src/purl.rs rename to src/mxc-sdk/src/policy_store/purl.rs diff --git a/src/core/mxc_policy_store/src/request.rs b/src/mxc-sdk/src/policy_store/request.rs similarity index 96% rename from src/core/mxc_policy_store/src/request.rs rename to src/mxc-sdk/src/policy_store/request.rs index b338ee02e..b731785d1 100644 --- a/src/core/mxc_policy_store/src/request.rs +++ b/src/mxc-sdk/src/policy_store/request.rs @@ -10,9 +10,9 @@ //! as an omitted optional field. Unknown keys and wrongly typed values are //! `malformed_request` (`invalid_context`) failures. -use crate::errors::{invalid_context, Result}; -use crate::json::{Json, JsonObject}; -use crate::model::{ResolveContext, SymbolMap, ToolCandidate, ToolInput, ToolInputs}; +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)) { @@ -145,7 +145,7 @@ pub fn parse_resolve_request(text: &str) -> Result<(ToolInputs, ResolveContext)> #[cfg(test)] mod tests { use super::*; - use crate::errors::ErrorReason; + use crate::policy_store::errors::ErrorReason; #[test] fn parses_a_full_request() { diff --git a/src/core/mxc_policy_store/src/resolver.rs b/src/mxc-sdk/src/policy_store/resolver.rs similarity index 97% rename from src/core/mxc_policy_store/src/resolver.rs rename to src/mxc-sdk/src/policy_store/resolver.rs index bb735107a..b5d511eb9 100644 --- a/src/core/mxc_policy_store/src/resolver.rs +++ b/src/mxc-sdk/src/policy_store/resolver.rs @@ -5,18 +5,18 @@ //! entry, select its version and intent, and compose every contributing //! (tool, intent) pair and its dependencies into one candidate floor. -use crate::catalog::{ +use crate::policy_store::catalog::{ entry_index, CatalogEntry, Dependency, IdentityPredicate, IntentDefinition, Overlay, SymbolSource, }; -use crate::compose::{component_symbols, compose_check, compose_policy, Component}; -use crate::effective::{ +use crate::policy_store::compose::{component_symbols, compose_check, compose_policy, Component}; +use crate::policy_store::effective::{ has_arch_specific, materialize, select_platform_variant, Closure, IntentChoice, Node, }; -use crate::errors::{invalid_catalog, invalid_context, ErrorReason, PolicyCatalogError, Result}; -use crate::host::{HostEnvironment, SystemHost}; -use crate::json::cmp_utf16; -use crate::model::{ +use crate::policy_store::errors::{invalid_catalog, invalid_context, ErrorReason, PolicyCatalogError, Result}; +use crate::policy_store::host::{HostEnvironment, SystemHost}; +use crate::policy_store::json::cmp_utf16; +use crate::policy_store::model::{ Architecture, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, Diagnostics, EntryMatchRecord, IntentMode, IntentSelection, MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, @@ -24,10 +24,10 @@ use crate::model::{ ToolResolutionStatus, ToolResolutionWarning, ToolWarningCode, VersionSelection, VersionStatus, VersionVariantMetadata, Warning, }; -use crate::paths::{case_key, is_absolute_path}; -use crate::purl::{parse_purl, ParsedPurl}; -use crate::store::{bundled_catalog_store, CatalogStore}; -use crate::text::replace_symbols; +use crate::policy_store::paths::{case_key, is_absolute_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}; diff --git a/src/core/mxc_policy_store/src/store.rs b/src/mxc-sdk/src/policy_store/store.rs similarity index 98% rename from src/core/mxc_policy_store/src/store.rs rename to src/mxc-sdk/src/policy_store/store.rs index a8fc4d48e..9bb878697 100644 --- a/src/core/mxc_policy_store/src/store.rs +++ b/src/mxc-sdk/src/policy_store/store.rs @@ -6,12 +6,12 @@ //! compiled into the native library and inherits MXC package signing //! (design §10). -use crate::catalog::{ +use crate::policy_store::catalog::{ compare_catalog_revisions, is_catalog_revision_id, validate_catalog_revision, validate_contract, CatalogContract, CatalogRevision, CATALOG_SCHEMA_VERSION, }; -use crate::errors::{invalid_catalog, ErrorReason, PolicyCatalogError, Result}; -use crate::json::{Json, JsonObject}; +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}; @@ -335,7 +335,7 @@ pub fn load_catalog_directory(directory: impl AsRef) -> Result s.clone(), - Some(other) => crate::json::js_to_string(other), + Some(other) => crate::policy_store::json::js_to_string(other), None => "undefined".to_string(), }; if git_succeeds(&["cat-file", "-e", &at(&file)], &top) { @@ -308,7 +308,7 @@ pub fn validate_catalog_directory( /// 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::store::bundled_catalog_source_dir()); - let store = crate::store::bundled_catalog_source().and_then(CatalogStore::new); + 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/core/mxc_policy_store/src/vers.rs b/src/mxc-sdk/src/policy_store/vers.rs similarity index 100% rename from src/core/mxc_policy_store/src/vers.rs rename to src/mxc-sdk/src/policy_store/vers.rs diff --git a/src/core/mxc_policy_store/src/view.rs b/src/mxc-sdk/src/policy_store/view.rs similarity index 89% rename from src/core/mxc_policy_store/src/view.rs rename to src/mxc-sdk/src/policy_store/view.rs index a44897770..4d6465354 100644 --- a/src/core/mxc_policy_store/src/view.rs +++ b/src/mxc-sdk/src/policy_store/view.rs @@ -5,11 +5,11 @@ //! effective policy, by entry, version option, platform, architecture, and //! intent, so reviewers see what each combination actually grants. -use crate::catalog::{entry_index, CatalogContract, CatalogRevision}; -use crate::effective::{materialize_entry, Materialized}; -use crate::errors::Result; -use crate::json::{cmp_utf16, Json}; -use crate::model::{Architecture, FilesystemPolicy, Platform}; +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::json::{cmp_utf16, Json}; +use crate::policy_store::model::{Architecture, FilesystemPolicy, Platform}; fn cell(values: &[String]) -> String { if values.is_empty() { @@ -161,7 +161,7 @@ pub fn render_reviewer_view( 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: 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", + "\n\n# Catalog revision {} — reviewer view\n\nEvery effective policy each entry can produce: 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 { @@ -170,7 +170,7 @@ pub fn render_reviewer_view( "\n## `{}` — {} (revision {}, `{}` versions)\n", entry.entry_id, entry.display_name, - crate::catalog::revision_number(entry.entry_revision), + crate::policy_store::catalog::revision_number(entry.entry_revision), entry.version_scheme )); let mut options: Vec> = vec![None]; diff --git a/src/core/mxc_policy_store/tests/catalog_validation.rs b/src/mxc-sdk/tests/policy_store_catalog_validation.rs similarity index 97% rename from src/core/mxc_policy_store/tests/catalog_validation.rs rename to src/mxc-sdk/tests/policy_store_catalog_validation.rs index 632ddac7a..7470f69d7 100644 --- a/src/core/mxc_policy_store/tests/catalog_validation.rs +++ b/src/mxc-sdk/tests/policy_store_catalog_validation.rs @@ -18,13 +18,14 @@ //! 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_policy_store::tooling::{ +use mxc_sdk::__policy_store::tooling::{ render_reviewer_view, validate_bundled_catalog, IdentityPredicate, Json, }; -use mxc_policy_store::{ +use mxc_sdk::__policy_store::{ bundled_catalog_store, Architecture, FixedHost, Platform, PolicyCatalog, ResolveContext, ToolCandidate, }; @@ -63,7 +64,7 @@ fn every_revision_file_is_listed_in_the_manifest() { } fn context_for( - store: &mxc_policy_store::CatalogStore, + store: &mxc_sdk::__policy_store::CatalogStore, platform: Platform, revision: &str, ) -> ResolveContext { @@ -78,7 +79,7 @@ fn context_for( for (name, definition) in store.contract().symbols() { if !matches!( definition.source, - mxc_policy_store::tooling::SymbolSource::Context + mxc_sdk::__policy_store::tooling::SymbolSource::Context ) { ctx = ctx.symbol(name, format!("{base}{sep}{name}")); } diff --git a/src/core/mxc_policy_store/tests/common/mod.rs b/src/mxc-sdk/tests/policy_store_common/mod.rs similarity index 87% rename from src/core/mxc_policy_store/tests/common/mod.rs rename to src/mxc-sdk/tests/policy_store_common/mod.rs index d089c09f8..e0515bed2 100644 --- a/src/core/mxc_policy_store/tests/common/mod.rs +++ b/src/mxc-sdk/tests/policy_store_common/mod.rs @@ -6,8 +6,8 @@ #![allow(dead_code)] -use mxc_policy_store::tooling::{Json, JsonObject}; -use mxc_policy_store::{ +use mxc_sdk::__policy_store::tooling::{Json, JsonObject}; +use mxc_sdk::__policy_store::{ Architecture, CatalogStore, ErrorReason, FixedHost, MemorySource, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, ToolInputs, }; @@ -15,7 +15,7 @@ use std::path::PathBuf; use std::sync::Arc; pub fn crate_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("policy_store") } pub fn read_json(path: PathBuf) -> Json { @@ -26,7 +26,7 @@ pub fn read_json(path: PathBuf) -> Json { /// The contract shipped with the embedded catalog. pub fn contract() -> Json { - let files = mxc_policy_store::tooling::bundled_catalog_files(); + let files = mxc_sdk::__policy_store::tooling::bundled_catalog_files(); let text = files .iter() .find(|(name, _)| *name == "contract.v1.json") @@ -63,7 +63,7 @@ pub fn catalog_for(revision: Json, host: Arc) -> PolicyCatalog { } pub fn bundled_catalog(host: Arc) -> PolicyCatalog { - PolicyCatalog::with_host(mxc_policy_store::bundled_catalog_store().unwrap(), host) + PolicyCatalog::with_host(mxc_sdk::__policy_store::bundled_catalog_store().unwrap(), host) } pub fn j(text: &str) -> Json { @@ -113,10 +113,10 @@ pub fn reason_of(result: Result) -> E /// Fixture `tools` (single input or array), through the binding parser. pub fn tools_from(value: &Json) -> ToolInputs { - mxc_policy_store::request::parse_tool_inputs(value).expect("fixture tools parse") + 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_policy_store::request::parse_resolve_context(value).expect("fixture context parses") + mxc_sdk::__policy_store::request::parse_resolve_context(value).expect("fixture context parses") } diff --git a/src/core/mxc_policy_store/tests/conformance.rs b/src/mxc-sdk/tests/policy_store_conformance.rs similarity index 97% rename from src/core/mxc_policy_store/tests/conformance.rs rename to src/mxc-sdk/tests/policy_store_conformance.rs index cf3997bed..7ae6ffe6f 100644 --- a/src/core/mxc_policy_store/tests/conformance.rs +++ b/src/mxc-sdk/tests/policy_store_conformance.rs @@ -10,11 +10,12 @@ //! 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_policy_store::tooling::{Json, JsonObject}; -use mxc_policy_store::{Architecture, Platform, PolicyCatalogError}; +use mxc_sdk::__policy_store::tooling::{Json, JsonObject}; +use mxc_sdk::__policy_store::{Architecture, Platform, PolicyCatalogError}; fn failure(result: Result) -> Option<(String, String)> { result.err().map(|e| { diff --git a/src/core/mxc_policy_store/tests/library.rs b/src/mxc-sdk/tests/policy_store_library.rs similarity index 98% rename from src/core/mxc_policy_store/tests/library.rs rename to src/mxc-sdk/tests/policy_store_library.rs index 387be56de..c386c87a3 100644 --- a/src/core/mxc_policy_store/tests/library.rs +++ b/src/mxc-sdk/tests/policy_store_library.rs @@ -5,14 +5,15 @@ //! 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_policy_store::tooling::{ +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_policy_store::{ +use mxc_sdk::__policy_store::{ get_catalog_info, list_catalog_entries, resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, FixedHost, HostEnvironment, IntentMode, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, @@ -48,7 +49,7 @@ fn with_default(policy: &str, extra: &str) -> String { format!(r#"{{"default":{{"sandboxPolicy":{policy}}}{extra}}}"#) } -fn fs_json(policy: &Option) -> Json { +fn fs_json(policy: &Option) -> Json { policy .as_ref() .expect("a policy") @@ -1657,7 +1658,7 @@ fn overlapping_catalog_egress_denies_are_removed_in_full_with_diagnostics() { #[test] fn bundled_store_verifies_and_canonical_json_ignores_formatting() { - let store = mxc_policy_store::bundled_catalog_store().unwrap(); + 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, @@ -1675,15 +1676,15 @@ fn bundled_store_verifies_and_canonical_json_ignores_formatting() { #[test] fn unreadable_revision_is_an_integrity_error_everywhere() { - let source = mxc_policy_store::MemorySource { + let source = mxc_sdk::__policy_store::MemorySource { files: HashMap::new(), - ..mxc_policy_store::MemorySource::publishing( + ..mxc_sdk::__policy_store::MemorySource::publishing( contract(), &[revision(vec![entry("tool:a", "")])], None, ) }; - let store = Arc::new(mxc_policy_store::CatalogStore::new(source).unwrap()); + 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!( @@ -1712,11 +1713,11 @@ fn revision_id_mismatch_and_invalid_manifest() { o.insert("catalogRevision", "2000-01-01.1".into()); } // A file that declares another revision id → integrity. - let source = mxc_policy_store::MemorySource { + let source = mxc_sdk::__policy_store::MemorySource { files: HashMap::from([("revisions/2000-01-01.1.json".to_string(), other.clone())]), - ..mxc_policy_store::MemorySource::publishing(contract(), &[relabeled], None) + ..mxc_sdk::__policy_store::MemorySource::publishing(contract(), &[relabeled], None) }; - let error = mxc_policy_store::CatalogStore::new(source) + let error = mxc_sdk::__policy_store::CatalogStore::new(source) .unwrap() .revision(None) .unwrap_err(); diff --git a/src/core/mxc_policy_store/tests/vectors.rs b/src/mxc-sdk/tests/policy_store_vectors.rs similarity index 94% rename from src/core/mxc_policy_store/tests/vectors.rs rename to src/mxc-sdk/tests/policy_store_vectors.rs index 3dabacddd..2722bf920 100644 --- a/src/core/mxc_policy_store/tests/vectors.rs +++ b/src/mxc-sdk/tests/policy_store_vectors.rs @@ -4,14 +4,15 @@ //! 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_policy_store::tooling::{ +use mxc_sdk::__policy_store::tooling::{ canonical_json, is_absolute_path, normalize_path, parse_purl, path_key_segments, Json, ParsedPurl, }; -use mxc_policy_store::Platform; +use mxc_sdk::__policy_store::Platform; #[test] fn canonical_json_vectors() { @@ -95,7 +96,7 @@ fn malformed_percent_encoding_is_an_invalid_purl() { #[test] fn embedded_catalog_matches_repository_catalog() { let dir = crate_dir().join("catalog"); - let embedded = mxc_policy_store::tooling::bundled_catalog_files(); + 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() From a4387cd8b1ce48a7777974fb8837d14756035fa0 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 12:07:45 -0700 Subject: [PATCH 40/50] policy store: align the core model with spec f7a450c Resolve to command-free ContainerRequirements (the v1 ContainerRequest access fields) through mxc_sdk::v1::resolve_tool_requirements and resolve_tool_requirements_with_diagnostics, with failures mapped to the SDK Error. - Catalog: default.requirements replaces sandboxPolicy; the root declares sdkContractVersion and the SDK owns the wire version. Revision 2026-10-06.1 replaces 2026-10-05.1 and the reviewer view adds a requests bundle of the exact 1.0.0 documents it validates. - PURL identity compares type, namespace and name only; invalid candidate PURLs are purl_invalid / tool_unmatched per pair and ignored components are reported as purl_components_ignored. - Warnings are structured (per-input and detail kinds) with stable codes. - Layers are de-duplicated per layer with owner attribution; dependency diagnostics carry inputIndexes. - Symbols resolve from the caller, then host values and discovery, then contract defaults, reporting symbol_resolved. - Filesystem object identity fails closed per pair (filesystem_identity_unresolved); alias paths are preserved and read-only aliases of writable paths are promoted. - Every materialized and resolved floor is validated as an exact 1.0.0 one-shot request. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mxc-sdk/build.rs | 4 +- src/mxc-sdk/build/build_policy_store.rs | 5 +- .../policy_store/catalog/contract.v1.json | 22 +- .../policy_store/catalog/manifest.json | 6 +- .../{2026-10-05.1.json => 2026-10-06.1.json} | 23 +- .../{2026-10-05.1.md => 2026-10-06.1.md} | 12 +- .../catalog/views/2026-10-06.1.requests.json | 1071 +++++++++++++++++ .../conformance/fixtures/bundled-catalog.json | 944 +++++++++++++-- .../fixtures/composition-catalog.json | 134 ++- .../fixtures/synthetic-catalog.json | 343 +++++- .../schema/catalog.v1.schema.json | 31 +- src/mxc-sdk/src/lib.rs | 25 +- src/mxc-sdk/src/policy.rs | 79 ++ src/mxc-sdk/src/policy/exact/mod.rs | 30 + src/mxc-sdk/src/policy_store/catalog.rs | 183 ++- src/mxc-sdk/src/policy_store/compose.rs | 700 +++++++---- src/mxc-sdk/src/policy_store/effective.rs | 465 ++++--- src/mxc-sdk/src/policy_store/exact.rs | 286 +++++ src/mxc-sdk/src/policy_store/host.rs | 232 +++- src/mxc-sdk/src/policy_store/mod.rs | 51 +- src/mxc-sdk/src/policy_store/model.rs | 454 ++++++- src/mxc-sdk/src/policy_store/purl.rs | 217 +++- src/mxc-sdk/src/policy_store/resolver.rs | 588 ++++++--- src/mxc-sdk/src/policy_store/sdk.rs | 81 ++ src/mxc-sdk/src/policy_store/view.rs | 77 +- .../tests/policy_store_catalog_validation.rs | 28 +- src/mxc-sdk/tests/policy_store_common/mod.rs | 8 +- src/mxc-sdk/tests/policy_store_conformance.rs | 80 +- src/mxc-sdk/tests/policy_store_library.rs | 461 +++---- src/mxc-sdk/tests/policy_store_sdk.rs | 41 + src/mxc-sdk/tests/policy_store_vectors.rs | 13 +- 31 files changed, 5476 insertions(+), 1218 deletions(-) rename src/mxc-sdk/policy_store/catalog/revisions/{2026-10-05.1.json => 2026-10-06.1.json} (94%) rename src/mxc-sdk/policy_store/catalog/views/{2026-10-05.1.md => 2026-10-06.1.md} (91%) create mode 100644 src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.requests.json create mode 100644 src/mxc-sdk/src/policy_store/exact.rs create mode 100644 src/mxc-sdk/src/policy_store/sdk.rs create mode 100644 src/mxc-sdk/tests/policy_store_sdk.rs diff --git a/src/mxc-sdk/build.rs b/src/mxc-sdk/build.rs index 6c5809e3e..92e5f3015 100644 --- a/src/mxc-sdk/build.rs +++ b/src/mxc-sdk/build.rs @@ -10,10 +10,10 @@ mod build_mxc_build_common; mod build_mxc_common; #[path = "build/build_mxc_telemetry.rs"] mod build_mxc_telemetry; -#[path = "build/build_policy_store.rs"] -mod build_policy_store; #[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; diff --git a/src/mxc-sdk/build/build_policy_store.rs b/src/mxc-sdk/build/build_policy_store.rs index 9bbe8a991..bce88e25a 100644 --- a/src/mxc-sdk/build/build_policy_store.rs +++ b/src/mxc-sdk/build/build_policy_store.rs @@ -16,7 +16,7 @@ pub fn run() { 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()); + println!("cargo:rerun-if-changed={}", source.display()); assert!( source.join("manifest.json").is_file(), @@ -72,7 +72,8 @@ pub fn run() { } writeln!(code, "];").unwrap(); - let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")).join("policy_store_catalog.rs"); + 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"); } diff --git a/src/mxc-sdk/policy_store/catalog/contract.v1.json b/src/mxc-sdk/policy_store/catalog/contract.v1.json index 5c47cfa16..63c0d8e7c 100644 --- a/src/mxc-sdk/policy_store/catalog/contract.v1.json +++ b/src/mxc-sdk/policy_store/catalog/contract.v1.json @@ -1,7 +1,6 @@ { - "$comment": "Catalog contract v1: the exact SandboxPolicy contract versions catalog entries may embed, and the symbol vocabulary entries may reference. 'context' symbols come only from ResolveContext.projectRoot; 'caller' symbols come only from ResolveContext.symbols; 'host' symbols are OS well-known folders the resolver may derive for the current host, which ResolveContext.symbols may override.", + "$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", - "sandboxPolicyVersions": ["0.8.0-alpha", "0.9.0-alpha"], "symbols": { "project_root": { "source": "context", @@ -9,19 +8,24 @@ }, "git_prefix": { "source": "caller", - "description": "Directory containing the git executable." + "description": "Directory containing the git executable (discovered on PATH when omitted)." }, "node_prefix": { "source": "caller", - "description": "Directory containing the node executable." + "description": "Directory containing the node executable (discovered on PATH when omitted)." }, "npm_prefix": { "source": "caller", - "description": "Directory containing the npm and npx launchers." + "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." + "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", @@ -33,11 +37,15 @@ }, "ssh_prefix": { "source": "caller", - "description": "Directory containing the ssh executable." + "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/manifest.json b/src/mxc-sdk/policy_store/catalog/manifest.json index 1552464b7..eeb4ec4f2 100644 --- a/src/mxc-sdk/policy_store/catalog/manifest.json +++ b/src/mxc-sdk/policy_store/catalog/manifest.json @@ -1,10 +1,10 @@ { "catalogSchemaVersion": "1", - "defaultRevision": "2026-10-05.1", + "defaultRevision": "2026-10-06.1", "revisions": [ { - "catalogRevision": "2026-10-05.1", - "file": "revisions/2026-10-05.1.json" + "catalogRevision": "2026-10-06.1", + "file": "revisions/2026-10-06.1.json" } ] } diff --git a/src/mxc-sdk/policy_store/catalog/revisions/2026-10-05.1.json b/src/mxc-sdk/policy_store/catalog/revisions/2026-10-06.1.json similarity index 94% rename from src/mxc-sdk/policy_store/catalog/revisions/2026-10-05.1.json rename to src/mxc-sdk/policy_store/catalog/revisions/2026-10-06.1.json index cef2c09d3..e31049f0a 100644 --- a/src/mxc-sdk/policy_store/catalog/revisions/2026-10-05.1.json +++ b/src/mxc-sdk/policy_store/catalog/revisions/2026-10-06.1.json @@ -1,10 +1,11 @@ { "catalogSchemaVersion": "1", - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", + "sdkContractVersion": "1.0.0", "entries": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "displayName": "Git", "versionScheme": "intdot", "identity": [ @@ -21,8 +22,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${git_prefix}" @@ -166,7 +166,7 @@ }, { "entryId": "tool:node", - "entryRevision": 1, + "entryRevision": 2, "displayName": "Node.js", "versionScheme": "semver", "identity": [ @@ -179,8 +179,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${node_prefix}" @@ -195,7 +194,7 @@ }, { "entryId": "tool:npm", - "entryRevision": 1, + "entryRevision": 2, "displayName": "npm / npx", "versionScheme": "npm", "identity": [ @@ -214,8 +213,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${npm_prefix}" @@ -239,7 +237,7 @@ }, { "entryId": "tool:ssh", - "entryRevision": 1, + "entryRevision": 2, "displayName": "OpenSSH client", "versionScheme": "intdot", "identity": [ @@ -252,8 +250,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${ssh_prefix}" diff --git a/src/mxc-sdk/policy_store/catalog/views/2026-10-05.1.md b/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.md similarity index 91% rename from src/mxc-sdk/policy_store/catalog/views/2026-10-05.1.md rename to src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.md index b686ce876..e46c98b2c 100644 --- a/src/mxc-sdk/policy_store/catalog/views/2026-10-05.1.md +++ b/src/mxc-sdk/policy_store/catalog/views/2026-10-06.1.md @@ -1,10 +1,10 @@ -# Catalog revision 2026-10-05.1 — reviewer view +# Catalog revision 2026-10-06.1 — reviewer view -Every effective policy each entry can produce: 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. +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 2, `intdot` versions) +## `tool:git` — Git (revision 3, `intdot` versions) ### Default (no detected version or out of range) @@ -60,7 +60,7 @@ Every effective policy each entry can produce: the default, plus platform additi | 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 1, `semver` versions) +## `tool:node` — Node.js (revision 2, `semver` versions) ### Default (no detected version or out of range) @@ -70,7 +70,7 @@ Every effective policy each entry can produce: the default, plus platform additi | linux | any | (all) | `${node_prefix}` | — | — | — | — | — | | macos | any | (all) | `${node_prefix}` | — | — | — | — | — | -## `tool:npm` — npm / npx (revision 1, `npm` versions) +## `tool:npm` — npm / npx (revision 2, `npm` versions) ### Default (no detected version or out of range) @@ -80,7 +80,7 @@ Every effective policy each entry can produce: the default, plus platform additi | 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 1, `intdot` versions) +## `tool:ssh` — OpenSSH client (revision 2, `intdot` versions) ### Default (no detected version or out of range) 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 index 8316a0943..503a8c26e 100644 --- a/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json +++ b/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json @@ -1,5 +1,5 @@ { - "$comment": "Language-neutral conformance cases against the bundled catalog. Every language binding (TypeScript, Rust, C#) must produce the same `expect` for each case from resolveSandboxPolicyWithDiagnostics(tools, context), and resolveSandboxPolicy must return `expect.policy`. `policy: null` is the language's absence value (undefined / None / null). `expectError` is the failure's MXC error `code` and stable `details.reason`. `tools` is a single input or an array. Expectations are generated by `MXC_POLICY_STORE_UPDATE_FIXTURES=1 cargo test -p mxc_policy_store --test conformance` and reviewed by hand.", + "$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": [ { @@ -16,8 +16,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "C:\\Program Files\\Git\\cmd", @@ -62,7 +61,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -70,7 +69,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "invocation-name", @@ -94,7 +93,13 @@ ], "resolvedDependencies": [], "warnings": [ - "input 0 ('git.exe') matched tool:git only by invocation name (weak identity)" + { + "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)" + } ] } } @@ -118,8 +123,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/local/bin" @@ -127,7 +131,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -135,7 +139,7 @@ "matches": [ { "entryId": "tool:node", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -155,7 +159,13 @@ ], "resolvedDependencies": [], "warnings": [ - "input 0 ('node') matched tool:node only by invocation name (weak identity)" + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:node", + "invocationName": "node", + "message": "input 0 ('node') matched tool:node only by invocation name (weak identity)" + } ] } } @@ -178,8 +188,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/opt/homebrew/bin" @@ -191,7 +200,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -199,7 +208,7 @@ "matches": [ { "entryId": "tool:npm", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "purl", @@ -224,7 +233,10 @@ "resolvedDependencies": [ { "entryId": "tool:node", - "entryRevision": 1, + "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -235,7 +247,15 @@ } ], "warnings": [ - "input 0 ('npx'): the version '10.9.0' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion" + { + "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)" + } ] } } @@ -261,8 +281,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin", @@ -308,7 +327,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -316,7 +335,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "invocation-name", @@ -343,7 +362,7 @@ "matches": [ { "entryId": "tool:npm", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -366,7 +385,7 @@ "matches": [ { "entryId": "tool:node", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -389,7 +408,7 @@ "matches": [ { "entryId": "tool:npm", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -410,7 +429,11 @@ "resolvedDependencies": [ { "entryId": "tool:node", - "entryRevision": 1, + "entryRevision": 2, + "inputIndexes": [ + 1, + 3 + ], "versionSelection": { "status": "matched_default" }, @@ -421,10 +444,34 @@ } ], "warnings": [ - "input 0 ('git') matched tool:git only by invocation name (weak identity)", - "input 1 ('npm') matched tool:npm only by invocation name (weak identity)", - "input 2 ('node') matched tool:node only by invocation name (weak identity)", - "input 3 ('npm') matched tool:npm only by invocation name (weak identity)" + { + "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)" + } ] } } @@ -445,8 +492,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin" @@ -490,7 +536,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -503,7 +549,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "invocation-name", @@ -530,9 +576,16 @@ { "code": "tool_unmatched", "inputIndex": 0, + "invocationName": "cargo", "message": "input 0 ('cargo') matched no eligible catalog entry" }, - "input 1 ('git') matched tool:git only by invocation name (weak identity)" + { + "code": "weak_identity", + "inputIndex": 1, + "entryId": "tool:git", + "invocationName": "git", + "message": "input 1 ('git') matched tool:git only by invocation name (weak identity)" + } ] } } @@ -551,9 +604,8 @@ } }, "expect": { - "policy": null, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -566,6 +618,7 @@ { "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" } ] @@ -580,9 +633,8 @@ "architecture": "x64" }, "expect": { - "policy": null, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [], "resolvedDependencies": [], "warnings": [] @@ -601,9 +653,8 @@ } }, "expect": { - "policy": null, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -611,7 +662,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "invocation-name", @@ -635,8 +686,24 @@ ], "resolvedDependencies": [], "warnings": [ - "input 0 ('git') matched tool:git only by invocation name (weak identity)", - "required symbol 'project_root' (needed by tool:git) is unresolved; supply ResolveContext.projectRoot; no policy was returned" + { + "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_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" + } ] } } @@ -687,8 +754,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin" @@ -732,7 +798,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -740,7 +806,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "invocation-name", @@ -767,7 +833,7 @@ "matches": [ { "entryId": "tool:node", - "entryRevision": 1, + "entryRevision": 2, "matchedIdentities": [ { "kind": "invocation-name", @@ -787,9 +853,48 @@ ], "resolvedDependencies": [], "warnings": [ - "input 0 ('git') matched tool:git only by invocation name (weak identity)", - "input 1 ('node') matched tool:node only by invocation name (weak identity)", - "read-only '/opt/node' (tool:node) is covered by read-write '/opt' (tool:git); the read-only entry was omitted" + { + "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": "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" + } ] } } @@ -812,8 +917,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin" @@ -844,7 +948,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -852,7 +956,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -901,8 +1005,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin", @@ -934,7 +1037,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -942,7 +1045,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -972,7 +1075,10 @@ "resolvedDependencies": [ { "entryId": "tool:ssh", - "entryRevision": 1, + "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -1005,8 +1111,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin" @@ -1038,7 +1143,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1046,7 +1151,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1097,9 +1202,8 @@ } }, "expect": { - "policy": null, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1107,7 +1211,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1138,7 +1242,6 @@ "code": "intent_unsupported", "inputIndex": 0, "entryId": "tool:git", - "detectedVersion": "2.45.1", "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" } @@ -1165,9 +1268,8 @@ } }, "expect": { - "policy": null, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1175,7 +1277,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1206,14 +1308,12 @@ "inputIndex": 0, "entryId": "tool:git", "detectedVersion": "2.30.0", - "intent": "bundle-fetch", "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", - "detectedVersion": "2.30.0", "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" } @@ -1240,9 +1340,8 @@ } }, "expect": { - "policy": null, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1250,7 +1349,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1276,7 +1375,6 @@ "inputIndex": 0, "entryId": "tool:git", "detectedVersion": "banana", - "intent": "fetch", "message": "input 0 ('git'): detected version 'banana' is not a valid intdot version for tool:git; the input contributes nothing" } ] @@ -1301,8 +1399,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin" @@ -1360,7 +1457,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1368,7 +1465,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1419,8 +1516,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "C:\\Program Files\\Git", @@ -1432,7 +1528,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1440,7 +1536,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1501,8 +1597,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/usr/bin" @@ -1546,7 +1641,7 @@ } }, "diagnostics": { - "catalogRevision": "2026-10-05.1", + "catalogRevision": "2026-10-06.1", "tools": [ { "inputIndex": 0, @@ -1554,7 +1649,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1584,7 +1679,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1614,7 +1709,7 @@ "matches": [ { "entryId": "tool:git", - "entryRevision": 2, + "entryRevision": 3, "matchedIdentities": [ { "kind": "purl", @@ -1649,6 +1744,7 @@ { "code": "tool_unmatched", "inputIndex": 3, + "invocationName": "cargo", "message": "input 3 ('cargo') matched no eligible catalog entry" } ] @@ -1676,6 +1772,672 @@ "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, + "status": "tool_unmatched", + "matches": [] + }, + { + "inputIndex": 1, + "status": "matched_default", + "matches": [ + { + "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)" + } + ] + } + } + }, + { + "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, + "status": "matched_default", + "matches": [ + { + "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)" + } + ] + } + } + }, + { + "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, + "status": "matched_default", + "matches": [ + { + "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": "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, + "status": "filesystem_identity_unresolved", + "matches": [ + { + "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, + "status": "filesystem_identity_unresolved", + "matches": [ + { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" + } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] + } + } + ] + }, + { + "inputIndex": 2, + "status": "matched_default", + "matches": [ + { + "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": "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, + "status": "matched_default", + "matches": [ + { + "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, + "status": "matched_default", + "matches": [ + { + "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": "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, + "status": "filesystem_identity_unresolved", + "matches": [ + { + "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": "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 index 8fc5c5055..5107aaefe 100644 --- a/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json +++ b/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json @@ -3,6 +3,7 @@ "catalog": { "catalogSchemaVersion": "1", "catalogRevision": "2000-01-02.1", + "sdkContractVersion": "1.0.0", "entries": [ { "entryId": "tool:helper", @@ -18,8 +19,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${git_prefix}/helper" @@ -86,8 +86,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${git_prefix}/fetcher" @@ -141,8 +140,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${git_prefix}/locker" @@ -203,8 +201,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/fetcher", @@ -276,6 +273,9 @@ { "entryId": "tool:helper", "entryRevision": 1, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -288,7 +288,13 @@ } ], "warnings": [ - "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:fetcher", + "invocationName": "fetcher", + "message": "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + } ] } } @@ -307,8 +313,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/locker", @@ -373,6 +378,9 @@ { "entryId": "tool:helper", "entryRevision": 1, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -383,7 +391,13 @@ } ], "warnings": [ - "input 0 ('locker') matched tool:locker only by invocation name (weak identity)" + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:locker", + "invocationName": "locker", + "message": "input 0 ('locker') matched tool:locker only by invocation name (weak identity)" + } ] } } @@ -405,8 +419,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/helper", @@ -507,6 +520,9 @@ { "entryId": "tool:helper", "entryRevision": 1, + "inputIndexes": [ + 1 + ], "versionSelection": { "status": "matched_default" }, @@ -519,8 +535,20 @@ } ], "warnings": [ - "input 0 ('helper') matched tool:helper only by invocation name (weak identity)", - "input 1 ('fetcher') matched tool:fetcher only by invocation name (weak identity)" + { + "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)" + } ] } } @@ -542,13 +570,12 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/fetcher", - "/g/locker", - "/g/helper" + "/g/helper", + "/g/locker" ] }, "network": { @@ -654,6 +681,9 @@ { "entryId": "tool:helper", "entryRevision": 1, + "inputIndexes": [ + 1 + ], "versionSelection": { "status": "matched_default" }, @@ -665,6 +695,9 @@ { "entryId": "tool:helper", "entryRevision": 1, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -677,9 +710,64 @@ } ], "warnings": [ - "input 0 ('fetcher') matched tool:fetcher only by invocation name (weak identity)", - "input 1 ('locker') matched tool:locker only by invocation name (weak identity)", - "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]" + { + "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": "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 index 4468b848c..2de347f57 100644 --- a/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json +++ b/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json @@ -3,6 +3,7 @@ "catalog": { "catalogSchemaVersion": "1", "catalogRevision": "2000-01-01.1", + "sdkContractVersion": "1.0.0", "entries": [ { "entryId": "tool:base", @@ -18,8 +19,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${git_prefix}/share" @@ -86,8 +86,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "${git_prefix}/plugins" @@ -119,8 +118,7 @@ } ], "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readwritePaths": [ "${project_root}" @@ -267,14 +265,13 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ - "/g/lib-arm64", - "/g/tools", "/g/share", - "/g/lib" + "/g/lib", + "/g/lib-arm64", + "/g/tools" ], "readwritePaths": [ "/w" @@ -321,6 +318,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -332,6 +332,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "requiredVersionRange": "vers:semver/>=22.0.0", "versionSelection": { "status": "matched_default" @@ -362,13 +365,12 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ - "/g/tools", "/g/share", - "/g/lib" + "/g/lib", + "/g/tools" ], "readwritePaths": [ "/w" @@ -433,6 +435,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -442,7 +447,21 @@ } } ], - "warnings": [] + "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" + } + ] } } }, @@ -464,8 +483,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/share", @@ -535,6 +553,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -550,8 +571,20 @@ "inputIndex": 0, "entryId": "tool:app", "detectedVersion": "1.0.0", - "intent": "serve", "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" } ] } @@ -575,7 +608,6 @@ } }, "expect": { - "policy": null, "diagnostics": { "catalogRevision": "2000-01-01.1", "tools": [ @@ -611,7 +643,6 @@ "inputIndex": 0, "entryId": "tool:app", "detectedVersion": "not a version", - "intent": "serve", "message": "input 0 ('app'): detected version 'not a version' is not a valid npm version for tool:app; the input contributes nothing" } ] @@ -636,8 +667,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/share", @@ -689,6 +719,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -698,7 +731,21 @@ } } ], - "warnings": [] + "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" + } + ] } } }, @@ -720,7 +767,6 @@ } }, "expect": { - "policy": null, "diagnostics": { "catalogRevision": "2000-01-01.1", "tools": [ @@ -761,7 +807,6 @@ "code": "intent_unsupported", "inputIndex": 0, "entryId": "tool:app", - "detectedVersion": "2.4.1", "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" } @@ -787,8 +832,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "/g/share", @@ -872,6 +916,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -883,6 +930,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "requiredVersionRange": "vers:semver/>=22.0.0", "versionSelection": { "status": "matched_default" @@ -893,7 +943,21 @@ } } ], - "warnings": [] + "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" + } + ] } } }, @@ -914,13 +978,12 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ - "/g/tools", "/g/share", - "/g/lib" + "/g/lib", + "/g/tools" ], "readwritePaths": [ "/w" @@ -965,6 +1028,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -975,7 +1041,28 @@ } ], "warnings": [ - "input 0 ('app'): the version '2.4.1' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion" + { + "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" + } ] } } @@ -1012,8 +1099,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "C:\\g\\share", @@ -1050,8 +1136,25 @@ ], "resolvedDependencies": [], "warnings": [ - "input 0 ('base') matched tool:base only by invocation name (weak identity)", - "architecture was not specified; overlays were selected for the native system architecture 'arm64'; the tool's architecture was not verified" + { + "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" + } ] } } @@ -1071,8 +1174,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ "C:\\g\\share", @@ -1109,7 +1211,13 @@ ], "resolvedDependencies": [], "warnings": [ - "input 0 ('base') matched tool:base only by invocation name (weak identity)" + { + "code": "weak_identity", + "inputIndex": 0, + "entryId": "tool:base", + "invocationName": "base", + "message": "input 0 ('base') matched tool:base only by invocation name (weak identity)" + } ] } } @@ -1127,13 +1235,12 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ - "C:\\g\\tools", "C:\\g\\share", - "C:\\g\\arm64" + "C:\\g\\arm64", + "C:\\g\\tools" ], "readwritePaths": [ "C:\\p" @@ -1194,6 +1301,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -1204,7 +1314,13 @@ } ], "warnings": [ - "input 0 ('APP.CMD') matched tool:app only by invocation name (weak identity)" + { + "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)" + } ] } } @@ -1223,7 +1339,6 @@ "allowWeakIdentityFallback": true }, "expect": { - "policy": null, "diagnostics": { "catalogRevision": "2000-01-01.1", "tools": [ @@ -1238,6 +1353,7 @@ { "code": "tool_unmatched", "inputIndex": 0, + "invocationName": "BASE", "message": "input 0 ('BASE') matched no eligible catalog entry" } ] @@ -1268,13 +1384,12 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readonlyPaths": [ - "/g/tools", "/g/share", - "/g/lib" + "/g/lib", + "/g/tools" ], "readwritePaths": [ "/w" @@ -1369,6 +1484,10 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0, + 1 + ], "versionSelection": { "status": "matched_default" }, @@ -1378,7 +1497,22 @@ } } ], - "warnings": [] + "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" + } + ] } } }, @@ -1399,8 +1533,7 @@ } }, "expect": { - "policy": { - "version": "0.9.0-alpha", + "requirements": { "filesystem": { "readwritePaths": [ "/g" @@ -1445,6 +1578,9 @@ { "entryId": "tool:base", "entryRevision": 2, + "inputIndexes": [ + 0 + ], "versionSelection": { "status": "matched_default" }, @@ -1455,9 +1591,99 @@ } ], "warnings": [ - "read-only '/g/tools' (tool:app) is covered by read-write '/g' (tool:app); the read-only entry was omitted", - "read-only '/g/share' (tool:base) is covered by read-write '/g' (tool:app); the read-only entry was omitted", - "read-only '/g/lib' (tool:base) is covered by read-write '/g' (tool:app); the read-only entry was omitted" + { + "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": "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" + } ] } } @@ -1478,7 +1704,6 @@ } }, "expect": { - "policy": null, "diagnostics": { "catalogRevision": "2000-01-01.1", "tools": [ @@ -1493,7 +1718,7 @@ { "code": "tool_unmatched", "inputIndex": 0, - "intent": "build", + "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/schema/catalog.v1.schema.json b/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json index 71267b977..a36309002 100644 --- a/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json +++ b/src/mxc-sdk/policy_store/schema/catalog.v1.schema.json @@ -2,12 +2,13 @@ "$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, composition limits) are enforced by the catalog validator, not by this schema.", + "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": { @@ -18,6 +19,10 @@ "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": { @@ -149,11 +154,11 @@ "type": "object", "additionalProperties": false, "required": [ - "sandboxPolicy" + "requirements" ], "properties": { - "sandboxPolicy": { - "$ref": "#/$defs/sandboxPolicy" + "requirements": { + "$ref": "#/$defs/requirements" }, "dependencies": { "$ref": "#/$defs/dependencies" @@ -401,17 +406,11 @@ } } }, - "sandboxPolicy": { - "description": "Catalog-supported subset of the MXC SandboxPolicy contract. Backend-specific keys (processContainer, seatbelt, lxc, containment, ...) are rejected so variants stay backend-neutral.", + "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, - "required": [ - "version" - ], "properties": { - "version": { - "type": "string" - }, "filesystem": { "type": "object", "additionalProperties": false, @@ -469,8 +468,11 @@ "ui": { "type": "object", "additionalProperties": false, + "required": [ + "disable" + ], "properties": { - "allowWindows": { + "disable": { "type": "boolean" }, "clipboard": { @@ -488,7 +490,8 @@ }, "timeoutMs": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 4294967295 } } } diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index 18bfb9ff4..87905e68c 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -297,9 +297,30 @@ 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, EntryMatchRecord, IdentityStrength, + IntentMode, IntentSelection, MatchedIdentity, NetworkRequirement, PathAccess, + PathRequirement, Platform, PlatformVariantMetadata, Provenance, PurlComponent, + ResolutionDetailWarning, ResolveContext, SymbolMap, SymbolValueSource, ToolCandidate, + ToolInput, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, + 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::{ 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/catalog.rs b/src/mxc-sdk/src/policy_store/catalog.rs index b687169ca..aa4a2dcff 100644 --- a/src/mxc-sdk/src/policy_store/catalog.rs +++ b/src/mxc-sdk/src/policy_store/catalog.rs @@ -8,6 +8,7 @@ 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}; @@ -43,13 +44,29 @@ pub enum SymbolSource { 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, - pub sandbox_policy_versions: Vec, symbols: Vec<(String, SymbolDefinition)>, } @@ -64,8 +81,9 @@ impl CatalogContract { } } -/// An embedded, validated `SandboxPolicy`. The reviewed JSON object is kept -/// as written so field order and values are preserved exactly. +/// 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, @@ -76,13 +94,6 @@ impl CatalogPolicy { &self.raw } - pub fn version(&self) -> &str { - self.raw - .get("version") - .and_then(Json::as_str) - .unwrap_or_default() - } - pub fn has_filesystem(&self) -> bool { self.raw.contains_key("filesystem") } @@ -175,7 +186,7 @@ pub struct Overlay { /// The unversioned default every entry has exactly once. #[derive(Clone, Debug, PartialEq)] pub struct EntryDefault { - pub sandbox_policy: CatalogPolicy, + pub requirements: CatalogPolicy, pub dependencies: Vec, pub intents: Vec<(String, IntentDefinition)>, } @@ -219,6 +230,8 @@ pub struct CatalogEntry { 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, } @@ -321,12 +334,7 @@ pub fn validate_contract(raw: &Json) -> Result { }; only_fields( raw, - &[ - "$comment", - "catalogSchemaVersion", - "sandboxPolicyVersions", - "symbols", - ], + &["$comment", "catalogSchemaVersion", "symbols"], "contract", )?; if raw.get("catalogSchemaVersion").and_then(Json::as_str) != Some(CATALOG_SCHEMA_VERSION) { @@ -334,11 +342,6 @@ pub fn validate_contract(raw: &Json) -> Result { "contract.catalogSchemaVersion must be '{CATALOG_SCHEMA_VERSION}'" )); } - let sandbox_policy_versions = string_array( - raw.get("sandboxPolicyVersions"), - "contract.sandboxPolicyVersions", - 1, - )?; let Some(raw_symbols) = record(raw.get("symbols")) else { return fail("contract.symbols must be an object"); }; @@ -350,7 +353,7 @@ pub fn validate_contract(raw: &Json) -> Result { }; only_fields( definition, - &["source", "description"], + &["source", "description", "defaults"], &format!("contract.symbols.{name}"), )?; let source = match definition.get("source").and_then(Json::as_str) { @@ -363,19 +366,61 @@ pub fn validate_contract(raw: &Json) -> Result { 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, }, )); } - Ok(CatalogContract { + let contract = CatalogContract { catalog_schema_version: CATALOG_SCHEMA_VERSION.to_string(), - sandbox_policy_versions, 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) } // --------------------------------------------------------------------------- @@ -414,7 +459,7 @@ pub(crate) fn validate_template_path( } // --------------------------------------------------------------------------- -// Embedded SandboxPolicy +// Embedded requirements (v1 ContainerRequest access fields) // --------------------------------------------------------------------------- fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> { @@ -517,7 +562,7 @@ fn validate_network_rules(value: &Json, at: &str, deny_list: bool) -> Result<()> Ok(()) } -pub(crate) fn validate_sandbox_policy( +pub(crate) fn validate_requirements( raw: Option<&Json>, at: &str, contract: &CatalogContract, @@ -532,17 +577,18 @@ pub(crate) fn validate_sandbox_policy( )); } } - only_fields( - raw, - &["version", "filesystem", "network", "ui", "timeoutMs"], - at, - )?; - let version = non_empty_string(raw.get("version"), &format!("{at}.version"))?; - if !contract.sandbox_policy_versions.contains(&version) { - return fail(format!( - "'{at}.version' '{version}' is not a SandboxPolicy version registered in the catalog contract" - )); + 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")); @@ -565,6 +611,11 @@ pub(crate) fn validate_sandbox_policy( 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 { @@ -620,14 +671,17 @@ pub(crate) fn validate_sandbox_policy( }; only_fields( ui, - &["allowWindows", "clipboard", "allowInputInjection"], + &["disable", "clipboard", "allowInputInjection"], &format!("{at}.ui"), )?; - for key in ["allowWindows", "allowInputInjection"] { - if let Some(value) = ui.get(key) { - if value.as_bool().is_none() { - return fail(format!("'{at}.ui.{key}' must be a boolean")); - } + 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") { @@ -637,8 +691,13 @@ pub(crate) fn validate_sandbox_policy( } } if let Some(timeout) = raw.get("timeoutMs") { - if !timeout.as_f64().is_some_and(|n| is_integer(n) && n >= 1.0) { - return fail(format!("'{at}.timeoutMs' must be a positive integer")); + 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() }) @@ -682,9 +741,9 @@ fn validate_identity(raw: Option<&Json>, at: &str) -> Result, at: &str) -> Result = key.split('\0').filter(|s| !s.is_empty()).collect(); + return fail(format!("'{at}' repeats identity '{}'", shown.join("/"))); } } Ok(predicates) @@ -962,11 +1022,11 @@ fn validate_default( "'{at}' must be an object; every entry has exactly one unversioned default" )); }; - only_fields(item, &["sandboxPolicy", "dependencies", "intents"], at)?; + only_fields(item, &["requirements", "dependencies", "intents"], at)?; Ok(EntryDefault { - sandbox_policy: validate_sandbox_policy( - item.get("sandboxPolicy"), - &format!("{at}.sandboxPolicy"), + requirements: validate_requirements( + item.get("requirements"), + &format!("{at}.requirements"), contract, )?, dependencies: validate_dependencies( @@ -1242,7 +1302,12 @@ pub fn validate_catalog_revision( }; only_fields( object, - &["catalogSchemaVersion", "catalogRevision", "entries"], + &[ + "catalogSchemaVersion", + "catalogRevision", + "sdkContractVersion", + "entries", + ], "catalog", )?; if object.get("catalogSchemaVersion").and_then(Json::as_str) @@ -1260,6 +1325,15 @@ pub fn validate_catalog_revision( "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"); }; @@ -1304,6 +1378,7 @@ pub fn validate_catalog_revision( Ok(CatalogRevision { catalog_schema_version: contract.catalog_schema_version.clone(), catalog_revision, + sdk_contract_version, entries, }) } diff --git a/src/mxc-sdk/src/policy_store/compose.rs b/src/mxc-sdk/src/policy_store/compose.rs index 3c1be01ff..26c112f0c 100644 --- a/src/mxc-sdk/src/policy_store/compose.rs +++ b/src/mxc-sdk/src/policy_store/compose.rs @@ -1,93 +1,134 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Floor composition of selected components (design §4.5): every selected -//! base, its selected additions, and its dependencies combine into one -//! `SandboxPolicy` that preserves the access each requested pair needs. +//! 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::{FilesystemPolicy, Platform, SandboxPolicy}; +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 (entry, base, additions) contribution. +/// One selected layer and the input indexes that require it. #[derive(Clone, Debug)] -pub struct Component<'a> { +pub struct Contribution<'a> { pub entry_id: &'a str, - pub base: &'a CatalogPolicy, - pub additions: Vec<&'a Additions>, + pub body: LayerBody<'a>, + pub owners: Vec, } -impl Component<'_> { - fn has_additions(&self) -> bool { - self.additions.iter().any(|a| !a.is_empty()) +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 needs_network(&self) -> bool { - self.base.field("network").is_some() - || self.additions.iter().any(|a| !a.egress_allow.is_empty()) +fn field(class: PathAccess) -> &'static str { + match class { + PathAccess::Denied => "deniedPaths", + PathAccess::Readonly => "readonlyPaths", + PathAccess::Readwrite => "readwritePaths", } +} - /// Path templates of one access class, base first, then additions. - fn paths(&self, class: Class) -> Vec<&str> { - let mut out = self.base.filesystem_field(class.field()); - for additions in &self.additions { - let extra = match class { - Class::Denied => &[][..], - Class::Readonly => &additions.readonly_paths[..], - Class::Readwrite => &additions.readwrite_paths[..], - }; - out.extend(extra.iter().map(String::as_str)); - } - out +fn label(class: PathAccess) -> &'static str { + match class { + PathAccess::Denied => "denied", + PathAccess::Readonly => "read-only", + PathAccess::Readwrite => "read-write", } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Class { - Denied, - Readonly, - Readwrite, +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 Class { - const ALL: [Class; 3] = [Class::Denied, Class::Readonly, Class::Readwrite]; +impl Group<'_> { + fn has_additions(&self) -> bool { + self.additions.iter().any(|a| !a.is_empty()) + } - fn field(self) -> &'static str { - match self { - Class::Denied => "deniedPaths", - Class::Readonly => "readonlyPaths", - Class::Readwrite => "readwritePaths", - } + 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 label(self) -> &'static str { - match self { - Class::Denied => "denied", - Class::Readonly => "read-only", - Class::Readwrite => "read-write", +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 components reference, first-seen order, with the entries -/// that need each. -pub fn component_symbols(components: &[Component<'_>]) -> Vec<(String, Vec)> { - let mut out: Vec<(String, Vec)> = Vec::new(); - for component in components { - for class in Class::ALL { - for template in component.paths(class) { +/// 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) { - match out.iter_mut().find(|(n, _)| n == name) { - Some((_, ids)) => { - if !ids.iter().any(|id| id == component.entry_id) { - ids.push(component.entry_id.to_string()); - } + 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 } - None => out.push((name.to_string(), vec![component.entry_id.to_string()])), - } + }; + let (_, ids, owners) = &mut out[index]; + add_id(ids, contribution.entry_id); + add_owners(owners, &contribution.owners); } } } @@ -95,55 +136,59 @@ pub fn component_symbols(components: &[Component<'_>]) -> Vec<(String, 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(components: &[Component<'_>]) -> Result<(), String> { - let mut versions: Vec<&str> = Vec::new(); - for component in components { - let version = component.base.version(); - if !versions.contains(&version) { - versions.push(version); - } - } - if versions.len() > 1 { - versions.sort_by(|a, b| cmp_utf16(a, b)); - return Err(format!( - "mixed sandboxPolicy.version values ({})", - versions.join(", ") - )); - } - if is_passthrough(components) { +pub fn compose_check(contributions: &[Contribution<'_>]) -> Result<(), String> { + let groups = groups(contributions); + if is_passthrough(&groups) { return Ok(()); } - for component in components { - for key in component.base.raw().keys() { - if !matches!(key, "version" | "filesystem" | "network") { + for group in &groups { + for key in group.base.into_iter().flat_map(|b| b.raw().keys()) { + if !matches!(key, "filesystem" | "network") { return Err(format!( "'{}' uses '{key}', which has no v1 cross-policy composition rule", - component.entry_id + group.entry_id )); } } } - if components.iter().filter(|c| c.needs_network()).count() <= 1 { + if groups.iter().filter(|g| g.needs_network()).count() <= 1 { return Ok(()); } - for component in components { - let Some(network) = component.base.field("network").and_then(Json::as_object) else { + 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", - component.entry_id + 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", - component.entry_id + group.entry_id )); } } @@ -154,22 +199,61 @@ pub fn compose_check(components: &[Component<'_>]) -> Result<(), String> { /// A single selected policy without additions or dependencies keeps every /// catalog-supported field without cross-policy composition. -fn is_passthrough(components: &[Component<'_>]) -> bool { - components.len() == 1 && !components[0].has_additions() +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 + } } -/// The composed policy and the composition diagnostics. +/// 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 policy: SandboxPolicy, - pub warnings: Vec, + 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 { @@ -190,34 +274,76 @@ fn ids(item: &PathItem) -> String { item.entry_ids.join(", ") } -/// Composes components that passed [`compose_check`]. `resolve` maps a path -/// template to its substituted value (identity for symbolic validation). -pub fn compose_policy( - components: &[Component<'_>], +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 Class::ALL { + for class in CLASSES { let mut items: Vec = Vec::new(); - for component in components { - for template in component.paths(class) { + for contribution in contributions { + for template in contribution.paths(class) { let path = normalize_path(&resolve(template), platform); let exact = path_exact_segments(&path, platform); - match items.iter_mut().find(|i| i.exact == exact) { - Some(item) => { - if !item.entry_ids.iter().any(|id| id == component.entry_id) { - item.entry_ids.push(component.entry_id.to_string()); - } + 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 } - None => items.push(PathItem { - folded: path_key_segments(&path, Platform::Windows), - path, - exact, - entry_ids: vec![component.entry_id.to_string()], - }), - } + }; + add_id(&mut items[index].entry_ids, contribution.entry_id); + add_owners(&mut items[index].owners, &contribution.owners); } } classes.push(items); @@ -225,7 +351,7 @@ pub fn compose_policy( // Case sensitivity of the target filesystem is not determined, so paths // compare case-sensitively; report pairs that differ only by case. - let all: Vec<(Class, &PathItem)> = Class::ALL + let all: Vec<(PathAccess, &PathItem)> = CLASSES .iter() .zip(&classes) .flat_map(|(class, items)| items.iter().map(move |i| (*class, i))) @@ -235,121 +361,247 @@ pub fn compose_policy( if is_same_or_nested(&left.folded, &right.folded) && !is_same_or_nested(&left.exact, &right.exact) { - warnings.push(format!( - "{} '{}' ({}) and {} '{}' ({}) differ only by case; filesystem case sensitivity was not determined, so they were compared case-sensitively and kept distinct", - left_class.label(), - left.path, - ids(left), - right_class.label(), - right.path, - ids(right) + 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, readwrite] = <[Vec; 3]>::try_from(classes) + let [denied, readonly, mut readwrite] = <[Vec; 3]>::try_from(classes) .unwrap_or_else(|_| unreachable!("three access classes")); - let readonly: Vec = readonly - .into_iter() - .filter(|ro| match readwrite.iter().find(|rw| is_within(&ro.exact, &rw.exact)) { - Some(rw) => { - warnings.push(format!( + // 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), - rw.path, - ids(rw) - )); - false + 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 => {} } - None => true, - }) - .collect(); + } + 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; - let denied: Vec = denied - .into_iter() - .filter(|deny| { - let grant = readwrite - .iter() - .map(|g| (Class::Readwrite, g)) - .chain(readonly.iter().map(|g| (Class::Readonly, g))) - .find(|(_, g)| is_same_or_nested(&deny.exact, &g.exact)); - match grant { - Some((class, g)) => { - warnings.push(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), - class.label(), - g.path, - ids(g), - deny.path - )); - false + // 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)); } - None => true, } - }) - .collect(); + } + 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 = components.iter().any(|c| { - c.base.has_filesystem() - || c.additions - .iter() - .any(|a| !a.readonly_paths.is_empty() || !a.readwrite_paths.is_empty()) + 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(|| FilesystemPolicy { - denied_paths: pick(denied), + let filesystem = has_filesystem.then(|| FilesystemRequirements { + denied_paths: pick(kept_denied), readonly_paths: pick(readonly), readwrite_paths: pick(readwrite), }); - let root = components[0].base; - let passthrough = is_passthrough(components); - let network = compose_network(components, &mut warnings); + let groups = groups(contributions); + let passthrough = is_passthrough(&groups); + let root = groups.first().and_then(|g| g.base); + let network = compose_network(contributions, &groups, &mut warnings); Composed { - policy: SandboxPolicy { - version: root.version().to_string(), + requirements: Requirements { filesystem, network, - ui: passthrough.then(|| root.field("ui").cloned()).flatten(), + ui: passthrough + .then(|| root.and_then(|r| r.field("ui").cloned())) + .flatten(), timeout_ms: passthrough - .then(|| root.field("timeoutMs").and_then(Json::as_f64)) + .then(|| { + root.and_then(|r| r.field("timeoutMs")) + .and_then(Json::as_f64) + .map(|n| n as u32) + }) .flatten(), }, warnings, + identity_failures: failures, } } -/// Outbound rules keyed by canonical JSON, with the entries contributing each. +/// Outbound rules keyed by canonical JSON, with their contributors. #[derive(Default)] struct Rules { - items: Vec<(String, Json, Vec)>, + 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, entry_id: &str) { + fn add(&mut self, rule: &Json, contribution: &Contribution<'_>) { let key = canonical_json(rule); - match self.items.iter_mut().find(|(k, _, _)| *k == key) { - Some((_, _, ids)) => { - if !ids.iter().any(|id| id == entry_id) { - ids.push(entry_id.to_string()); - } + 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 } - None => self - .items - .push((key, rule.clone(), vec![entry_id.to_string()])), - } + }; + 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(|(_, rule, _)| rule.clone()).collect())) + .then(|| Json::Array(self.items.iter().map(|i| i.rule.clone()).collect())) } } @@ -362,53 +614,79 @@ fn egress_rules<'j>(network: Option<&'j Json>, field: &str) -> impl Iterator], warnings: &mut Vec) -> Option { - let needing: Vec<&Component<'_>> = components.iter().filter(|c| c.needs_network()).collect(); +/// 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 component in &needing { - let network = component.base.field("network"); - for rule in egress_rules(network, "allow") { - allow.add(rule, component.entry_id); - } - for additions in &component.additions { - for rule in &additions.egress_allow { - allow.add(rule, component.entry_id); + 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); + } } - } - for rule in egress_rules(network, "deny") { - deny.add(rule, component.entry_id); } } - deny.items.retain(|(_, rule, deny_ids)| { - let Some((_, required, allow_ids)) = allow + deny.items.retain(|denied| { + let required: Vec<&RuleItem> = allow .items .iter() - .find(|(_, a, _)| rules_overlap(a, rule)) - else { + .filter(|a| rules_overlap(&a.rule, &denied.rule)) + .collect(); + if required.is_empty() { return true; - }; - warnings.push(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(rule), - deny_ids.join(", "), - describe_rule(required), - allow_ids.join(", "), - describe_rule(rule) + } + 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.field("network").and_then(Json::as_object), + [only] => only + .base + .and_then(|b| b.field("network")) + .and_then(Json::as_object), _ => None, }; let mut egress = JsonObject::new(); diff --git a/src/mxc-sdk/src/policy_store/effective.rs b/src/mxc-sdk/src/policy_store/effective.rs index 6dc2dd3c1..d574a6978 100644 --- a/src/mxc-sdk/src/policy_store/effective.rs +++ b/src/mxc-sdk/src/policy_store/effective.rs @@ -1,21 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Effective policies (design §4.3–§4.4): the default plus the selected -//! platform overlay, at most one version variant, and the selected intents; -//! dependency closure; and the build-time materialization of every effective -//! policy the catalog can produce. +//! 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_sandbox_policy, Additions, CatalogContract, CatalogEntry, Dependency, - EntryIndex, IntentDefinition, Overlay, PlatformVariant, VersionVariant, + fail, validate_requirements, Additions, CatalogContract, CatalogEntry, CatalogPolicy, + Dependency, EntryIndex, IntentDefinition, Overlay, PlatformVariant, VersionVariant, }; -use crate::policy_store::compose::{compose_check, compose_policy, Component}; +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, IntentMode, IntentSelection, Platform, SandboxPolicy, - VersionSelection, + Architecture, DependencyRecord, FilesystemRequirements, IntentMode, IntentSelection, Platform, + Requirements, VersionSelection, }; use std::collections::{HashMap, HashSet}; @@ -66,8 +67,8 @@ pub fn select_platform_variant<'a>( })) } -/// Whether the platform has architecture-specific overlays (so a neutral -/// selection is a fallback worth reporting). +/// 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 @@ -75,19 +76,57 @@ pub fn has_arch_specific(entry: &CatalogEntry, platform: Platform) -> bool { .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 additions: Vec<&'a Additions>, - pub dependencies: Vec<&'a Dependency>, + pub layers: Vec>, } /// One effective policy before intent selection. #[derive(Clone, Debug)] pub struct Effective<'a> { pub entry: &'a CatalogEntry, - pub base_additions: Vec<&'a Additions>, - pub base_dependencies: Vec<&'a Dependency>, + pub base_layers: Vec>, /// Sorted by name. pub intents: Vec>, } @@ -107,23 +146,47 @@ pub enum IntentChoice<'s> { /// The selected base plus intents of one effective policy. #[derive(Clone, Debug)] pub struct Selected<'a> { - pub additions: Vec<&'a Additions>, - pub dependencies: Vec<&'a Dependency>, + 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.additions.push(&definition.additions); - existing.dependencies.extend(&definition.dependencies); - } + Some(existing) if extend => existing.layers.push(added), Some(_) => { return Err(format!( "intent '{name}' from {origin} is already declared by another overlay" @@ -136,8 +199,7 @@ fn add_intents<'a>( } None => intents.push(EffectiveIntent { name: name.clone(), - additions: vec![&definition.additions], - dependencies: definition.dependencies.iter().collect(), + layers: vec![added], }), } } @@ -152,45 +214,76 @@ pub fn materialize<'a>( ) -> std::result::Result, String> { let mut effective = Effective { entry, - base_additions: Vec::new(), - base_dependencies: entry.default.dependencies.iter().collect(), + 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 overlays: [(Option<&'a Overlay>, String); 2] = [ + 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"); ( - platform.map(|p| &p.overlay), - platform.map_or_else(String::new, |p| { - format!( - "the {}/{} overlay", - p.platform, - p.architecture.map_or("*", Architecture::as_str) - ) - }), - ), + &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"); ( - version.map(|v| &v.overlay), - version.map_or_else(String::new, |v| { - format!("version range '{}'", v.version_range) - }), - ), - ]; - for (overlay, origin) in overlays { - let Some(overlay) = overlay else { continue }; - effective.base_additions.push(&overlay.policy_additions); - effective.base_dependencies.extend(&overlay.dependencies); + &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, &overlay.new_intents, false, &origin)?; + add_intents( + &mut effective.intents, + entry, + source, + &overlay.new_intents, + false, + &origin, + )?; } effective .intents @@ -224,41 +317,86 @@ impl<'a> Effective<'a> { return None; } } - let mut additions = self.base_additions.clone(); - let mut dependencies = self.base_dependencies.clone(); + let mut layers = self.base_layers.clone(); for intent in &chosen { - additions.extend(&intent.additions); - dependencies.extend(&intent.dependencies); + layers.extend(intent.layers.iter().cloned()); } Some(Selected { - additions, - dependencies, + layers, intent_names: chosen.iter().map(|i| i.name.clone()).collect(), }) } } -/// A node of the composed set: the component and how its platform overlay -/// was selected. -#[derive(Clone, Debug)] -pub struct Node<'a> { - pub component: Component<'a>, - /// Neutral fallback on a platform with architecture-specific overlays. - pub neutral_fallback: Option, +/// 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 (design -/// §4.5). Each (entry, base or intent) component contributes once. +/// 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 nodes: Vec>, pub records: Vec, - /// Node index per dependency entry, and the intents already added to it. - done: HashMap<&'a str, (usize, HashSet)>, + /// Platform selection of every dependency entry visited, with its owners. + pub selections: Vec<(&'a CatalogEntry, Option>, Vec)>, } impl<'a, 'm> Closure<'a, 'm> { @@ -271,25 +409,32 @@ impl<'a, 'm> Closure<'a, 'm> { by_id, platform, architecture, - nodes: Vec::new(), records: Vec::new(), - done: HashMap::new(), + selections: Vec::new(), } } - /// Adds the dependencies of a contribution of `root`, transitively. - pub fn add_dependencies( + /// Adds the dependencies of `layers` (selected for `root`), transitively. + pub fn add_layers( &mut self, root: &'a CatalogEntry, - dependencies: &[&'a Dependency], + layers: &[Layer<'a>], + owners: &[usize], + set: &mut LayerSet<'a>, ) -> Result<()> { let mut stack = vec![root.entry_id.as_str()]; - self.visit_all(dependencies, &mut stack) + 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], + dependencies: &'a [Dependency], + owners: &[usize], + set: &mut LayerSet<'a>, stack: &mut Vec<&'a str>, ) -> Result<()> { for dependency in dependencies { @@ -313,15 +458,21 @@ impl<'a, 'm> Closure<'a, 'm> { .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)); - if let Some(missing) = named.iter().find(|n| !effective.has_intent(n)) { + 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 { @@ -331,64 +482,30 @@ impl<'a, 'm> Closure<'a, 'm> { } else { IntentMode::None }, - selected: named.clone(), + selected: named, }, }; - if !self.records.contains(&record) { - self.records.push(record); - } - let (new_base, new_intents): (bool, Vec) = - match self.done.get(target.entry_id.as_str()) { - None => (true, named), - Some((_, added)) => ( - false, - named.into_iter().filter(|n| !added.contains(n)).collect(), - ), - }; - if !new_base && new_intents.is_empty() { - continue; - } - let base = effective - .select(IntentChoice::BaseOnly) - .expect("the base always selects"); - let chosen = effective - .select(IntentChoice::Set(&new_intents)) - .expect("named intents were checked"); - // `chosen` repeats the base; keep only what is new. - let base_len = base.additions.len(); - let base_dep_len = base.dependencies.len(); - let mut additions: Vec<&'a Additions> = Vec::new(); - let mut next: Vec<&'a Dependency> = Vec::new(); - if new_base { - additions.extend(&base.additions); - next.extend(&base.dependencies); - } - additions.extend(&chosen.additions[base_len..]); - next.extend(&chosen.dependencies[base_dep_len..]); - match self.done.get_mut(target.entry_id.as_str()) { - Some((index, added)) => { - added.extend(new_intents); - self.nodes[*index].component.additions.extend(additions); - } + match self.records.iter_mut().find(|r| r.same_record(&record)) { + Some(existing) => union_into(&mut existing.input_indexes, owners), None => { - self.done.insert( - &target.entry_id, - (self.nodes.len(), new_intents.into_iter().collect()), - ); - self.nodes.push(Node { - component: Component { - entry_id: &target.entry_id, - base: &target.default.sandbox_policy, - additions, - }, - neutral_fallback: selection - .filter(|s| !s.exact && has_arch_specific(target, self.platform)) - .map(|_| self.platform), - }); + 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); - self.visit_all(&next, stack)?; + for layer in &selected.layers { + set.add(layer, owners); + self.visit_all(layer.dependencies, owners, set, stack)?; + } stack.pop(); } Ok(()) @@ -425,7 +542,7 @@ impl<'a, 'm> Closure<'a, 'm> { } // --------------------------------------------------------------------------- -// Build-time materialization (design §7) +// Build-time materialization (design §4.2, §7) // --------------------------------------------------------------------------- /// One materialized effective policy, symbolic (templates unsubstituted). @@ -437,43 +554,44 @@ pub struct Materialized { pub version_range: Option, /// `None` for all intents. pub intent: Option, - pub policy: SandboxPolicy, + 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_policy: SandboxPolicy, + pub default_requirements: Requirements, pub default_dependency_entry_ids: Vec, } -fn compose_symbolic( - entry: &CatalogEntry, - selected: &Selected<'_>, - by_id: &EntryIndex<'_>, +fn compose_symbolic<'a>( + entry: &'a CatalogEntry, + selected: &Selected<'a>, + by_id: &EntryIndex<'a>, platform: Platform, architecture: Architecture, -) -> std::result::Result<(SandboxPolicy, Vec), String> { +) -> 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_dependencies(entry, &selected.dependencies) + .add_layers(entry, &selected.layers, &[0], &mut set) .map_err(|e| e.detail().to_string())?; - let mut components = vec![Component { - entry_id: &entry.entry_id, - base: &entry.default.sandbox_policy, - additions: selected.additions.clone(), - }]; - components.extend(closure.nodes.iter().map(|n| n.component.clone())); - compose_check(&components)?; - let composed = compose_policy(&components, &|t: &str| t.to_string(), platform); + 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.policy, deps)) + Ok((composed.requirements, deps)) } -fn rule_keys(policy: &SandboxPolicy) -> HashSet { - policy +fn rule_keys(requirements: &Requirements) -> HashSet { + requirements .network .as_ref() .and_then(|n| n.get("egress")) @@ -486,10 +604,10 @@ fn rule_keys(policy: &SandboxPolicy) -> HashSet { } fn fs_paths( - policy: &SandboxPolicy, - field: fn(&crate::policy_store::model::FilesystemPolicy) -> &Option>, + requirements: &Requirements, + field: fn(&FilesystemRequirements) -> &Option>, ) -> Vec { - policy + requirements .filesystem .as_ref() .and_then(|fs| field(fs).clone()) @@ -498,9 +616,9 @@ fn fs_paths( /// Every access the default grants is still granted by the effective policy. fn subset_violation( - default: &SandboxPolicy, + default: &Requirements, default_deps: &[String], - effective: &SandboxPolicy, + effective: &Requirements, effective_deps: &[String], platform: Platform, ) -> Option { @@ -535,8 +653,8 @@ fn subset_violation( } /// Materializes every platform × architecture × version × intent policy of -/// `entry` (design §7), validating composition, the composed policy, and that -/// the default is a subset of each effective policy. +/// `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<'_>, @@ -572,13 +690,16 @@ pub fn materialize_entry( .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 (policy, deps) = + let (requirements, deps) = compose_symbolic(entry, &selected, by_id, platform, architecture) .map_err(|e| invalid_catalog(format!("{at}: {e}")))?; if let Err(e) = - validate_sandbox_policy(Some(&policy.to_json()), "composed", contract) + validate_requirements(Some(&requirements.to_json()), "composed", contract) { - return fail(format!("{at}: composed policy is invalid: {}", e.detail())); + return fail(format!( + "{at}: composed requirements are invalid: {}", + e.detail() + )); } let default_choice = match choice { IntentChoice::Named(name) if !default_effective.has_intent(name) => { @@ -589,12 +710,16 @@ pub fn materialize_entry( let default_selected = default_effective .select(default_choice) .expect("default intents select"); - let (default_policy, default_deps) = + 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_policy, &default_deps, &policy, &deps, platform) - { + 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}" )); @@ -604,10 +729,10 @@ pub fn materialize_entry( architecture, version_range: range.clone(), intent: intent.clone(), - policy, + requirements, dependency_entry_ids: deps, intent_names: selected.intent_names, - default_policy, + default_requirements, default_dependency_entry_ids: default_deps, }); } @@ -617,14 +742,28 @@ pub fn materialize_entry( Ok(out) } -/// Revision-level materialization check, called from catalog validation. +/// 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 { - materialize_entry(entry, by_id, contract)?; + 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/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/host.rs b/src/mxc-sdk/src/policy_store/host.rs index 33396fd19..0f6df610e 100644 --- a/src/mxc-sdk/src/policy_store/host.rs +++ b/src/mxc-sdk/src/policy_store/host.rs @@ -12,9 +12,16 @@ //! - 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; @@ -26,11 +33,165 @@ pub trait HostEnvironment: Send + Sync { 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() { + 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, @@ -124,7 +285,9 @@ fn parse_reg_output(output: &str) -> Option { 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()); + let end = value + .find(crate::policy_store::text::js_is_space) + .unwrap_or(value.len()); if end > 0 { return Some(value[..end].to_string()); } @@ -253,20 +416,36 @@ impl HostEnvironment for SystemHost { match name { "user_home" => home_dir(), "temp_dir" => temp_dir(), - "programData" if cfg!(windows) => { - std::env::var("ProgramData").ok().filter(|v| !v.is_empty()) - } + "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 { @@ -275,6 +454,9 @@ impl FixedHost { platform, architecture, symbols: Vec::new(), + discovered: Vec::new(), + aliases: Vec::new(), + unknown_paths: Vec::new(), } } @@ -282,6 +464,23 @@ impl FixedHost { 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 { @@ -299,6 +498,29 @@ impl HostEnvironment for FixedHost { .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)] diff --git a/src/mxc-sdk/src/policy_store/mod.rs b/src/mxc-sdk/src/policy_store/mod.rs index 7e5f8c216..cc8456c59 100644 --- a/src/mxc-sdk/src/policy_store/mod.rs +++ b/src/mxc-sdk/src/policy_store/mod.rs @@ -2,18 +2,21 @@ // Licensed under the MIT License. //! **PROTOTYPE, pending API review.** The MXC policy store: resolves known -//! tools to a candidate floor [`SandboxPolicy`] from a policy catalog that is -//! bundled statically in `mxc-sdk`. +//! 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 (for example, the names may drop "Sandbox"). The feature -//! is not part of MXC 1.0. +//! 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. //! -//! The runtime lookup API ([`resolve_sandbox_policy`], -//! [`resolve_sandbox_policy_with_diagnostics`], [`get_catalog_info`], +//! 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 compose with their own policy. It is complementary to Learning +//! 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. @@ -26,6 +29,7 @@ pub mod catalog; pub mod compose; pub mod effective; pub mod errors; +pub mod exact; pub mod history; pub mod host; pub mod json; @@ -35,26 +39,29 @@ 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, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, - CatalogInfo, CatalogIntentMetadata, DefaultMetadata, DependencyRecord, Diagnostics, - EntryMatchRecord, FilesystemPolicy, IdentityStrength, IntentMode, IntentSelection, - MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, ResolveContext, - SandboxConfigResolution, SandboxPolicy, SymbolMap, ToolCandidate, ToolInput, ToolInputs, - ToolRecord, ToolResolutionStatus, ToolResolutionWarning, ToolWarningCode, VersionSelection, - VersionStatus, VersionVariantMetadata, Warning, + Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, + CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, DependencyRecord, + DetailWarningKind, Diagnostics, EntryMatchRecord, FilesystemRequirements, IdentityStrength, + IntentMode, IntentSelection, MatchedIdentity, NetworkRequirement, PathAccess, PathRequirement, + Platform, PlatformVariantMetadata, Provenance, PurlComponent, Requirements, + RequirementsResolution, ResolutionDetailWarning, ResolveContext, SymbolMap, SymbolValueSource, + ToolCandidate, ToolInput, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, + ToolWarningKind, VersionSelection, VersionStatus, VersionVariantMetadata, Warning, }; pub use resolver::{ - get_catalog_info, list_catalog_entries, resolve_sandbox_policy, - resolve_sandbox_policy_with_diagnostics, PolicyCatalog, + 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, @@ -73,22 +80,28 @@ pub mod tooling { 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; + 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::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_reviewer_view; + 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 index 5e61ebb83..9612a252e 100644 --- a/src/mxc-sdk/src/policy_store/model.rs +++ b/src/mxc-sdk/src/policy_store/model.rs @@ -305,9 +305,9 @@ impl IdentityStrength { } } -/// The filesystem part of a composed policy. +/// The filesystem part of composed requirements. #[derive(Clone, Debug, Default, PartialEq)] -pub struct FilesystemPolicy { +pub struct FilesystemRequirements { pub denied_paths: Option>, pub readonly_paths: Option>, pub readwrite_paths: Option>, @@ -317,7 +317,11 @@ pub(crate) fn strings(values: &[String]) -> Json { Json::Array(values.iter().map(|v| Json::String(v.clone())).collect()) } -impl FilesystemPolicy { +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 { @@ -333,21 +337,22 @@ impl FilesystemPolicy { } } -/// The catalog-supported subset of MXC's `SandboxPolicy`, structurally -/// compatible with the MXC SDK type. `network` and `ui` are carried as JSON. +/// 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 SandboxPolicy { - pub version: String, - pub filesystem: Option, +pub struct Requirements { + pub filesystem: Option, pub network: Option, pub ui: Option, - pub timeout_ms: Option, + pub timeout_ms: Option, } -impl SandboxPolicy { +impl Requirements { + /// The `ContainerRequirements` JSON shape used by the Node and C# SDKs. pub fn to_json(&self) -> Json { let mut object = JsonObject::new(); - object.insert("version", Json::String(self.version.clone())); if let Some(fs) = &self.filesystem { object.insert("filesystem", fs.to_json()); } @@ -358,7 +363,7 @@ impl SandboxPolicy { object.insert("ui", ui.clone()); } if let Some(timeout) = self.timeout_ms { - object.insert("timeoutMs", Json::Number(timeout)); + object.insert("timeoutMs", Json::Number(f64::from(timeout))); } Json::Object(object) } @@ -396,6 +401,9 @@ 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 { @@ -404,6 +412,7 @@ impl ToolResolutionStatus { ToolResolutionStatus::Version(status) => status.as_str(), ToolResolutionStatus::IntentUnsupported => "intent_unsupported", ToolResolutionStatus::ToolUnmatched => "tool_unmatched", + ToolResolutionStatus::FilesystemIdentityUnresolved => "filesystem_identity_unresolved", } } } @@ -506,27 +515,84 @@ pub struct ToolRecord { 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, } -/// Code of a structured per-input warning. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ToolWarningCode { - VersionOutOfRange, - VersionUnparseable, - IntentUnsupported, - ToolUnmatched, +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 ToolWarningCode { +impl PurlComponent { pub fn as_str(self) -> &'static str { match self { - ToolWarningCode::VersionOutOfRange => "version_out_of_range", - ToolWarningCode::VersionUnparseable => "version_unparseable", - ToolWarningCode::IntentUnsupported => "intent_unsupported", - ToolWarningCode::ToolUnmatched => "tool_unmatched", + 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", } } } @@ -534,56 +600,320 @@ impl ToolWarningCode { /// A structured per-input warning (design §5.1 `ToolResolutionWarning`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ToolResolutionWarning { - pub code: ToolWarningCode, pub input_index: usize, - pub entry_id: Option, - pub detected_version: Option, - pub intent: Option, + pub kind: ToolWarningKind, pub message: String, } -/// A diagnostics warning: a structured per-input warning or free text. +/// 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 (design §5.1 +/// `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 { - Text(String), Tool(ToolResolutionWarning), + Detail(ResolutionDetailWarning), } impl Warning { - /// The human-readable message. + 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::Text(text) => text, 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::Text(text) => Json::String(text.clone()), Warning::Tool(w) => { - let mut o = JsonObject::new(); - o.insert("code", w.code.as_str().into()); o.insert("inputIndex", Json::Number(w.input_index as f64)); - if let Some(v) = &w.entry_id { - o.insert("entryId", v.as_str().into()); - } - if let Some(v) = &w.detected_version { - o.insert("detectedVersion", v.as_str().into()); + 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()); + } } - if let Some(v) = &w.intent { - o.insert("intent", v.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) } } - } -} - -impl From for Warning { - fn from(value: String) -> Self { - Warning::Text(value) + Json::Object(o) } } @@ -646,6 +976,7 @@ impl Diagnostics { 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())); } @@ -663,19 +994,20 @@ impl Diagnostics { } } -/// Result of `resolve_sandbox_policy_with_diagnostics` (design §5.1). +/// Result of a diagnostics lookup (design §5.1 `ToolRequirementsResolution`) +/// in the catalog's JSON model. #[derive(Clone, Debug, PartialEq)] -pub struct SandboxConfigResolution { - pub policy: Option, +pub struct RequirementsResolution { + pub requirements: Option, pub diagnostics: Diagnostics, } -impl SandboxConfigResolution { - /// `{"policy"?, "diagnostics"}`; `policy` is omitted when absent. +impl RequirementsResolution { + /// `{"requirements"?, "diagnostics"}`; `requirements` is omitted when absent. pub fn to_json(&self) -> Json { let mut object = JsonObject::new(); - if let Some(policy) = &self.policy { - object.insert("policy", policy.to_json()); + if let Some(requirements) = &self.requirements { + object.insert("requirements", requirements.to_json()); } object.insert("diagnostics", self.diagnostics.to_json()); Json::Object(object) @@ -749,7 +1081,6 @@ impl CatalogAdditionsMetadata { #[derive(Clone, Debug, PartialEq, Eq)] pub struct DefaultMetadata { pub dependency_entry_ids: Vec, - pub sandbox_policy_version: String, pub intents: Vec, } @@ -813,10 +1144,6 @@ impl CatalogEntryMetadata { "dependencyEntryIds", strings(&self.default.dependency_entry_ids), ); - default.insert( - "sandboxPolicyVersion", - self.default.sandbox_policy_version.as_str().into(), - ); default.insert("intents", intents_json(&self.default.intents)); o.insert("default", Json::Object(default)); let platform_variants = self @@ -854,6 +1181,7 @@ impl CatalogEntryMetadata { pub struct CatalogInfo { pub catalog_schema_version: String, pub catalog_revision: String, + pub sdk_contract_version: String, } impl CatalogInfo { @@ -864,6 +1192,10 @@ impl CatalogInfo { 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/purl.rs b/src/mxc-sdk/src/policy_store/purl.rs index 91f2115dc..f3c298f94 100644 --- a/src/mxc-sdk/src/policy_store/purl.rs +++ b/src/mxc-sdk/src/policy_store/purl.rs @@ -1,14 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Package URL parsing reduced to identity matching. +//! 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 package URL. +/// A parsed, percent-decoded package URL. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParsedPurl { - /// `type/[namespace/]name` with the type lower-cased. Version-free. - pub key: String, + /// 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`. @@ -40,34 +51,120 @@ fn is_purl_type(value: &str) -> bool { && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '+' || c == '-') } -/// Parses `pkg:type/namespace/name@version?qualifiers#subpath`. +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 { - let rest = value.strip_prefix("pkg:")?; - let rest = rest.split('#').next().unwrap_or(""); - let mut rest = rest.split('?').next().unwrap_or(""); - let last_slash = rest.rfind('/'); - let at = rest.rfind('@'); - let mut version = None; - if let Some(at) = at { - if last_slash.is_none_or(|slash| at > slash) { - version = Some(decode_uri_component(&rest[at + 1..])?); - rest = &rest[..at]; + 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 segments: Vec<&str> = rest.split('/').collect(); - if segments.len() < 2 || segments.iter().any(|s| s.is_empty()) { + 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; } - if !is_purl_type(segments[0]) { + 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 { - key: format!( - "{}/{}", - segments[0].to_ascii_lowercase(), - segments[1..].join("/") - ), + package_type, + namespace, + name, version: version.filter(|v| !v.is_empty()), + has_qualifiers, + has_subpath, + key, }) } @@ -75,36 +172,62 @@ pub fn parse_purl(value: &str) -> Option { mod tests { use super::*; - fn purl(key: &str, version: Option<&str>) -> Option { - Some(ParsedPurl { - key: key.into(), - version: version.map(Into::into), - }) + fn key(value: &str) -> Option { + parse_purl(value).map(|p| p.key) } #[test] - fn parses_like_typescript() { + 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!( - parse_purl("pkg:npm/npm@10.9.0"), - purl("npm/npm", Some("10.9.0")) + 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/%40scope/pkg@1.0.0?x=y#sub"), - purl("npm/%40scope/pkg", Some("1.0.0")) - ); - assert_eq!(parse_purl("pkg:npm/npm"), purl("npm/npm", None)); - assert_eq!(parse_purl("pkg:npm/npm@"), purl("npm/npm", None)); - assert_eq!( - parse_purl("pkg:npm/a@%E2%82%AC"), - purl("npm/a", Some("\u{20ac}")) + parse_purl("pkg:npm/a@%E2%82%AC") + .unwrap() + .version + .as_deref(), + Some("\u{20ac}") ); - assert_eq!(parse_purl("npm/npm"), None); - assert_eq!(parse_purl("pkg:npm"), None); - assert_eq!(parse_purl("pkg:npm//x"), None); - assert_eq!(parse_purl("pkg:1pm/x"), None); - assert_eq!(parse_purl("pkg:npm/npm@%E0%A4%A"), None); - assert_eq!(parse_purl("pkg:npm/npm@%C0%AF"), None); - assert_eq!(parse_purl("pkg:npm/npm@%ED%A0%80"), None); - assert_eq!(parse_purl("pkg:npm/npm@%zz"), None); + } + + #[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/resolver.rs b/src/mxc-sdk/src/policy_store/resolver.rs index b5d511eb9..c111e6c56 100644 --- a/src/mxc-sdk/src/policy_store/resolver.rs +++ b/src/mxc-sdk/src/policy_store/resolver.rs @@ -9,22 +9,29 @@ use crate::policy_store::catalog::{ entry_index, CatalogEntry, Dependency, IdentityPredicate, IntentDefinition, Overlay, SymbolSource, }; -use crate::policy_store::compose::{component_symbols, compose_check, compose_policy, Component}; +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, Node, + 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::errors::{invalid_catalog, invalid_context, ErrorReason, PolicyCatalogError, Result}; -use crate::policy_store::host::{HostEnvironment, SystemHost}; +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, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, - CatalogInfo, CatalogIntentMetadata, DefaultMetadata, Diagnostics, EntryMatchRecord, IntentMode, - IntentSelection, MatchedIdentity, Platform, PlatformVariantMetadata, Provenance, - ResolveContext, SandboxConfigResolution, SandboxPolicy, ToolCandidate, ToolInputs, ToolRecord, - ToolResolutionStatus, ToolResolutionWarning, ToolWarningCode, VersionSelection, VersionStatus, - VersionVariantMetadata, Warning, + Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, + CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, + DetailWarningKind, Diagnostics, EntryMatchRecord, IntentMode, IntentSelection, MatchedIdentity, + Platform, PlatformVariantMetadata, Provenance, PurlComponent, Requirements, + RequirementsResolution, ResolutionDetailWarning, ResolveContext, SymbolValueSource, + ToolCandidate, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, + ToolWarningKind, VersionSelection, VersionStatus, VersionVariantMetadata, Warning, }; -use crate::policy_store::paths::{case_key, is_absolute_path}; +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; @@ -61,19 +68,33 @@ fn describe_input(index: usize, tool: &ToolCandidate) -> String { format!("input {index} ('{}')", tool.invocation_name) } -fn tool_warning( - code: ToolWarningCode, - input_index: usize, - entry_id: Option<&str>, - tool: &ToolCandidate, - message: String, -) -> Warning { +fn tool_warning(input_index: usize, kind: ToolWarningKind, message: String) -> Warning { Warning::Tool(ToolResolutionWarning { - code, input_index, - entry_id: entry_id.map(str::to_string), - detected_version: tool.detected_version.clone(), - intent: tool.intent.clone(), + 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, }) } @@ -161,6 +182,7 @@ impl PolicyCatalog { Ok(CatalogInfo { catalog_schema_version: revision.catalog_schema_version.clone(), catalog_revision: revision.catalog_revision.clone(), + sdk_contract_version: revision.sdk_contract_version.clone(), }) } @@ -193,7 +215,6 @@ impl PolicyCatalog { .collect(), default: DefaultMetadata { dependency_entry_ids: dependency_ids(&entry.default.dependencies), - sandbox_policy_version: entry.default.sandbox_policy.version().to_string(), intents: intent_metadata(&entry.default.intents), }, platform_variants: entry @@ -225,28 +246,28 @@ impl PolicyCatalog { // Runtime lookup (design §5.1) // ----------------------------------------------------------------------- - /// The composed candidate policy, or `None` when no policy can be resolved. - pub fn resolve_sandbox_policy( + /// 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)?.policy) + ) -> Result> { + Ok(self.resolve(&tools.into(), ctx)?.requirements) } /// Resolves one tool or a list of tools in one pass: the composed - /// candidate policy with attribution and warnings. Library failures are + /// requirements with attribution and warnings. Library failures are /// errors, never absence. The result is a best-effort floor, not /// authorization. - pub fn resolve_sandbox_policy_with_diagnostics( + pub fn resolve_requirements_with_diagnostics( &self, tools: impl Into, ctx: &ResolveContext, - ) -> Result { + ) -> Result { self.resolve(&tools.into(), ctx) } - fn resolve(&self, tools: &ToolInputs, ctx: &ResolveContext) -> Result { + 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)?; @@ -270,12 +291,68 @@ impl PolicyCatalog { let mut ordered: Vec<&CatalogEntry> = revision.entries.iter().collect(); ordered.sort_by(|a, b| cmp_utf16(&a.entry_id, &b.entry_id)); - let mut roots: Vec> = Vec::new(); - let mut contributed: HashSet<(String, String, Option, String)> = HashSet::new(); + 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 purl = self.parse_input_purl(tool, input_index, &mut warnings)?; + let unmatched = |records: &mut Vec| { + records.push(ToolRecord { + input_index, + status: ToolResolutionStatus::ToolUnmatched, + matches: Vec::new(), + }); + }; + // 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, @@ -293,30 +370,33 @@ impl PolicyCatalog { format!(": {}", skipped.join("; ")) }; warnings.push(tool_warning( - ToolWarningCode::ToolUnmatched, input_index, - None, - tool, + ToolWarningKind::ToolUnmatched { + invocation_name: tool.invocation_name.clone(), + }, format!( "{} matched no eligible catalog entry{suffix}", describe_input(input_index, tool) ), )); - tool_records.push(ToolRecord { - input_index, - status: ToolResolutionStatus::ToolUnmatched, - matches: Vec::new(), - }); + unmatched(&mut tool_records); continue; } }; let entry = choice.entry; if !choice.strong { - warnings.push(Warning::Text(format!( - "{} matched {} only by invocation name (weak identity)", - describe_input(input_index, tool), - entry.entry_id - ))); + 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 @@ -345,10 +425,11 @@ impl PolicyCatalog { None => { record.version_selection.status = VersionStatus::VersionUnparseable; warnings.push(tool_warning( - ToolWarningCode::VersionUnparseable, input_index, - Some(&entry.entry_id), - tool, + 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), @@ -379,10 +460,11 @@ impl PolicyCatalog { None => { record.version_selection.status = VersionStatus::VersionOutOfRange; warnings.push(tool_warning( - ToolWarningCode::VersionOutOfRange, input_index, - Some(&entry.entry_id), - tool, + 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), @@ -413,10 +495,11 @@ impl PolicyCatalog { }); let available = effective.intent_names(); warnings.push(tool_warning( - ToolWarningCode::IntentUnsupported, input_index, - Some(&entry.entry_id), - tool, + 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), @@ -450,119 +533,195 @@ impl PolicyCatalog { status, matches: vec![record], }); - - let key = ( - entry.entry_id.clone(), - version_variant - .map(|v| v.version_range.as_str().to_string()) - .unwrap_or_default(), - selection.map(|s| s.index), - tool.intent.clone().unwrap_or_else(|| "*".to_string()), - ); - if contributed.insert(key) { - roots.push(Node { - component: Component { - entry_id: &entry.entry_id, - base: &entry.default.sandbox_policy, - additions: selected.additions.clone(), - }, - neutral_fallback: selection - .filter(|s| !s.exact && has_arch_specific(entry, platform)) - .map(|_| platform), - }); - closure.add_dependencies(entry, &selected.dependencies)?; + 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 resolved_dependencies = closure.sorted_records(); - let mut nodes = roots; - nodes.extend(closure.nodes); let mut diagnostics = Diagnostics { catalog_revision: revision.catalog_revision.clone(), tools: tool_records, - resolved_dependencies, + resolved_dependencies: Vec::new(), warnings: Vec::new(), }; - if nodes.is_empty() { + if set.is_empty() { diagnostics.warnings = warnings; - return Ok(SandboxConfigResolution { - policy: None, + return Ok(RequirementsResolution { + requirements: None, diagnostics, }); } - if ctx_architecture.is_none() { - if let Some(native) = lazy.value.get() { - warnings.push(Warning::Text(format!( - "architecture was not specified; overlays were selected for the native system architecture '{native}'; the tool's architecture was not verified" - ))); + // 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)); + } } } - let mut reported: HashSet<&str> = HashSet::new(); - for node in &nodes { - if let Some(platform) = node.neutral_fallback { - if reported.insert(node.component.entry_id) { - warnings.push(Warning::Text(format!( - "{} uses its architecture-neutral {platform} additions; no {}-specific overlay exists", - node.component.entry_id, - architecture()? - ))); + 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 components: Vec> = nodes.into_iter().map(|n| n.component).collect(); - if let Err(violation) = compose_check(&components) { + 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 symbols = self.resolve_symbols(&components, ctx, platform, &mut warnings)?; - let policy = match symbols { + let Some(symbols) = self.resolve_symbols( + &set.contributions(&excluded_none), + ctx, + platform, + &mut warnings, + )? + else { + 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; + } + } + 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(symbols) => { - let composed = compose_policy( - &components, - &|template: &str| replace_symbols(template, |name| symbols[name].clone()), - platform, - ); - warnings.extend(composed.warnings.into_iter().map(Warning::Text)); - Some(composed.policy) + 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) } }; diagnostics.warnings = warnings; - Ok(SandboxConfigResolution { - policy, + Ok(RequirementsResolution { + requirements, diagnostics, }) } - fn parse_input_purl( - &self, - tool: &ToolCandidate, - input_index: usize, - warnings: &mut Vec, - ) -> Result> { - let Some(package_url) = &tool.package_url else { - return Ok(None); - }; - let Some(purl) = parse_purl(package_url) else { - return Err(invalid_context(format!( - "{}: '{package_url}' is not a valid package URL", - describe_input(input_index, tool) - ))); - }; - if let Some(version) = &purl.version { - warnings.push(Warning::Text(format!( - "{}: the version '{version}' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion", - describe_input(input_index, tool) - ))); - } - Ok(Some(purl)) - } - /// 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)] @@ -699,69 +858,125 @@ impl PolicyCatalog { Ok((platform, architecture)) } - /// Resolves every symbol the selected components need; `None` (with - /// warnings) when any is unresolved. Never a partial policy. + /// 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, - components: &[Component<'_>], + 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::new(); - for (name, entry_ids) in component_symbols(components) { + 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 value = if definition.source == SymbolSource::Context { - ctx.project_root.clone() - } else { - let supplied = ctx - .symbols - .as_ref() - .and_then(|s| s.get(&name)) - .map(str::to_string); - if supplied.is_none() - && definition.source == SymbolSource::Host - && self.host.platform().ok() == Some(platform) - { - let discovered = self.host.symbol(&name); - if let Some(value) = &discovered { - warnings.push(Warning::Text(format!( - "symbol '{name}' resolved from the host environment to '{value}'" - ))); - } - discovered + let mut value: Option<(String, SymbolValueSource)> = + if definition.source == SymbolSource::Context { + ctx.project_root + .clone() + .map(|v| (v, SymbolValueSource::Caller)) } else { - supplied + 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) = value else { - missing.push((name, entry_ids)); + } + 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" + "symbol '{name}' must resolve to an absolute {platform} path (from {} value '{value}')", + source.as_str() ))); } + if source != SymbolValueSource::Caller { + 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) in missing { + 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}") }; - warnings.push(Warning::Text(format!( - "required symbol '{name}' (needed by {}) is unresolved; supply {hint}; no policy was returned", + 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); } @@ -769,6 +984,23 @@ impl PolicyCatalog { } } +/// 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() { @@ -778,20 +1010,20 @@ fn bundled() -> Result<&'static PolicyCatalog> { Ok(CATALOG.get_or_init(|| catalog)) } -/// Composed candidate policy for one or several tools, from the bundled catalog. -pub fn resolve_sandbox_policy( +/// Composed requirements for one or several tools, from the bundled catalog. +pub fn resolve_requirements( tools: impl Into, ctx: &ResolveContext, -) -> Result> { - bundled()?.resolve_sandbox_policy(tools, ctx) +) -> Result> { + bundled()?.resolve_requirements(tools, ctx) } -/// Composed candidate policy plus attribution, from the bundled catalog. -pub fn resolve_sandbox_policy_with_diagnostics( +/// Composed requirements plus attribution, from the bundled catalog. +pub fn resolve_requirements_with_diagnostics( tools: impl Into, ctx: &ResolveContext, -) -> Result { - bundled()?.resolve_sandbox_policy_with_diagnostics(tools, ctx) +) -> Result { + bundled()?.resolve_requirements_with_diagnostics(tools, ctx) } /// Bundled catalog entry metadata. @@ -799,7 +1031,7 @@ pub fn list_catalog_entries() -> Result> { bundled()?.list_catalog_entries() } -/// Bundled catalog schema version and revision. +/// 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..074b9947e --- /dev/null +++ b/src/mxc-sdk/src/policy_store/sdk.rs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The public v1 surface (design §5): 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, ToolInputs, +}; +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. +pub fn resolve_tool_requirements( + tools: impl Into, + ctx: &ResolveContext, +) -> Result, Error> { + typed(resolver::resolve_requirements(tools, ctx).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: impl Into, + ctx: &ResolveContext, +) -> Result { + let resolution = + resolver::resolve_requirements_with_diagnostics(tools, ctx).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) +} diff --git a/src/mxc-sdk/src/policy_store/view.rs b/src/mxc-sdk/src/policy_store/view.rs index 4d6465354..66a0050fb 100644 --- a/src/mxc-sdk/src/policy_store/view.rs +++ b/src/mxc-sdk/src/policy_store/view.rs @@ -8,8 +8,9 @@ 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::json::{cmp_utf16, Json}; -use crate::policy_store::model::{Architecture, FilesystemPolicy, Platform}; +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() { @@ -23,8 +24,11 @@ fn cell(values: &[String]) -> String { } } -fn paths(m: &Materialized, field: fn(&FilesystemPolicy) -> &Option>) -> Vec { - m.policy +fn paths( + m: &Materialized, + field: fn(&FilesystemRequirements) -> &Option>, +) -> Vec { + m.requirements .filesystem .as_ref() .and_then(|fs| field(fs).clone()) @@ -81,7 +85,7 @@ fn describe_rule(rule: &Json) -> String { } fn egress(m: &Materialized) -> Vec { - let Some(network) = &m.policy.network else { + let Some(network) = &m.requirements.network else { return Vec::new(); }; let allow: Vec = network @@ -99,13 +103,13 @@ fn egress(m: &Materialized) -> Vec { } } -type PathField = fn(&FilesystemPolicy) -> &Option>; +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 { - policy: m.default_policy.clone(), + requirements: m.default_requirements.clone(), dependency_entry_ids: m.default_dependency_entry_ids.clone(), ..m.clone() }; @@ -161,7 +165,7 @@ pub fn render_reviewer_view( 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: 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", + "\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 { @@ -232,3 +236,60 @@ pub fn render_reviewer_view( } 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 index 7470f69d7..8f139649f 100644 --- a/src/mxc-sdk/tests/policy_store_catalog_validation.rs +++ b/src/mxc-sdk/tests/policy_store_catalog_validation.rs @@ -11,7 +11,8 @@ //! - 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 rendered reviewer view in `catalog/views/` is current; +//! - 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. //! @@ -23,7 +24,8 @@ mod common; use common::{crate_dir, read_json}; use mxc_sdk::__policy_store::tooling::{ - render_reviewer_view, validate_bundled_catalog, IdentityPredicate, Json, + canonical_json, render_exact_requests, render_reviewer_view, validate_bundled_catalog, + IdentityPredicate, Json, }; use mxc_sdk::__policy_store::{ bundled_catalog_store, Architecture, FixedHost, Platform, PolicyCatalog, ResolveContext, @@ -117,14 +119,14 @@ fn every_entry_resolves_deterministically_through_its_own_identity() { let label = format!("{revision_id} {} {platform}/{architecture}", entry.entry_id); let first = - catalog.resolve_sandbox_policy_with_diagnostics(candidate.clone(), &ctx); + catalog.resolve_requirements_with_diagnostics(candidate.clone(), &ctx); let second = - catalog.resolve_sandbox_policy_with_diagnostics(candidate.clone(), &ctx); + catalog.resolve_requirements_with_diagnostics(candidate.clone(), &ctx); match (first, second) { (Ok(first), Ok(second)) => { - if first.policy.is_none() { + if first.requirements.is_none() { failures.push(format!( - "{label} produced no policy: {:?}", + "{label} produced no requirements: {:?}", first.diagnostics.warnings )); } else if first.to_json() != second.to_json() { @@ -206,8 +208,8 @@ fn every_entry_has_a_bundled_conformance_case() { ); } -/// The reviewer view is generated; set `MXC_POLICY_STORE_UPDATE_VIEWS=1` to -/// rewrite it after a catalog change. +/// 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(); @@ -219,11 +221,21 @@ fn reviewer_views_are_current() { .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!( diff --git a/src/mxc-sdk/tests/policy_store_common/mod.rs b/src/mxc-sdk/tests/policy_store_common/mod.rs index e0515bed2..e3484538b 100644 --- a/src/mxc-sdk/tests/policy_store_common/mod.rs +++ b/src/mxc-sdk/tests/policy_store_common/mod.rs @@ -63,7 +63,10 @@ pub fn catalog_for(revision: Json, host: Arc) -> PolicyCatalog { } pub fn bundled_catalog(host: Arc) -> PolicyCatalog { - PolicyCatalog::with_host(mxc_sdk::__policy_store::bundled_catalog_store().unwrap(), host) + PolicyCatalog::with_host( + mxc_sdk::__policy_store::bundled_catalog_store().unwrap(), + host, + ) } pub fn j(text: &str) -> Json { @@ -74,6 +77,7 @@ 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) } @@ -89,7 +93,7 @@ pub fn entry(entry_id: &str, overrides: &str) -> Json { let mut base = j(&format!( r#"{{"entryId":"{entry_id}","entryRevision":1,"displayName":"{name}","versionScheme":"semver", "identity":[{{"kind":"invocation-name","names":["{name}"]}}], - "default":{{"sandboxPolicy":{{"version":"0.9.0-alpha","filesystem":{{"readwritePaths":["${{project_root}}"]}}}}}}, + "default":{{"requirements":{{"filesystem":{{"readwritePaths":["${{project_root}}"]}}}}}}, "provenance":{{"method":"test","sourceRevision":"test"}}}}"# )); if !overrides.is_empty() { diff --git a/src/mxc-sdk/tests/policy_store_conformance.rs b/src/mxc-sdk/tests/policy_store_conformance.rs index 7ae6ffe6f..cc27f6ab5 100644 --- a/src/mxc-sdk/tests/policy_store_conformance.rs +++ b/src/mxc-sdk/tests/policy_store_conformance.rs @@ -15,7 +15,55 @@ mod common; use common::*; use mxc_sdk::__policy_store::tooling::{Json, JsonObject}; -use mxc_sdk::__policy_store::{Architecture, Platform, PolicyCatalogError}; +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| { @@ -48,9 +96,12 @@ fn all_conformance_fixtures() { "{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 @@ -58,7 +109,7 @@ fn all_conformance_fixtures() { .and_then(|h| h.get("nativeArchitecture")) .and_then(Json::as_str) .map_or(Architecture::X64, |a| Architecture::parse(a).unwrap()); - let host = fixed_host(host_platform, host_arch); + 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), @@ -70,7 +121,7 @@ fn all_conformance_fixtures() { if update_dir.is_some() { let mut outcome = JsonObject::new(); outcome.insert("name", case.get("name").unwrap().clone()); - match catalog.resolve_sandbox_policy_with_diagnostics(tools, &ctx) { + match catalog.resolve_requirements_with_diagnostics(tools, &ctx) { Ok(result) => outcome.insert("expect", result.to_json()), Err(error) => { let mut e = JsonObject::new(); @@ -96,12 +147,12 @@ fn all_conformance_fixtures() { .to_string(), )); assert_eq!( - failure(catalog.resolve_sandbox_policy_with_diagnostics(tools.clone(), &ctx)), + failure(catalog.resolve_requirements_with_diagnostics(tools.clone(), &ctx)), expected, "{case_name}" ); assert_eq!( - failure(catalog.resolve_sandbox_policy(tools, &ctx)), + failure(catalog.resolve_requirements(tools, &ctx)), expected, "{case_name}" ); @@ -110,15 +161,16 @@ fn all_conformance_fixtures() { } let expect = case.get("expect").unwrap(); let mut expected = JsonObject::new(); - let expected_policy = expect.get("policy").filter(|p| !p.is_null()).cloned(); - if let Some(policy) = &expected_policy { - expected.insert("policy", policy.clone()); + 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_sandbox_policy_with_diagnostics(tools.clone(), &ctx) + .resolve_requirements_with_diagnostics(tools.clone(), &ctx) .unwrap(); assert_eq!( actual.to_json(), @@ -126,12 +178,16 @@ fn all_conformance_fixtures() { "{case_name}\nactual: {}", actual.to_json() ); - let policy = catalog.resolve_sandbox_policy(tools.clone(), &ctx).unwrap(); - assert_eq!(policy.map(|p| p.to_json()), expected_policy, "{case_name}"); + 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_sandbox_policy_with_diagnostics(as_array, &ctx) + .resolve_requirements_with_diagnostics(as_array, &ctx) .unwrap(); assert_eq!( again, actual, diff --git a/src/mxc-sdk/tests/policy_store_library.rs b/src/mxc-sdk/tests/policy_store_library.rs index c386c87a3..5361cb833 100644 --- a/src/mxc-sdk/tests/policy_store_library.rs +++ b/src/mxc-sdk/tests/policy_store_library.rs @@ -14,17 +14,16 @@ use mxc_sdk::__policy_store::tooling::{ validate_catalog_revision, validate_contract, Json, PublishedRevision, PublishedState, }; use mxc_sdk::__policy_store::{ - get_catalog_info, list_catalog_entries, resolve_sandbox_policy, - resolve_sandbox_policy_with_diagnostics, Architecture, ErrorReason, FixedHost, HostEnvironment, - IntentMode, Platform, PolicyCatalog, PolicyCatalogError, ResolveContext, - SandboxConfigResolution, ToolCandidate, ToolInput, ToolInputs, ToolResolutionStatus, + 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; -const V: &str = "0.9.0-alpha"; - fn weak() -> ResolveContext { ResolveContext::new().allow_weak(true) } @@ -44,12 +43,12 @@ fn validate_err(entries: Vec, needle: &str) { ); } -/// `{"default":{"sandboxPolicy":}}` as entry overrides. +/// `{"default":{"requirements":}}` as entry overrides. fn with_default(policy: &str, extra: &str) -> String { - format!(r#"{{"default":{{"sandboxPolicy":{policy}}}{extra}}}"#) + format!(r#"{{"default":{{"requirements":{policy}}}{extra}}}"#) } -fn fs_json(policy: &Option) -> Json { +fn fs_json(policy: &Option) -> Json { policy .as_ref() .expect("a policy") @@ -67,9 +66,9 @@ fn messages(warnings: &[Warning]) -> String { .join("\n") } -fn egress(result: &SandboxConfigResolution) -> Option { +fn egress(result: &RequirementsResolution) -> Option { result - .policy + .requirements .as_ref() .and_then(|p| p.network.clone()) .and_then(|n| n.get("egress").and_then(|e| e.get("allow")).cloned()) @@ -102,15 +101,15 @@ fn omitted_architecture_uses_the_native_system_architecture() { let ctx = ResolveContext::new() .project_root("C:\\p") .symbol("git_prefix", "C:\\g"); - assert_eq!(catalog.resolve_sandbox_policy("a", &ctx).unwrap(), None); + assert_eq!(catalog.resolve_requirements("a", &ctx).unwrap(), None); let result = catalog - .resolve_sandbox_policy_with_diagnostics( + .resolve_requirements_with_diagnostics( ToolCandidate::new("a").with_package_url("pkg:npm/a"), &ctx, ) .unwrap(); assert_eq!( - fs_json(&result.policy), + fs_json(&result.requirements), j(r#"{"readonlyPaths":["C:\\g\\arm64"],"readwritePaths":["C:\\p"]}"#) ); assert_eq!(result.diagnostics.catalog_revision, "2000-01-01.1"); @@ -122,7 +121,7 @@ fn omitted_architecture_uses_the_native_system_architecture() { fn explicit_architecture_wins_and_suppresses_the_host_default_warning() { let catalog = arch_catalog(fixed_host(Platform::Windows, Architecture::Arm64)); let result = catalog - .resolve_sandbox_policy_with_diagnostics( + .resolve_requirements_with_diagnostics( ToolCandidate::new("a").with_package_url("pkg:npm/a"), &ResolveContext::new() .architecture(Architecture::X64) @@ -131,7 +130,7 @@ fn explicit_architecture_wins_and_suppresses_the_host_default_warning() { ) .unwrap(); assert_eq!( - fs_json(&result.policy), + fs_json(&result.requirements), j(r#"{"readonlyPaths":["C:\\g\\x64"],"readwritePaths":["C:\\p"]}"#) ); assert!(result.diagnostics.warnings.is_empty()); @@ -159,19 +158,45 @@ fn another_architecture_is_never_used_and_neutral_additions_are_reported() { .project_root("/p") .symbol("node_prefix", "/n"); let a = catalog - .resolve_sandbox_policy_with_diagnostics("a", &ctx) + .resolve_requirements_with_diagnostics("a", &ctx) .unwrap(); - assert_eq!(fs_json(&a.policy), j(r#"{"readwritePaths":["/p"]}"#)); - assert!(!messages(&a.diagnostics.warnings).contains("architecture")); + 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) => Some(d.kind.clone()), + Warning::Tool(_) => None, + }) + .collect() + }; + assert_eq!( + fallback(&a), + [DetailWarningKind::ArchitectureFallback { + platform: Platform::Linux, + architecture: Architecture::X64, + selected: ArchitectureFallback::Default + }] + ); let b = catalog - .resolve_sandbox_policy_with_diagnostics("b", &ctx) + .resolve_requirements_with_diagnostics("b", &ctx) .unwrap(); assert_eq!( - fs_json(&b.policy), + 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 uses its architecture-neutral linux additions; no x64-specific overlay exists" + "tool:b has no x64-specific linux overlay; its architecture-neutral platform data was used" )); } @@ -189,6 +214,12 @@ impl HostEnvironment for FailingHost { fn symbol(&self, _: &str) -> Option { None } + fn discover(&self, _: &str) -> Result, PolicyCatalogError> { + Ok(None) + } + fn compare_objects(&self, _: &str, _: &str) -> ExistingObjectComparison { + ExistingObjectComparison::Different + } } #[test] @@ -205,21 +236,24 @@ fn host_architecture_is_detected_only_when_an_overlay_needs_it() { ); let catalog = PolicyCatalog::with_host(store, Arc::new(FailingHost)); let error = catalog - .resolve_sandbox_policy("u", &weak().project_root("/p")) + .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_sandbox_policy_with_diagnostics("t", &weak().project_root("/p")) + .resolve_requirements_with_diagnostics("t", &weak().project_root("/p")) .unwrap(); - assert_eq!(fs_json(&result.policy), j(r#"{"readwritePaths":["/p"]}"#)); + assert_eq!( + fs_json(&result.requirements), + j(r#"{"readwritePaths":["/p"]}"#) + ); assert!(!messages(&result.diagnostics.warnings).contains("architecture")); assert_eq!( - catalog.resolve_sandbox_policy("nothing", &weak()).unwrap(), + catalog.resolve_requirements("nothing", &weak()).unwrap(), None ); assert!(catalog - .resolve_sandbox_policy( + .resolve_requirements( "u", &weak().architecture(Architecture::X64).project_root("/p") ) @@ -234,9 +268,9 @@ fn host_architecture_is_detected_only_when_an_overlay_needs_it() { #[test] fn never_fabricates_project_root_or_caller_symbols() { let result = bundled_catalog(linux_x64()) - .resolve_sandbox_policy_with_diagnostics("git", &weak().architecture(Architecture::X64)) + .resolve_requirements_with_diagnostics("git", &weak().architecture(Architecture::X64)) .unwrap(); - assert_eq!(result.policy, None); + assert_eq!(result.requirements, None); let warnings = messages(&result.diagnostics.warnings); let a = warnings.find("required symbol 'git_prefix'").unwrap(); let b = warnings.find("required symbol 'project_root'").unwrap(); @@ -248,9 +282,7 @@ fn host_symbols_only_for_the_current_host_platform_and_caller_overrides_win() { let rev = revision(vec![entry( "tool:t", &with_default( - &format!( - r#"{{"version":"{V}","filesystem":{{"readonlyPaths":["${{user_home}}/.cfg"]}}}}"# - ), + r#"{"filesystem":{"readonlyPaths":["${user_home}/.cfg"]}}"#, "", ), )]); @@ -259,17 +291,30 @@ fn host_symbols_only_for_the_current_host_platform_and_caller_overrides_win() { ); let catalog = catalog_for(rev, host); let derived = catalog - .resolve_sandbox_policy_with_diagnostics("t", &weak()) + .resolve_requirements_with_diagnostics("t", &weak()) .unwrap(); assert_eq!( - fs_json(&derived.policy), + fs_json(&derived.requirements), j(r#"{"readonlyPaths":["/home/me/.cfg"]}"#) ); assert!(messages(&derived.diagnostics.warnings) - .contains("symbol 'user_home' resolved from the host environment to '/home/me'")); + .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_sandbox_policy( + .resolve_requirements( "t", &weak() .platform(Platform::Macos) @@ -279,10 +324,10 @@ fn host_symbols_only_for_the_current_host_platform_and_caller_overrides_win() { None ); let overridden = catalog - .resolve_sandbox_policy_with_diagnostics("t", &weak().symbol("user_home", "/srv/u")) + .resolve_requirements_with_diagnostics("t", &weak().symbol("user_home", "/srv/u")) .unwrap(); assert_eq!( - fs_json(&overridden.policy), + fs_json(&overridden.requirements), j(r#"{"readonlyPaths":["/srv/u/.cfg"]}"#) ); assert!(!messages(&overridden.diagnostics.warnings).contains("host environment")); @@ -296,8 +341,9 @@ fn windows_overlay_adds_program_data_git() { .project_root("C:\\p") .symbol("git_prefix", "C:\\Program Files\\Git") .symbol("programData", "C:\\ProgramData"); - let policy = bundled_catalog(linux_x64()) - .resolve_sandbox_policy( + // 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"), @@ -328,17 +374,17 @@ fn string_object_and_one_element_array_are_equivalent() { let catalog = bundled_catalog(linux_x64()); let ctx = git_ctx(); let a = catalog - .resolve_sandbox_policy_with_diagnostics("git", &ctx) + .resolve_requirements_with_diagnostics("git", &ctx) .unwrap(); assert_eq!( catalog - .resolve_sandbox_policy_with_diagnostics(ToolCandidate::new("git"), &ctx) + .resolve_requirements_with_diagnostics(ToolCandidate::new("git"), &ctx) .unwrap(), a ); assert_eq!( catalog - .resolve_sandbox_policy_with_diagnostics(vec!["git"], &ctx) + .resolve_requirements_with_diagnostics(vec!["git"], &ctx) .unwrap(), a ); @@ -347,13 +393,10 @@ fn string_object_and_one_element_array_are_equivalent() { allow_weak_identity_fallback: false, ..ctx }; - assert_eq!( - catalog.resolve_sandbox_policy("git", &strict).unwrap(), - None - ); + assert_eq!(catalog.resolve_requirements("git", &strict).unwrap(), None); assert_eq!( catalog - .resolve_sandbox_policy(ToolCandidate::new("git").with_intent("fetch"), &strict) + .resolve_requirements(ToolCandidate::new("git").with_intent("fetch"), &strict) .unwrap(), None ); @@ -365,10 +408,6 @@ fn invalid_context_and_inputs_are_failures_not_absence() { let cases: Vec<(ToolInputs, ResolveContext)> = vec![ ("".into(), ResolveContext::new()), ("/usr/bin/git".into(), ResolveContext::new()), - ( - ToolCandidate::new("npm").with_package_url("npm").into(), - ResolveContext::new(), - ), ( ToolCandidate::new("npm").with_detected_version("").into(), ResolveContext::new(), @@ -408,19 +447,34 @@ fn invalid_context_and_inputs_are_failures_not_absence() { ]; for (tools, ctx) in cases { assert_eq!( - reason_of(catalog.resolve_sandbox_policy(tools.clone(), &ctx)), + 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_sandbox_policy("node", &ctx).unwrap_err(); - assert_eq!( - error.message(), - "[malformed_request] symbol 'node_prefix' must resolve to an absolute linux path" + 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() ); } } @@ -436,10 +490,10 @@ fn results_are_caller_owned_and_deterministic() { .symbol("node_prefix", "/node"); let tool = ToolCandidate::new("npm").with_package_url("pkg:npm/npm"); let mut first = catalog - .resolve_sandbox_policy_with_diagnostics(tool.clone(), &ctx) + .resolve_requirements_with_diagnostics(tool.clone(), &ctx) .unwrap(); first - .policy + .requirements .as_mut() .unwrap() .filesystem @@ -449,18 +503,25 @@ fn results_are_caller_owned_and_deterministic() { .as_mut() .unwrap() .push("/mutated".into()); - first - .diagnostics - .warnings - .push("mutated".to_string().into()); + first.diagnostics.warnings.clear(); let second = catalog - .resolve_sandbox_policy_with_diagnostics(tool, &ctx) + .resolve_requirements_with_diagnostics(tool, &ctx) .unwrap(); assert_eq!( - fs_json(&second.policy), + fs_json(&second.requirements), j(r#"{"readonlyPaths":["/n","/node"],"readwritePaths":["/p","/c"]}"#) ); - assert!(!messages(&second.diagnostics.warnings).contains("mutated")); + assert_eq!( + second.diagnostics.warnings, + catalog + .resolve_requirements_with_diagnostics( + ToolCandidate::new("npm").with_package_url("pkg:npm/npm"), + &ctx + ) + .unwrap() + .diagnostics + .warnings + ); } #[test] @@ -468,7 +529,7 @@ 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_sandbox_policy_with_diagnostics( + .resolve_requirements_with_diagnostics( name, &weak() .platform(platform) @@ -510,11 +571,11 @@ fn package_identity_then_intent_then_architecture_and_ties_are_ambiguous() { named("tool:a", ""), named( "tool:b", - r#","default":{"sandboxPolicy":{"version":"0.9.0-alpha"},"intents":{"build":{}}}"#, + r#","default":{"requirements":{},"intents":{"build":{}}}"#, ), entry( "tool:c", - r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"},{"kind":"invocation-name","names":["x"]}],"default":{"sandboxPolicy":{"version":"0.9.0-alpha"}}}"#, + r#"{"identity":[{"kind":"purl","value":"pkg:npm/x"},{"kind":"invocation-name","names":["x"]}],"default":{"requirements":{}}}"#, ), ]; let mut reversed = entries.clone(); @@ -524,7 +585,7 @@ fn package_identity_then_intent_then_architecture_and_ties_are_ambiguous() { let ctx = weak().project_root("/p"); let chosen = |tool: ToolCandidate| { catalog - .resolve_sandbox_policy_with_diagnostics(tool, &ctx) + .resolve_requirements_with_diagnostics(tool, &ctx) .unwrap() .diagnostics .tools[0] @@ -540,7 +601,7 @@ fn package_identity_then_intent_then_architecture_and_ties_are_ambiguous() { chosen(ToolCandidate::new("x").with_intent("build")), "tool:b" ); - let error = catalog.resolve_sandbox_policy("x", &ctx).unwrap_err(); + let error = catalog.resolve_requirements("x", &ctx).unwrap_err(); assert_eq!(error.reason(), ErrorReason::AmbiguousMatch); assert_eq!( error.message(), @@ -565,7 +626,7 @@ fn exact_architecture_outranks_neutral_and_default() { linux_x64(), ); let result = catalog - .resolve_sandbox_policy_with_diagnostics("x", &weak().project_root("/p")) + .resolve_requirements_with_diagnostics("x", &weak().project_root("/p")) .unwrap(); assert_eq!(result.diagnostics.tools[0].matches[0].entry_id, "tool:b"); } @@ -573,7 +634,7 @@ fn exact_architecture_outranks_neutral_and_default() { #[test] fn package_url_versions_are_ignored_with_a_warning() { let result = bundled_catalog(linux_x64()) - .resolve_sandbox_policy_with_diagnostics( + .resolve_requirements_with_diagnostics( ToolCandidate::new("git").with_package_url("pkg:generic/git@2.45.0"), &git_ctx(), ) @@ -583,9 +644,16 @@ fn package_url_versions_are_ignored_with_a_warning() { tool.status, ToolResolutionStatus::Version(VersionStatus::MatchedDefault) ); - assert!(messages(&result.diagnostics.warnings).contains( - "input 0 ('git'): the version '2.45.0' embedded in packageUrl is not version evidence and was ignored; supply detectedVersion" - )); + 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] + } + ); } // --------------------------------------------------------------------------- @@ -599,9 +667,9 @@ fn git(version: Option<&str>, intent: Option<&str>) -> ToolCandidate { tool } -fn resolve_git(tools: impl Into) -> SandboxConfigResolution { +fn resolve_git(tools: impl Into) -> RequirementsResolution { bundled_catalog(linux_x64()) - .resolve_sandbox_policy_with_diagnostics( + .resolve_requirements_with_diagnostics( tools, &git_ctx() .symbol("ssh_prefix", "/ssh") @@ -636,7 +704,7 @@ fn version_selection_statuses() { "tool:ssh" ); assert_eq!( - fs_json(&inside.policy), + fs_json(&inside.requirements), j(r#"{"readonlyPaths":["/g","/ssh"],"readwritePaths":["/p"]}"#) ); assert_eq!( @@ -649,17 +717,21 @@ fn version_selection_statuses() { outside.diagnostics.tools[0].status, ToolResolutionStatus::Version(VersionStatus::VersionOutOfRange) ); - assert_eq!(outside.policy, none.policy); + assert_eq!(outside.requirements, none.requirements); let Warning::Tool(warning) = &outside.diagnostics.warnings[0] else { panic!("structured warning expected"); }; - assert_eq!(warning.code.as_str(), "version_out_of_range"); - assert_eq!(warning.entry_id.as_deref(), Some("tool:git")); - assert_eq!(warning.detected_version.as_deref(), Some("2.30.0")); - assert_eq!(warning.intent.as_deref(), Some("push")); + 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.policy, None); + assert_eq!(bad.requirements, None); assert_eq!( bad.diagnostics.tools[0].status, ToolResolutionStatus::Version(VersionStatus::VersionUnparseable) @@ -680,7 +752,7 @@ fn intent_selection_and_unsupported_intents() { ["bundle-fetch", "fetch", "local", "push"] ); assert_eq!( - fs_json(&all.policy), + fs_json(&all.requirements), j(r#"{"readonlyPaths":["/g"],"readwritePaths":["/p","/tmp/git-bundles"]}"#) ); assert_eq!( @@ -694,7 +766,7 @@ fn intent_selection_and_unsupported_intents() { ); let local = resolve_git(git(None, Some("local"))); - assert_eq!(local.policy.as_ref().unwrap().network, None); + 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]; @@ -707,7 +779,7 @@ fn intent_selection_and_unsupported_intents() { tool.matches[0].intent_selection.as_ref().unwrap().mode, IntentMode::Unsupported ); - assert_eq!(unsupported.policy, None); + assert_eq!(unsupported.requirements, None); let both = resolve_git(git(Some("2.30"), Some("bundle-fetch"))); let tool = &both.diagnostics.tools[0]; @@ -721,8 +793,8 @@ fn intent_selection_and_unsupported_intents() { .warnings .iter() .filter_map(|w| match w { - Warning::Tool(t) => Some(t.code.as_str()), - Warning::Text(_) => None, + Warning::Tool(t) => Some(t.kind.code()), + Warning::Detail(_) => None, }) .collect(); assert_eq!(codes, ["version_out_of_range", "intent_unsupported"]); @@ -750,7 +822,7 @@ fn pairs_compose_and_a_tool_without_network_does_not_veto_another() { ToolResolutionStatus::ToolUnmatched ); assert_eq!( - fs_json(&result.policy), + fs_json(&result.requirements), j(r#"{"readonlyPaths":["/g"],"readwritePaths":["/p"]}"#) ); } @@ -762,16 +834,13 @@ fn pairs_compose_and_a_tool_without_network_does_not_veto_another() { fn paths_entry(id: &str, policy_fs: &str) -> Json { entry( id, - &with_default( - &format!(r#"{{"version":"{V}","filesystem":{policy_fs}}}"#), - "", - ), + &with_default(&format!(r#"{{"filesystem":{policy_fs}}}"#), ""), ) } -fn compose(entries: Vec, names: Vec<&str>) -> SandboxConfigResolution { +fn compose(entries: Vec, names: Vec<&str>) -> RequirementsResolution { catalog_for(revision(entries), linux_x64()) - .resolve_sandbox_policy_with_diagnostics( + .resolve_requirements_with_diagnostics( names, &weak() .project_root("/work") @@ -790,7 +859,10 @@ fn filesystem_floor_composition_rules() { ], vec!["a", "b"], ); - assert_eq!(fs_json(&ro_rw.policy), j(r#"{"readwritePaths":["/work"]}"#)); + 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" )); @@ -803,7 +875,7 @@ fn filesystem_floor_composition_rules() { vec!["a"], ); assert_eq!( - fs_json(&nested_ro.policy), + fs_json(&nested_ro.requirements), j(r#"{"readwritePaths":["/work"]}"#) ); @@ -815,7 +887,7 @@ fn filesystem_floor_composition_rules() { vec!["a"], ); assert_eq!( - fs_json(&nested_rw.policy), + fs_json(&nested_rw.requirements), j(r#"{"readonlyPaths":["/work"],"readwritePaths":["/work/cache"]}"#) ); @@ -833,7 +905,7 @@ fn filesystem_floor_composition_rules() { vec!["a", "b"], ); assert_eq!( - fs_json(&denies.policy), + fs_json(&denies.requirements), j(r#"{"deniedPaths":["/secrets"],"readwritePaths":["/work","/data/cache"]}"#) ); assert!(messages(&denies.diagnostics.warnings).contains( @@ -857,17 +929,17 @@ fn unknown_case_sensitivity_keeps_case_variants_with_a_diagnostic() { .symbol("git_prefix", "C:\\Tools") .symbol("node_prefix", "c:\\tools"); let result = catalog - .resolve_sandbox_policy_with_diagnostics("w", &ctx) + .resolve_requirements_with_diagnostics("w", &ctx) .unwrap(); assert_eq!( - fs_json(&result.policy), + 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_sandbox_policy( + .resolve_requirements( "w", &ctx.symbol("git_prefix", "C:\\tools") .symbol("node_prefix", "C:/tools/"), @@ -885,7 +957,7 @@ fn dependency_chain_composes_each_entry_once_in_order() { .collect::>() .join(","); format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{project_root}}/{path}"]}}}},"dependencies":[{deps}]}}}}"# + r#"{{"default":{{"requirements":{{"filesystem":{{"readonlyPaths":["${{project_root}}/{path}"]}}}},"dependencies":[{deps}]}}}}"# ) }; let catalog = catalog_for( @@ -898,7 +970,7 @@ fn dependency_chain_composes_each_entry_once_in_order() { linux_x64(), ); let result = catalog - .resolve_sandbox_policy_with_diagnostics("top", &weak().project_root("/r")) + .resolve_requirements_with_diagnostics("top", &weak().project_root("/r")) .unwrap(); let deps: Vec<&str> = result .diagnostics @@ -908,34 +980,28 @@ fn dependency_chain_composes_each_entry_once_in_order() { .collect(); assert_eq!(deps, ["tool:leaf", "tool:left", "tool:right"]); assert_eq!( - fs_json(&result.policy), + 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 = format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"tool:c","versionRange":"vers:semver/>=2.0.0"}}]}}}}"# - ); - let b = format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"tool:c"}}]}}}}"# - ); + 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", - &format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"intents":{{"run":{{}}}}}}}}"# - ), + r#"{"default":{"requirements":{},"intents":{"run":{}}}}"#, ), ]), linux_x64(), ); let result = catalog - .resolve_sandbox_policy_with_diagnostics(vec!["a", "b", "a"], &weak()) + .resolve_requirements_with_diagnostics(vec!["a", "b", "a"], &weak()) .unwrap(); assert_eq!( result @@ -945,30 +1011,21 @@ fn dependency_diagnostics_keep_distinct_ranges_sorted() { .get("resolvedDependencies") .unwrap(), &j(r#"[ - {"entryId":"tool:c","entryRevision":1,"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"none","selected":[]}}, - {"entryId":"tool:c","entryRevision":1,"requiredVersionRange":"vers:semver/>=2.0.0","versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"none","selected":[]}}]"#) - ); - assert_eq!( - result.policy.unwrap().to_json(), - j(&format!(r#"{{"version":"{V}"}}"#)) + {"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:a", &with_default(r#"{"version":"0.8.0-alpha"}"#, "")), - entry( - "tool:b", - &with_default(&format!(r#"{{"version":"{V}"}}"#), ""), - ), + entry("tool:b", &with_default(r#"{}"#, "")), entry( "tool:d", &with_default( - &format!( - r#"{{"version":"{V}","network":{{"egress":{{"deny":[{{"ports":[{{"port":22}}]}}]}}}}}}"# - ), + r#"{"network":{"egress":{"deny":[{"ports":[{"port":22}]}]}}}"#, "", ), ), @@ -976,37 +1033,24 @@ fn unsupported_combinations_fail_rather_than_broaden() { "tool:n", &with_default( &format!( - r#"{{"version":"{V}","network":{{"egress":{{"default":"deny","allow":[{}]}}}}}}"#, + r#"{{"network":{{"egress":{{"default":"deny","allow":[{}]}}}}}}"#, tcp("192.0.2.1/32", 443) ), "", ), ), - entry( - "tool:u", - &with_default(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#), ""), - ), + entry("tool:u", &with_default(r#"{"timeoutMs":5}"#, "")), entry( "tool:i", - &with_default( - &format!(r#"{{"version":"{V}","network":{{"ingress":{{"default":"deny"}}}}}}"#), - "", - ), + &with_default(r#"{"network":{"ingress":{"default":"deny"}}}"#, ""), ), ]), linux_x64(), ); - let error = catalog - .resolve_sandbox_policy(vec!["a", "b"], &weak()) - .unwrap_err(); - assert_eq!( - error.message(), - "[policy_validation] selected entries cannot be composed: mixed sandboxPolicy.version values (0.8.0-alpha, 0.9.0-alpha)" - ); // A deny that does not overlap another pair's required allow stays. assert_eq!( catalog - .resolve_sandbox_policy(vec!["d", "n"], &weak()) + .resolve_requirements(vec!["d", "n"], &weak()) .unwrap() .unwrap() .network, @@ -1017,26 +1061,26 @@ fn unsupported_combinations_fail_rather_than_broaden() { ); // Non-egress network keys cannot be combined across network requirements. assert_eq!( - reason_of(catalog.resolve_sandbox_policy(vec!["i", "n"], &weak())), + reason_of(catalog.resolve_requirements(vec!["i", "n"], &weak())), ErrorReason::CompositionConflict ); assert_eq!( - reason_of(catalog.resolve_sandbox_policy(vec!["u", "b"], &weak())), + reason_of(catalog.resolve_requirements(vec!["u", "b"], &weak())), ErrorReason::CompositionConflict ); // A single selected policy without additions passes through whole. assert_eq!( catalog - .resolve_sandbox_policy("u", &weak()) + .resolve_requirements("u", &weak()) .unwrap() .unwrap() .to_json(), - j(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#)) + j(r#"{"timeoutMs":5}"#) ); // Only one component needs network: its network passes through. assert_eq!( catalog - .resolve_sandbox_policy(vec!["d", "b"], &weak()) + .resolve_requirements(vec!["d", "b"], &weak()) .unwrap() .unwrap() .network, @@ -1053,7 +1097,9 @@ fn inspection_over_the_bundled_catalog() { let info = get_catalog_info().unwrap(); assert_eq!( info.to_json(), - j(r#"{"catalogSchemaVersion":"1","catalogRevision":"2026-10-05.1"}"#) + 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(); @@ -1071,7 +1117,7 @@ fn inspection_over_the_bundled_catalog() { {"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("sandboxPolicy\"")); + assert!(!text.contains("Paths") && !text.contains("${") && !text.contains("requirements\"")); } #[test] @@ -1080,21 +1126,21 @@ fn module_level_functions_use_the_bundled_catalog() { .platform(Platform::Linux) .architecture(Architecture::X64); assert_eq!( - resolve_sandbox_policy("definitely-unknown-tool", &ctx).unwrap(), + resolve_requirements("definitely-unknown-tool", &ctx).unwrap(), None ); - let result = resolve_sandbox_policy_with_diagnostics( + let result = resolve_requirements_with_diagnostics( vec![ToolInput::from("definitely-unknown-tool")], &ctx, ) .unwrap(); - assert_eq!(result.policy, None); + assert_eq!(result.requirements, None); assert_eq!( result.diagnostics.to_json().get("tools").unwrap(), &j(r#"[{"inputIndex":0,"status":"tool_unmatched","matches":[]}]"#) ); assert_eq!( - reason_of(resolve_sandbox_policy( + reason_of(resolve_requirements( "git", &ctx.catalog_revision("1999-01-01.1") )), @@ -1107,9 +1153,7 @@ fn module_level_functions_use_the_bundled_catalog() { // --------------------------------------------------------------------------- fn dep(id: &str) -> String { - format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"{id}"}}]}}}}"# - ) + format!(r#"{{"default":{{"requirements":{{}},"dependencies":[{{"entryId":"{id}"}}]}}}}"#) } #[test] @@ -1132,7 +1176,7 @@ fn validation_accepts_minimal_and_rejects_cycles_and_missing_dependencies() { ); let ranged = |range: &str| { format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"tool:b","versionRange":"{range}"}}]}}}}"# + r#"{{"default":{{"requirements":{{}},"dependencies":[{{"entryId":"tool:b","versionRange":"{range}"}}]}}}}"# ) }; validate_err( @@ -1170,7 +1214,7 @@ fn validation_requires_exactly_one_default_and_a_scheme() { validate_err( vec![entry( "tool:a", - r#"{"default":{"sandboxPolicy":{"version":"0.9.0-alpha"},"versionRange":"vers:semver/>=1"}}"#, + r#"{"default":{"requirements":{},"versionRange":"vers:semver/>=1"}}"#, )], "unsupported field 'entries[0].default.versionRange'", ); @@ -1207,42 +1251,29 @@ fn validation_selectors() { validate_err( vec![entry( "tool:a", - &format!( - r#"{{"platformVariants":[{{"when":{{"platform":"linux"}},"sandboxPolicy":{{"version":"{V}"}}}}]}}"# - ), + r#"{"platformVariants":[{"when":{"platform":"linux"},"requirements":{}}]}"#, )], - "unsupported field 'entries[0].platformVariants[0].sandboxPolicy'", + "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(&format!(r#"{{"version":"{V}","processContainer":{{}}}}"#)), - "containment backend", - ); - validate_err( - p(&format!(r#"{{"version":"{V}","containment":"lxc"}}"#)), - "containment backend", - ); - validate_err( - p(r#"{"version":"0.9.0"}"#), - "not a SandboxPolicy version registered", - ); + 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(&format!( - r#"{{"version":"{V}","filesystem":{{"clearPolicyOnExit":true}}}}"# - )), + p(r#"{"filesystem":{"clearPolicyOnExit":true}}"#), "unsupported field", ); let path = |value: &str| { p(&format!( - r#"{{"version":"{V}","filesystem":{{"readonlyPaths":[{}]}}}}"#, + r#"{{"filesystem":{{"readonlyPaths":[{}]}}}}"#, Json::from(value) )) }; @@ -1255,7 +1286,7 @@ fn validation_policy_rules() { 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#"{{"version":"{V}","network":{n}}}"#)); + 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( @@ -1274,11 +1305,11 @@ fn validation_policy_rules() { ); validate(net(r#"{"egress":{"deny":[{"ports":[{"port":22}]}]}}"#)).unwrap(); validate_err( - p(&format!(r#"{{"version":"{V}","timeoutMs":1.5}}"#)), - "timeoutMs' must be a positive integer", + p(r#"{"timeoutMs":1.5}"#), + "timeoutMs' must be a positive unsigned 32-bit integer", ); validate_err( - p(&format!(r#"{{"version":"{V}","ui":{{"clipboard":"x"}}}}"#)), + p(r#"{"ui":{"disable":false,"clipboard":"x"}}"#), "clipboard' is unsupported", ); } @@ -1312,7 +1343,7 @@ fn validation_identity_rules() { "tool:a", r#"{"identity":[{"kind":"purl","value":"pkg:npm/a@1.0.0"}]}"#, )], - "must not pin a version", + "must name only type, namespace, and name", ); validate_err( vec![entry( @@ -1350,7 +1381,7 @@ fn validation_overlays_are_additive_only() { vec![entry( "tool:a", &format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"intents":{{"run":{{}}}}}},"platformVariants":[{{"when":{{"platform":"linux"}},{body}}}]}}"# + r#"{{"default":{{"requirements":{{}},"intents":{{"run":{{}}}}}},"platformVariants":[{{"when":{{"platform":"linux"}},{body}}}]}}"# ), )] }; @@ -1439,17 +1470,7 @@ fn validation_materializes_composition_limits() { err( vec![ entry("tool:a", &dep("tool:b")), - entry("tool:b", &with_default(r#"{"version":"0.8.0-alpha"}"#, "")), - ], - "mixed sandboxPolicy.version", - ); - err( - vec![ - entry("tool:a", &dep("tool:b")), - entry( - "tool:b", - &with_default(&format!(r#"{{"version":"{V}","timeoutMs":5}}"#), ""), - ), + entry("tool:b", &with_default(r#"{"timeoutMs":5}"#, "")), ], "'tool:b' uses 'timeoutMs', which has no v1 cross-policy composition rule", ); @@ -1457,7 +1478,7 @@ fn validation_materializes_composition_limits() { validate(vec![entry( "tool:a", &format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","network":{{"egress":{{"deny":[{{"ports":[{{"port":443}}]}}]}}}}}}, + r#"{{"default":{{"requirements":{{"network":{{"egress":{{"deny":[{{"ports":[{{"port":443}}]}}]}}}}}}, "intents":{{"net":{{"policyAdditions":{{"network":{{"egress":{{"allow":[{}]}}}}}}}}}}}}}}"#, tcp("192.0.2.1/32", 443) ), @@ -1469,7 +1490,7 @@ fn validation_materializes_composition_limits() { entry( "tool:a2", &format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","network":{{"egress":{{"allow":[{}]}}}}}},"dependencies":[{{"entryId":"tool:b"}}]}}}}"#, + r#"{{"default":{{"requirements":{{"network":{{"egress":{{"allow":[{}]}}}}}},"dependencies":[{{"entryId":"tool:b"}}]}}}}"#, tcp("192.0.2.1/32", 443) ), ), @@ -1477,7 +1498,7 @@ fn validation_materializes_composition_limits() { "tool:b", &with_default( &format!( - r#"{{"version":"{V}","network":{{"ingress":{{"default":"deny"}},"egress":{{"allow":[{}]}}}}}}"#, + r#"{{"network":{{"ingress":{{"default":"deny"}},"egress":{{"allow":[{}]}}}}}}"#, tcp("192.0.2.2/32", 443) ), "", @@ -1494,7 +1515,7 @@ fn validation_materializes_composition_limits() { fn dep_intents(id: &str, intents: &str) -> String { format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}"}},"dependencies":[{{"entryId":"{id}","intents":{intents}}}]}}}}"# + r#"{{"default":{{"requirements":{{}},"dependencies":[{{"entryId":"{id}","intents":{intents}}}]}}}}"# ) } @@ -1502,7 +1523,7 @@ fn helper() -> Json { entry( "tool:h", &format!( - r#"{{"default":{{"sandboxPolicy":{{"version":"{V}","filesystem":{{"readonlyPaths":["${{git_prefix}}/h"]}}}}, + 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":{{}}}}}}]}}"#, @@ -1523,22 +1544,22 @@ fn dependency_references_contribute_base_only_unless_they_name_intents() { ); let ctx = weak().symbol("git_prefix", "/g").symbol("temp_dir", "/t"); let base_only = catalog - .resolve_sandbox_policy_with_diagnostics("a", &ctx) + .resolve_requirements_with_diagnostics("a", &ctx) .unwrap(); assert_eq!( - fs_json(&base_only.policy), + fs_json(&base_only.requirements), j(r#"{"readonlyPaths":["/g/h"]}"#) ); assert_eq!(egress(&base_only), None); let named = catalog - .resolve_sandbox_policy_with_diagnostics("b", &ctx) + .resolve_requirements_with_diagnostics("b", &ctx) .unwrap(); assert_eq!( - fs_json(&named.policy), + fs_json(&named.requirements), j(r#"{"readonlyPaths":["/g/h"],"readwritePaths":["/t/h"]}"#) ); assert_eq!( - named.policy.as_ref().unwrap().network, + named.requirements.as_ref().unwrap().network, Some(j(&format!( r#"{{"egress":{{"default":"deny","allow":[{}]}}}}"#, tcp("203.0.113.0/24", 443) @@ -1552,7 +1573,7 @@ fn dependency_references_contribute_base_only_unless_they_name_intents() { .get("resolvedDependencies") .unwrap(), &j( - r#"[{"entryId":"tool:h","entryRevision":1,"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"named","selected":["store","sync"]}}]"# + r#"[{"entryId":"tool:h","entryRevision":1,"inputIndexes":[0],"versionSelection":{"status":"matched_default"},"intentSelection":{"mode":"named","selected":["store","sync"]}}]"# ) ); } @@ -1603,7 +1624,7 @@ fn dependency_intent_references_are_validated() { fn overlapping_catalog_egress_denies_are_removed_in_full_with_diagnostics() { let deny = |rules: &str| { with_default( - &format!(r#"{{"version":"{V}","network":{{"egress":{{"deny":[{rules}]}}}}}}"#), + &format!(r#"{{"network":{{"egress":{{"deny":[{rules}]}}}}}}"#), "", ) }; @@ -1613,7 +1634,7 @@ fn overlapping_catalog_egress_denies_are_removed_in_full_with_diagnostics() { "tool:n", &with_default( &format!( - r#"{{"version":"{V}","network":{{"egress":{{"allow":[{}]}}}}}}"#, + r#"{{"network":{{"egress":{{"allow":[{}]}}}}}}"#, tcp("192.0.2.10/32", 443) ), "", @@ -1632,10 +1653,10 @@ fn overlapping_catalog_egress_denies_are_removed_in_full_with_diagnostics() { linux_x64(), ); let result = catalog - .resolve_sandbox_policy_with_diagnostics(vec!["n", "d"], &weak()) + .resolve_requirements_with_diagnostics(vec!["n", "d"], &weak()) .unwrap(); assert_eq!( - result.policy.as_ref().unwrap().network, + 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"]}}]}}, @@ -1688,11 +1709,11 @@ fn unreadable_revision_is_an_integrity_error_everywhere() { assert_eq!(reason_of(store.revision(None)), ErrorReason::Integrity); let catalog = PolicyCatalog::with_host(store, linux_x64()); assert_eq!( - reason_of(catalog.resolve_sandbox_policy("a", &weak())), + reason_of(catalog.resolve_requirements("a", &weak())), ErrorReason::Integrity ); assert_eq!( - reason_of(catalog.resolve_sandbox_policy_with_diagnostics("a", &weak())), + reason_of(catalog.resolve_requirements_with_diagnostics("a", &weak())), ErrorReason::Integrity ); assert_eq!( @@ -1756,7 +1777,7 @@ fn explicit_revisions_are_never_substituted() { let ctx = weak().project_root("/p"); let of = |ctx: &ResolveContext| { let r = catalog - .resolve_sandbox_policy_with_diagnostics("a", ctx) + .resolve_requirements_with_diagnostics("a", ctx) .unwrap(); ( r.diagnostics.catalog_revision.clone(), @@ -1769,7 +1790,7 @@ fn explicit_revisions_are_never_substituted() { ("2000-01-01.1".into(), 1.0) ); let error = catalog - .resolve_sandbox_policy("a", &ctx.catalog_revision("2000-01-03.1")) + .resolve_requirements("a", &ctx.catalog_revision("2000-01-03.1")) .unwrap_err(); assert_eq!( error.message(), 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..14f8ea6b1 --- /dev/null +++ b/src/mxc-sdk/tests/policy_store_sdk.rs @@ -0,0 +1,41 @@ +// 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::{ResolveContext, 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", &ResolveContext::new()).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); +} + +#[test] +fn unmatched_tools_resolve_to_none_with_status() { + let result = resolve_tool_requirements_with_diagnostics( + ToolCandidate::new("no-such-tool-xyz"), + &ResolveContext::new(), + ) + .unwrap(); + assert!(result.requirements.is_none()); + assert_eq!( + result.diagnostics.tools[0].status, + ToolResolutionStatus::ToolUnmatched + ); +} + +#[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 index 2722bf920..753574f5e 100644 --- a/src/mxc-sdk/tests/policy_store_vectors.rs +++ b/src/mxc-sdk/tests/policy_store_vectors.rs @@ -10,7 +10,6 @@ mod common; use common::*; use mxc_sdk::__policy_store::tooling::{ canonical_json, is_absolute_path, normalize_path, parse_purl, path_key_segments, Json, - ParsedPurl, }; use mxc_sdk::__policy_store::Platform; @@ -82,13 +81,11 @@ fn path_vectors() { #[test] fn malformed_percent_encoding_is_an_invalid_purl() { assert_eq!(parse_purl("pkg:npm/npm@%E0%A4%A"), None); - assert_eq!( - parse_purl("pkg:npm/npm@10.9.0"), - Some(ParsedPurl { - key: "npm/npm".into(), - version: Some("10.9.0".into()) - }) - ); + 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), From ec88edb63bdf4c932ca334705c20d1c39a63caf3 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 12:13:54 -0700 Subject: [PATCH 41/50] mxc_ffi: expose the tool-requirements lookup to the Node and C# SDKs Restore the prototype policy store C ABI over mxc-sdk's internal policy_store module: mxc_resolve_tool_requirements_json and its diagnostics variant return {"requirements"?} / {"requirements"?, "diagnostics"} in the v1 ContainerRequirements shape, alongside the catalog info and entry listing. Failures keep the status code and the store's details.reason; panics stay contained. The request is a catalog lookup rather than policy ingress, and the composed value is proven to convert to the typed v1 sections before it crosses the boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ffi/mxc_ffi/build.rs | 2 + src/ffi/mxc_ffi/src/lib.rs | 6 + src/ffi/mxc_ffi/src/policy_store.rs | 234 ++++++++++++++++++++++++ src/ffi/mxc_ffi/tests/policy_store.rs | 250 ++++++++++++++++++++++++++ src/mxc-sdk/src/policy_store/sdk.rs | 53 ++++++ 5 files changed, 545 insertions(+) create mode 100644 src/ffi/mxc_ffi/src/policy_store.rs create mode 100644 src/ffi/mxc_ffi/tests/policy_store.rs 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..b61f46b0a --- /dev/null +++ b/src/ffi/mxc_ffi/tests/policy_store.rs @@ -0,0 +1,250 @@ +// 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]["matches"][0]["entryId"], + "tool:git" + ); + 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["matches"][0]["intentSelection"]["requested"], "fetch"); + 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/src/policy_store/sdk.rs b/src/mxc-sdk/src/policy_store/sdk.rs index 074b9947e..8c28475ad 100644 --- a/src/mxc-sdk/src/policy_store/sdk.rs +++ b/src/mxc-sdk/src/policy_store/sdk.rs @@ -79,3 +79,56 @@ pub fn list_catalog_entries() -> Result, Error> { 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()) + } +} From ffe33e58808e349d5949f904d77f64972ce8454d Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 12:20:16 -0700 Subject: [PATCH 42/50] Node SDK: add the prototype tool-requirements lookup to @microsoft/mxc-sdk/v1 resolveToolRequirements and resolveToolRequirementsWithDiagnostics take one ToolInput or an array and return Promises of ContainerRequirements (Pick) and ToolRequirementsResolution. Resolution runs on Koffi's worker pool because it examines host filesystem objects; getCatalogInfo (now with sdkContractVersion) and listCatalogEntries stay synchronous. Warnings are typed as the spec's discriminated unions, and failures reject with MxcError carrying details.reason. Labeled PROTOTYPE, pending API review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/node/package.json | 2 +- sdk/node/src/bindings/policy-store.ts | 140 +++++++++ sdk/node/src/v1/index.ts | 34 +++ sdk/node/src/v1/tool-requirements.ts | 362 +++++++++++++++++++++++ sdk/node/tests/unit/policy-store.test.ts | 221 ++++++++++++++ 5 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 sdk/node/src/bindings/policy-store.ts create mode 100644 sdk/node/src/v1/tool-requirements.ts create mode 100644 sdk/node/tests/unit/policy-store.test.ts 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..ad8785af7 100644 --- a/sdk/node/src/v1/index.ts +++ b/sdk/node/src/v1/index.ts @@ -66,6 +66,40 @@ 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, + 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..858f57b5c --- /dev/null +++ b/sdk/node/src/v1/tool-requirements.ts @@ -0,0 +1,362 @@ +// 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 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'; + +/** 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 egress rule with its source entries. */ +export interface NetworkRequirement { + rule: NetworkRuleConfig; + 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<{ + inputIndex: number; + status: ToolResolutionStatus; + /** At most one entry; empty for `tool_unmatched`. */ + matches: Array<{ + 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 & { platform: CatalogPlatform; architecture?: CatalogArchitecture } + >; + 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 }; + return { requirements: result.requirements, diagnostics: result.diagnostics }; +} + +/** + * **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..ff253756d --- /dev/null +++ b/sdk/node/tests/unit/policy-store.test.ts @@ -0,0 +1,221 @@ +// 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'; + +// 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.matches[0].entryId, 'tool:git'); + assert.strictEqual(tool.matches[0].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.matches[0].versionSelection.selectedVersionRange, + 'vers:intdot/>=2.40|<2.50', + ); + assert.deepStrictEqual(tool.matches[0].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); + + 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'); + }); + + 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', + ); + }); +}); From 33130f189b6c11cf65d214d48ad7254c6e0df4ef Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 12:32:30 -0700 Subject: [PATCH 43/50] .NET SDK: add the prototype tool-requirements lookup to Microsoft.Mxc.Sdk.V1 PROTOTYPE, pending API review. Adds MxcContainer.ResolveToolRequirements and ResolveToolRequirementsWithDiagnostics (single-tool and list overloads, plus Async Task forms), GetCatalogInfo, and ListCatalogEntries over the mxc_ffi policy-store exports, per spec f7a450c section 5.1. - Results map onto ContainerRequirements built from the existing v1 section types; ContainerRequest.FromRequirements adds the caller's command. - Typed diagnostics, including structured ResolutionWarning subtypes parsed strictly (unknown codes or fields fail rather than being dropped). - Failures throw MxcException with the new Reason property (null outside the policy store), restoring Chaz's earlier error-shape decision on top of the upstream MxcException. - The lookup is excluded from the IContainerRunner mirror test: it is a catalog lookup, not container execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MxcExceptionTests.cs | 9 + .../V1/SandboxAdapterTests.cs | 16 +- .../V1/ToolRequirementsTests.cs | 224 ++++++++ .../Microsoft.Mxc.Sdk/MxcJsonSerialization.cs | 12 + .../Microsoft.Mxc.Sdk/V1/ContainerRequest.cs | 17 + .../V1/MxcContainer.ToolRequirements.cs | 320 +++++++++++ .../Microsoft.Mxc.Sdk/V1/MxcContainer.cs | 2 +- .../Microsoft.Mxc.Sdk/V1/MxcException.cs | 12 + .../Microsoft.Mxc.Sdk/V1/ToolRequirements.cs | 530 ++++++++++++++++++ 9 files changed, 1140 insertions(+), 2 deletions(-) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs 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..ba2b2a0d5 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs @@ -0,0 +1,224 @@ +// 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.Equal("tool:git", tool.Matches[0].EntryId); + Assert.Equal("weak", tool.Matches[0].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.Matches[0].VersionSelection.SelectedVersionRange); + Assert.Equal("named", tool.Matches[0].IntentSelection!.Mode); + Assert.Equal("push", tool.Matches[0].IntentSelection!.Requested); + Assert.Equal(new[] { "push" }, tool.Matches[0].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); + + 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); + } + + [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..8a7dd3ff5 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs @@ -0,0 +1,320 @@ +// 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."); + } + + return new ToolRequirementsResolution( + resolution.Requirements, + resolution.Diagnostics ?? EmptyToolRequirementsDiagnostics); + } + + 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..629bf7c0b --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs @@ -0,0 +1,530 @@ +// 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 . +/// +/// +/// The bare invocation name, for example git. A weak identity, used only +/// when is set. +/// +public sealed record ToolCandidate(string InvocationName) +{ + /// + /// 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; } +} + +/// The entry matched by one input. +public sealed record ToolMatch( + 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. +/// +/// At most one entry; empty for tool_unmatched. +public sealed record ToolDiagnostics(int InputIndex, string Status, IReadOnlyList Matches); + +/// 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; +} From 36cca01c89e63b3a817aa9ecdf86ef1be0f908dc Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 12:35:20 -0700 Subject: [PATCH 44/50] docs: describe the prototype tool-requirements lookup for spec f7a450c PROTOTYPE, pending API review. - docs/policy-store/README.md: ContainerRequirements, the internal mxc-sdk policy_store module, the v1 entry points in each SDK (promise-based Node), per-input statuses, structured warnings, exact MXC 1.0.0 validation, failure reasons, and the catalog workflow with the current test names. - docs/policy-store/design.md: replaced the drifting copy of the spec with implementation notes that point at microsoft/mxc#1309 and record the prototype's decisions and known gaps. - src/mxc-sdk/policy_store/README.md and the Node, .NET, and Rust SDK READMEs: prototype sections with examples. - .github/copilot-instructions.md: the policy_store module invariant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + docs/policy-store/README.md | 233 ++++---- docs/policy-store/design.md | 849 +++-------------------------- sdk/dotnet/README.md | 30 + sdk/node/README.md | 29 + src/mxc-sdk/README.md | 24 + src/mxc-sdk/policy_store/README.md | 56 +- 7 files changed, 288 insertions(+), 934 deletions(-) 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 index b630e0431..af3424f56 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -4,118 +4,125 @@ > 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 a -candidate **floor** `SandboxPolicy` from a reviewed catalog bundled statically -in the MXC SDKs. A floor is a best-effort starting point, not a guarantee, and -it never grants access by itself: the caller reviews it, composes it with its -own policy and ceilings, and passes the result to the SDK's existing sandbox -APIs. It is complementary to Learning Mode, not a replacement. - -- [design.md](design.md) — the design proposal, updated for the design review - outcome and the entry model below. -- [`catalog/views/`](../../src/core/mxc_policy_store/catalog/views/) — a - generated reviewer view of every effective policy in each revision. +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 design spec is [`docs/mxc-policy-store.md` in + microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309); this + prototype tracks its head `f7a450c`. +- [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/core/mxc_policy_store` | Single implementation; owns `catalog/`, `schema/`, and `conformance/` | -| Rust SDK | `src/core/mxc-sdk/src/policy_store.rs` | `mxc_sdk::policy_store::{resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, get_catalog_info, list_catalog_entries}` | -| C ABI | `src/ffi/mxc_ffi/src/policy_store.rs` | `mxc_resolve_sandbox_policy_json`, `mxc_resolve_sandbox_policy_with_diagnostics_json`, `mxc_policy_catalog_info_json`, `mxc_list_policy_catalog_entries_json`, `mxc_policy_store_result_free` | -| Node SDK | `sdk/node/src/policy-store.ts` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` | -| C# SDK | `sdk/dotnet/Microsoft.Mxc.Sdk/MxcPolicyStore.cs` | `MxcPolicyStore.ResolveSandboxPolicy`, `ResolveSandboxPolicyWithDiagnostics`, `GetCatalogInfo`, `ListCatalogEntries` | - -There is one resolver. `build.rs` compiles the catalog into the -`mxc_policy_store` crate, so the data ships inside `mxc_ffi` and every SDK -that loads it; nothing is downloaded. The Rust SDK converts the store's policy -into `mxc_sdk::SandboxPolicy`. The Node and C# SDKs send a JSON request -(`{"tools", "context"?}`) through `mxc_ffi` and map the result onto their own -`SandboxPolicy` types. Resolution stays outside `mxc_engine`: it never selects -a backend or launches a sandbox. +| 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].status === 'matched_version' +// diagnostics.resolvedDependencies[0].entryId === 'tool:ssh' +if (requirements) { + const request: ContainerRequest = { ...requirements, command: 'git push' }; +} +``` ## Entry model -Each catalog entry has one unversioned **default** (a `sandboxPolicy`, 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 -can add paths, outbound allow rules, dependencies, and intents; 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. See [design §4](design.md#4-data-model). - -## Resolution pipeline - -One call resolves one input or an array of inputs in a single pass. Each -input is a tool candidate (`invocationName`, optional `packageUrl`, -`detectedVersion`, and `intent`), or a bare name. - -1. **Validate inputs and context.** Invalid input fails as - `malformed_request` (`invalid_context`), never as absence. -2. **Load the revision** — the requested `catalogRevision`, or the bundled - default. A missing revision is `backend_error` (`revision_unavailable`); - unreadable bundled data is `backend_error` (`integrity`). -3. **Match each input to at most one entry.** A `purl` match is strong; an - `invocation-name` match is weak and needs `allowWeakIdentityFallback`. - Names compare case-insensitively on Windows and macOS and exactly on Linux. - A version embedded in `packageUrl` is ignored with a warning. Candidates - are ranked by strength, then intent support, then architecture - specificity; a tie fails as `policy_validation` (`ambiguous_match`). -4. **Build the effective policy.** The exact architecture overlay wins over - the neutral one; another architecture is never a fallback. An omitted - architecture uses the device's native system architecture, with a warning. - `detectedVersion` selects at most one version overlay. -5. **Select the intent** and record a per-input status: - - | 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 policy plus every intent. Non-default outcomes - also appear as structured warnings. Other inputs still resolve. -6. **Close over dependencies**, depth-first, rejecting cycles. A dependency - contributes only its default base plus its platform overlay's base - additions; it never selects a version overlay. A reference that names - intents (`{ "entryId": "tool:ssh", "intents": ["connect"] }`) adds those - intents too, and catalog validation rejects a named intent the dependency - does not define. Dependency diagnostics report intent mode `none` or - `named`. -7. **Resolve symbols.** `project_root` comes only from `projectRoot`. A - missing required symbol yields no policy plus a warning naming it; the - resolver never returns a partial policy. -8. **Compose** to satisfy every contributing pair, and never more. Paths are - substituted, normalized, and de-duplicated; read-write supersedes - read-only; a catalog filesystem deny that overlaps a required path is - removed; outbound allow rules are unioned, so a tool without network needs - never vetoes another's; a catalog egress deny that overlaps any required - allow rule (destinations intersect after `except`, and protocol/port - selectors intersect) is removed in full, and non-overlapping denies stay. - Each adjustment produces a warning naming the full removed scope and the - contributing entries; a conflicting deny never fails the request. Mixed - policy versions, non-egress network settings across several network - requirements, and other fields the model cannot express fail as - `policy_validation` (`composition_conflict`). +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. +- 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. 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 over the floor. +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`, `MxcException.Reason` in C#, and -`PolicyCatalogError::reason()` in Rust. +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 SDK `SandboxPolicy`. | +| `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. | @@ -123,12 +130,12 @@ on the Node `MxcError`, `MxcException.Reason` in C#, and | `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 returns no policy. +Absence is not a failure: the resolve call yields no requirements. ## Changing the catalog Published revisions are immutable. Never edit a file under -`src/core/mxc_policy_store/catalog/revisions/` that is already listed in +`src/mxc-sdk/policy_store/catalog/revisions/` that is already listed in `catalog/manifest.json`; publish a new revision instead. 1. Copy the latest revision to `catalog/revisions/.json` and set @@ -137,33 +144,27 @@ Published revisions are immutable. Never edit a file under entry needs a `versionScheme` and one `default`; overlays are additive only, and version ranges in one entry must not overlap. 3. Append `{ catalogRevision, file }` to the manifest and point - `defaultRevision` at it. There is no separate catalog digest: the data is - compiled into the native library and inherits MXC package signing. + `defaultRevision` at it. The data is compiled into the native library and + inherits MXC package signing. 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 into the - fixtures by hand. -5. Regenerate the reviewer view with `MXC_POLICY_STORE_UPDATE_VIEWS=1` - (`cargo test -p mxc_policy_store --test catalog_validation`) and review it. - Its *Added to default* column shows what each platform, version, and - intent row adds to the common default. + 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. Regenerate the views with `MXC_POLICY_STORE_UPDATE_VIEWS=1` + (`cargo test -p mxc-sdk --test policy_store_catalog_validation`) and + review them. 6. Run the checks (from `src/`): ```text - cargo test -p mxc_policy_store - cargo test -p mxc-sdk --test policy_store + 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. -Each new entry or requirement expansion needs identity evidence, supported -version ranges, per-platform evidence, a minimized requirement set (captured -with Learning Mode before the pull request), fixtures, and `provenance`. See -[design §7](design.md#7-contribution-and-review). - -The JSON Schemas in `schema/` describe the catalog shape for editors. They are -not run in CI; the Rust validation in `tests/catalog_validation.rs` covers the -same rules and more. +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 index b8265c393..6e8fb6f11 100644 --- a/docs/policy-store/design.md +++ b/docs/policy-store/design.md @@ -1,786 +1,65 @@ - -# Feature Spec: Known-tool Policy Floors - -**Status:** Prototype, pending API review and sign-off before check-in. The -policy store ships inside MXC as new APIs in the existing MXC SDKs. It is not -part of MXC 1.0; a later MXC SDK release is targeted. The API names in this -document are the current proposal and may change in review (see -[§13](#13-open-questions)). - -**IMPORTANT NOTE:** A resolved policy is a best-effort floor, not a guarantee. -Applying it does not guarantee that a tool's end-to-end workflow will work -under process containment. The policy store is complementary to Learning Mode, -not a replacement for it (see [§8](#8-relationship-to-learning-mode)). - ---- - -## 1. Problem Statement - -Developers frequently disable process isolation after enabling it breaks tools -needed for their workflow. This makes the first-run experience for process -containment poor and reduces adoption before developers can identify the -missing policy. - -The near-term mitigation is a public, reviewable set of known per-tool policy -floors. Consumers can apply a candidate floor instead of starting from no tool -knowledge, and contributors can iterate on the data as failures are found. A -floor only describes a known minimum requirement. It does not prove that every -process, dependency, credential, service, or network interaction in an -end-to-end workflow is covered. - -[#779](https://github.com/microsoft/mxc/pull/779) proposed the initial config -floor data model and SDK resolver. This document narrows that proposal into a -temporary catalog contract with explicit identity, platform, dependency, -revision, and inspection behavior. It reuses #779's model where possible and -calls out differences directly. - -This document does not restate general MXC sandboxing concepts already covered -by [`docs/sandbox-policy/0.8.0/policy.md`](../sandbox-policy/0.8.0/policy.md) or -[`docs/versioning.md`](../versioning.md). It covers only what a policy store adds. - -### Non-goals - -- This does not change what the sandbox backend enforces, or `SandboxPolicy` / - `ContainerConfig` schema semantics. A catalog entry embeds an existing - `SandboxPolicy`; it does not define a parallel vocabulary. -- This is not a trust or attestation mechanism, and it does not authorize - anything. See [§9](#9-trust-model). -- This is not a guarantee that a complete tool workflow will succeed under - process containment. -- This does not define how any specific consumer stores, displays, or lets a - user approve requirements. See [§2](#2-ownership-boundary). -- This does not define Learning Mode's candidate-generation or review UX. See - [§8](#8-relationship-to-learning-mode). - -### MXC feature impact and defaults - -This adds known-tool lookup APIs to the existing MXC SDKs. Following the feature-impact -checklist in [`docs/authoring-a-new-feature.md`](../authoring-a-new-feature.md): - -- **Policy changes:** None. Catalog entries embed an existing, registered - `SandboxPolicy`. -- **ContainerConfig changes:** None. The catalog does not add configuration - fields or change omission behavior in an existing contract. -- **OS and backend changes:** None. Backends continue to validate whether they - can enforce the resolved policy. -- **MXC SDK changes:** Additive. The Node (`@microsoft/mxc-sdk`), Rust - (`mxc-sdk`), and C# (`Microsoft.Mxc.Sdk`) SDKs gain catalog lookup and - inspection APIs that return each SDK's existing `SandboxPolicy` type. Existing - APIs and behavior are unchanged. See - [§6](#6-packaging-inside-the-mxc-sdks). -- **No separate deliverable:** There is no separate repository, standalone - library, or CLI utility. - -The policy store does not add an MXC schema feature, activate the -`--experimental` runtime gate, or change executor behavior. - -Defaults and omission behavior are: - -- Existing callers do not perform catalog lookup automatically. A consumer - must explicitly enable or invoke it. -- Omitted `ResolveContext.platform` uses the current host platform. -- Omitted `ResolveContext.architecture` uses the device's native system - architecture, not the architecture of the calling process or a detected - tool build. Explicit caller selection takes precedence. See the selection - rules and emulation risk in [§4.4](#44-platform-and-architecture). -- Omitted `ResolveContext.catalogRevision` uses the catalog revision bundled - with the SDK. -- Omitted `ResolveContext.allowWeakIdentityFallback` is `false`. -- Omitted `projectRoot` and `symbols` provide no caller overrides. The resolver - may use approved host-known symbols, but it does not invent machine-specific - values. A selected entry with an unresolved required symbol is not - resolvable. -- Omitted `packageUrl` or `detectedVersion` supplies no matching evidence. The - resolver does not fabricate either value or infer a version from anything - else. Omitted `intent` selects the base policy plus every intent. -- If no policy can be resolved, `resolveSandboxPolicy` returns `undefined`. - `resolveSandboxPolicyWithDiagnostics` instead returns a result whose `policy` is - `undefined`, preserving the diagnostics. The consumer's restrictive baseline - remains unchanged. - -## 2. Ownership boundary - -MXC owns a validated, versioned, read-only data set of known-tool -sandbox requirements, the resolver that reads it, and the SDK APIs that expose -it, alongside the existing `SandboxPolicy` contract. The data is reviewed and -released with the MXC SDKs. - -The catalog states a candidate minimum that a tool needs. It does not grant -access, modify caller state, create a sandbox, or guarantee workflow success. - -Everything else is a consumer decision: - -- Whether automatic catalog lookup is enabled at all. -- Access-profile mapping, elevation preference, and per-tool authorization. -- Persistence of accepted requirements (which tools, which catalog/entry - revision, when). -- Composition with the consumer's own user, learned, and invocation-specific - policy layers, and with non-overridable OS/enterprise/device ceilings. -- Approval UX, audit, and the final call into `createConfigFromPolicy()` / - sandbox creation. - -A catalog lookup can only ever narrow what a consumer still has to decide for -itself. `resolveSandboxPolicy` returns a candidate composed requirement or -`undefined`; its diagnostics counterpart also reports how that result was -obtained. The consumer decides whether and how to act on it. This mirrors -#779's floor/policy distinction, discussed further in -[§3](#3-relationship-to-the-config-floors-proposal): a resolved entry is a -lower bound asserted by the tool ecosystem, never an upper bound the host is -required to grant. - -## 3. Relationship to the config-floors proposal - -| #779 (config floors) | This document (policy store) | -|---|---| -| One `schemaVersion` for the whole table | Separate version dimensions: `catalogSchemaVersion`, `catalogRevision`, per-entry `entryRevision`, the default's `sandboxPolicy.version`, and a per-entry tool `versionScheme` ([§4.1](#41-versions)) | -| Strongest satisfied identity predicate describes a match | Each input selects at most one ranked entry; a tie fails rather than guessing ([§4.3](#43-identity)) | -| One `sandboxPolicy` per entry; `when.platform` only conditions dependencies | One unversioned default per entry plus additive platform, version, and intent overlays; nothing can name a containment backend ([§4.2](#42-entry-shape)) | -| `requires` composition unspecified beyond "union" | Explicit composition rules that satisfy every requested pair; anything inexpressible fails ([§4.6](#46-composition)) | -| `getSandboxConfigForTool(tools: string[])` returns one composed policy | `resolveSandboxPolicy` accepts one tool or an array and returns one policy; `resolveSandboxPolicyWithDiagnostics` adds attribution, with catalog inspection kept separate ([§5](#5-api-surface)) | -| No revision/publication model | Immutable published catalog revisions; corrections publish a new revision ([§10](#10-immutable-revisions)) | - -The data model, the floor/policy direction argument, multi-tool composition, -and the identity layering problem (invocation name vs. launcher artifact vs. -executing image) build on #779. The matching, composition, and trust refinements -are stated here rather than implied by that reference. - -## 4. Data model - -### 4.1 Versions - -| Field | Meaning | +# 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 design spec is `docs/mxc-policy-store.md` in +[microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309). This +prototype tracks spec head `f7a450c`. 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. +- 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 | |---|---| -| `catalogSchemaVersion` | Version of the catalog JSON shape itself. | -| `catalogRevision` | Immutable identifier for one published, fully reviewed catalog. | -| `entryRevision` | Monotonic revision of a single entry, for cache invalidation and audit comparison. | -| `sandboxPolicy.version` | The exact registered `SandboxPolicy` contract version of an entry's default. | -| `versionScheme` | Required per entry: how its tool versions are parsed and compared (`npm`, `semver`, `pypi`, `nuget`, or `intdot`). | - -These identifiers serve separate purposes and do not advance in lockstep. -Registering a new `SandboxPolicy` contract does not change existing catalog -data. Migrating an entry to that contract changes its content, so the -migration increments both `entryRevision` and `catalogRevision`. Tool -version ranges are an orthogonal axis: they describe which builds of a tool -the reviewed evidence covers, not anything about the catalog. - -### 4.2 Entry shape - -An entry has exactly one unversioned **default** and any number of -**overlays**. Overlays only add; none can remove or narrow what the default or -another overlay grants. Abbreviated from the bundled `tool:git` entry: - -```json -{ - "entryId": "tool:git", - "entryRevision": 1, - "displayName": "Git", - "versionScheme": "intdot", - "identity": [ - { "kind": "purl", "value": "pkg:generic/git" }, - { "kind": "invocation-name", "names": ["git", "git.exe"] } - ], - "default": { - "sandboxPolicy": { - "version": "0.9.0-alpha", - "filesystem": { "readonlyPaths": ["${git_prefix}"], "readwritePaths": ["${project_root}"] } - }, - "intents": { - "local": { "exampleSubcommands": ["status", "diff"] }, - "fetch": { "policyAdditions": { "network": { "egress": { "allow": ["…tcp/443…"] } } } }, - "push": { "policyAdditions": { "network": { "egress": { "allow": ["…tcp/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": { "policyAdditions": { "…": "…" } } } } - ], - "provenance": { "method": "design-example", "sourceRevision": "…" } -} -``` - -The bundled revision (`2026-10-05.1`) carries `tool:git`, `tool:node`, -`tool:npm`, and `tool:ssh`. Its network addresses are documentation ranges -(RFC 5737), not real endpoints. - -- **Default.** One complete `sandboxPolicy`, the default's dependencies, and - its named intents. It applies whenever the entry matches. -- **Platform overlays** (`platformVariants`) add filesystem paths, outbound - allow rules, dependencies, intent extensions (`intentAdditions`), or new - intents for one platform, optionally one architecture. An exact - architecture overlay is preferred over the platform's architecture-neutral - overlay; another architecture's overlay is never used. Selectors must be - unique, with at most one neutral overlay per platform. -- **Version overlays** (`versionVariants`) carry a purl `vers` range in the - entry's `versionScheme`. Ranges within an entry must not overlap, so at most - one applies. A version overlay adds the same kinds of things a platform - overlay can. -- **Intents** are lower-case names (`[a-z][a-z0-9_-]*`). An intent adds - access and dependencies on top of the base policy. `intentAdditions` may - extend only intents the default declares; `newIntents` may introduce only - names no other applicable overlay declares. -- **Not additive**, and therefore rejected in an overlay: `deniedPaths`, - `network.egress.deny`, `network.egress.default`, ingress, `ui`, and - `timeoutMs`. Those may appear only in the default's `sandboxPolicy`. - -The **effective policy** for one platform, architecture, and version is the -default plus the selected platform overlay plus at most one version overlay; -an intent then selects from the effective policy's intents. Symbols -(`${project_root}`, `${npm_cache}`, …) are resolved before a policy is -returned; catalog data never ships a literal, machine-specific path. - -### 4.3 Identity - -A purl predicate is a **strong** identity; an invocation-name predicate is a -**weak** fallback that participates only with `allowWeakIdentityFallback`. -Catalog purls never pin a version, and a version embedded in a caller's -`packageUrl` is ignored with a warning: the only version evidence is -`detectedVersion`. Invocation names compare case-insensitively on Windows and -macOS and exactly on Linux. - -Each input selects at most one entry. Among eligible entries the resolver -prefers a strong match, then an entry whose applicable overlays declare the -requested intent, then an exact-architecture overlay over a neutral one over -none. A tie at the top rank fails as `policy_validation` -(`ambiguous_match`) rather than guessing. The resolver does not verify that -the executable really carries the identity the caller passed. - -### 4.4 Platform and architecture - -Supported platforms are `windows`, `linux`, and `macos`; architectures are -`x64` and `arm64`. An omitted `ResolveContext.architecture` uses the device's -native system architecture (not the process architecture), detected only when -an entry has architecture-specific overlays for the platform; when it is -used, a warning says the tool's architecture was not verified. Falling back -to a platform's neutral overlay because no exact overlay exists also produces -a warning. A failure to detect the native architecture when it is needed is -an error, not a guess. - -An x64 tool running under emulation on an ARM64 device may need the x64 -overlay. The resolver never inspects or runs the tool; callers that know the -tool's architecture should pass it explicitly. - -### 4.5 Resolution per tool and intent - -Each input is one tool-and-intent pair, resolved independently: - -| Situation | Status | Contribution | -|---|---|---| -| No detected version | `matched_default` | the effective policy without a version overlay | -| Version inside a range | `matched_version` | default plus that version overlay | -| Valid version in no range (including an entry with no ranges) | `version_out_of_range` | as `matched_default`, with a structured warning | -| Version the scheme cannot parse | `version_unparseable` | nothing | -| Intent not defined by the effective policy | `intent_unsupported` | nothing; the version status stays in `versionSelection` | -| No intent | (version status) | base policy plus every intent of the effective policy | -| No eligible entry | `tool_unmatched` | nothing | - -There is no wildcard fallback and no "require all" option: other inputs still -resolve, so a result may cover only some of the requested tools. -A dependency contributes only its unversioned default base plus its -applicable platform overlay's base additions; it never selects a version -overlay or includes intents. This differs from a requested tool with no -intent, which includes all effective intents. A reference may name dependency -intents (`{ "entryId": "tool:ssh", "intents": ["connect"] }`); those intents, -including their applicable platform `intentAdditions`, are then added. -Catalog validation rejects a named dependency intent that is missing from any -materialized platform combination where the reference applies. A -dependency's `versionRange` (in the target entry's scheme) is recorded as -unevaluated metadata. - -### 4.6 Composition - -All contributing pairs and their dependency closure compose into one policy: - -1. Every contribution must declare the same `sandboxPolicy.version`. -2. Paths are substituted and normalized with the selected platform's rules, - then de-duplicated exactly. Case-only differences are kept and warned - about, because the filesystem's case sensitivity is not determined. -3. Read-write supersedes read-only: a read-only path at or under a read-write - path is omitted, with a warning. -4. A catalog deny that overlaps any required read-only or read-write path is - removed entirely, with a warning naming its scope. Non-conflicting denies - are kept. -5. Outbound allow rules and catalog egress deny rules from every pair are - unioned (whole rules, never a cross-product) under a default-deny egress - posture, so a pair without network needs never vetoes another pair's. If - only one contribution uses network, its other network settings are kept. -6. A catalog egress deny that overlaps any required allow rule (destination - CIDRs intersect after `except` exclusions, and protocol/port selectors - intersect) is removed in full, not narrowed, within one entry or across - entries. The warning names the full removed scope, notes that other grants - may now apply throughout it, and lists the contributing entries. - Non-overlapping denies are kept, and a conflicting deny never fails the - request. -7. A single contribution with no additions passes through whole, including - fields such as `ui` and `timeoutMs`. Anything else the model cannot - express across contributions (for example ingress settings alongside - another contribution's network, or `timeoutMs` in a composed set) fails as - `policy_validation` (`composition_conflict`) rather than granting broader - access. - -The caller's own restrictions still win: the floor is input to the caller's -composition, never a ceiling override. - -### 4.7 Build-time validation - -Catalog validation (run by `cargo test -p mxc_policy_store`) checks the -contract and identity rules, the required `versionScheme` and single -default, unique selectors, non-overlapping ranges in the entry's scheme, -additive-only overlays, dependency targets and their ranges, and cycles. It -then materializes every platform × architecture × version × intent effective -policy (including the "all intents" case) with its dependency closure, -validates each composed policy, and checks that the default is a subset of -each, and that every dependency reference's named intents exist. A Markdown -reviewer view of every effective policy, with an *Added to default* column -showing each row's additions relative to the common default, is generated -into `catalog/views/.md` and must stay current. - -## 5. API surface - -The MXC SDKs separate runtime resolution from catalog inspection. -Resolution accepts one tool or an array and composes all applicable matching -entries and dependencies into one `SandboxPolicy`. Callers choose a policy-only -operation or a diagnostic operation over the same resolution logic. Neither -implicitly returns the whole catalog. These are in-process SDK calls, not a -hosted service, and they never launch a sandbox. - -The signatures below use TypeScript to describe the shared contract. Rust and -C# expose the same operations and metadata with idiomatic names and types. -TypeScript and C# expose single-tool and array overloads; Rust uses an idiomatic -one-or-many input type because it does not support function overloading. An -absent policy is `undefined` in TypeScript/JavaScript, `None` in Rust, and -`null` in C#. Failures remain distinct from policy absence. - -### 5.1 Runtime lookup - -```ts -interface ToolCandidate { - invocationName: string; - packageUrl?: string; // strong identity; any embedded version is ignored - detectedVersion?: string; // the only version evidence - intent?: string; // for example "fetch" or "push" -} - -type ToolInput = string | ToolCandidate; - -interface ResolveContext { - projectRoot?: string; - symbols?: Record; - platform?: "windows" | "linux" | "macos"; - architecture?: "x64" | "arm64"; - catalogRevision?: string; - allowWeakIdentityFallback?: boolean; -} - -type VersionStatus = - | "matched_default" | "matched_version" - | "version_out_of_range" | "version_unparseable"; - -interface VersionSelection { - status: VersionStatus; - detectedVersion?: string; - selectedVersionRange?: string; // matched_version only -} - -interface IntentSelection { - requested?: string; - mode: "named" | "all" | "none" | "unsupported"; // dependencies: none or named - selected: string[]; -} - -interface ToolResolutionWarning { - code: "version_out_of_range" | "version_unparseable" - | "intent_unsupported" | "tool_unmatched"; - inputIndex: number; - entryId?: string; - detectedVersion?: string; - intent?: string; - message: string; -} - -interface SandboxConfigResolution { - policy: SandboxPolicy | undefined; - diagnostics: { - catalogRevision: string; - tools: Array<{ - inputIndex: number; - status: VersionStatus | "intent_unsupported" | "tool_unmatched"; - matches: Array<{ // at most one - entryId: string; - entryRevision: number; - matchedIdentities: Array<{ kind: string; strength: "strong" | "weak" }>; - versionSelection: VersionSelection; - intentSelection?: IntentSelection; // absent when unparseable - }>; - }>; - resolvedDependencies: Array<{ - entryId: string; - entryRevision: number; - requiredVersionRange?: string; - versionSelection: VersionSelection; - intentSelection: IntentSelection; - }>; - warnings: Array; - }; -} - -declare function resolveSandboxPolicy( - tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxPolicy | undefined; -declare function resolveSandboxPolicyWithDiagnostics( - tools: ToolInput | readonly ToolInput[], ctx?: ResolveContext): SandboxConfigResolution; -``` - -C# exposes single-tool and list overloads; Rust takes an idiomatic -one-or-many input. A string input is shorthand for `{ invocationName }` and -carries no package, version, or intent evidence. A one-element array is -equivalent to a single input, with `inputIndex` `0`. - -```ts -const ctx = { projectRoot: "/work/repo", symbols: { git_prefix: "/usr/bin" } }; -const git = { invocationName: "git", packageUrl: "pkg:generic/git", detectedVersion: "2.45.1" }; -// One floor for a fetch followed by a push. -const policy = resolveSandboxPolicy( - [{ ...git, intent: "fetch" }, { ...git, intent: "push" }], ctx); -``` - -`resolveSandboxPolicy` returns the composed `SandboxPolicy` directly; -`resolveSandboxPolicyWithDiagnostics` returns the same policy with -attribution and warnings from the same pass. An empty or entirely -non-contributing lookup returns no policy, not an empty policy. An unresolved -required symbol in a contributing entry prevents a policy and names the -symbol in a warning; the resolver never returns a partial policy. Free-text -warnings cover weak matches, host-derived architecture and symbols, -neutral-overlay fallback, ignored purl versions, and composition adjustments; -per-input outcomes use structured warnings. - -### 5.2 Setup and inspection - -```ts -type CatalogPlatform = "windows" | "linux" | "macos"; -type CatalogArchitecture = "x64" | "arm64"; - -type CatalogIdentityMetadata = - | { kind: "purl"; value: string } - | { kind: "invocation-name"; names: string[] }; - -interface CatalogIntentMetadata { - name: string; - exampleSubcommands?: string[]; - dependencyEntryIds: string[]; -} - -interface CatalogAdditionsMetadata { - dependencyEntryIds: string[]; - intentAdditions: CatalogIntentMetadata[]; - newIntents: CatalogIntentMetadata[]; -} - -interface CatalogEntryMetadata { - catalogRevision: string; - entryId: string; - entryRevision: number; - displayName: string; - versionScheme: "npm" | "semver" | "pypi" | "nuget" | "intdot"; - identity: CatalogIdentityMetadata[]; - default: { - dependencyEntryIds: string[]; - sandboxPolicyVersion: string; - intents: CatalogIntentMetadata[]; - }; - platformVariants: Array< - { platform: CatalogPlatform; architecture?: CatalogArchitecture } & CatalogAdditionsMetadata>; - versionVariants: Array<{ versionRange: string } & CatalogAdditionsMetadata>; - provenance: { - method: string; - sourceRevision: string; - }; -} - -listCatalogEntries(): CatalogEntryMetadata[]; -getCatalogInfo(): { catalogSchemaVersion: string; catalogRevision: string }; -``` - -This supports setup UI, catalog browsing, and update decisions without paying -the cost of policy resolution, and keeps "give me everything" out of the -runtime lookup path entirely. Metadata exposes selectors, version ranges, -intent names, dependency IDs, and provenance, but not an unresolved or -resolved policy body. - -### 5.3 Consumer obligations - -A consumer that uses this API: - -1. Decides whether automatic lookup is enabled at all. -2. When persisting an accepted policy, retains its `catalogRevision` and - contributing entry IDs/revisions from diagnostics, whether the policy - covers one tool or several. -3. Keeps catalog-derived requirements in a layer separate from its own user, - learned, and invocation-specific policy. -4. Applies its own authorization, elevation, and restrictive-composition - rules on top. -5. Enforces its OS, enterprise, device, and backend ceilings regardless of - what the catalog returned. -6. Fails closed when a required entry cannot be realized on the current - host/backend. It falls back to its own restrictive baseline and does not - run uncontained. -7. Uses the diagnostics operation when attribution or audit is needed, and - records matched identities, catalog/entry revisions, warnings, and approval - state in its own audit trail. - -The policy store APIs never write a consumer's own policy state. A consumer's own -capability observation (see [§8](#8-relationship-to-learning-mode)) can produce -candidate evidence for a future contribution to this catalog; it is not a -mechanism for mutating the catalog at request time. - -## 6. Packaging inside the MXC SDKs - -The policy store ships inside MXC as new APIs in the existing MXC SDKs. There -is no separate repository, standalone library, package, or CLI utility. A -consumer that already uses an MXC SDK resolves a floor and passes its final, -authorized policy to that SDK's existing sandbox APIs. - -### 6.1 SDK surface and bundled data - -| Language | Package | API | -|---|---|---| -| TypeScript / JavaScript | `@microsoft/mxc-sdk` | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` | -| Rust | `mxc-sdk` (`mxc_sdk::policy_store`) | `resolve_sandbox_policy`, `resolve_sandbox_policy_with_diagnostics`, `get_catalog_info`, `list_catalog_entries` | -| C# / .NET | `Microsoft.Mxc.Sdk` (`MxcPolicyStore`) | `ResolveSandboxPolicy`, `ResolveSandboxPolicyWithDiagnostics`, `GetCatalogInfo`, `ListCatalogEntries` | - -Each SDK returns its own existing `SandboxPolicy` type, not a parallel -catalog-only type. - -The V1 policy data is bundled statically in each SDK package. It is not -downloaded. Lookup is local and does not contact a hosted service or run the -candidate tool. `ResolveContext.catalogRevision` selects a bundled revision, -not a network lookup; an explicitly requested revision that is unavailable is -an error, not a substitution with a different revision. An omitted revision -uses the SDK's bundled default. - -A consumer: - -1. Calls `getCatalogInfo()` or `listCatalogEntries()` for inspection. - `resolveSandboxPolicy()` returns a policy for one tool or an array; - `resolveSandboxPolicyWithDiagnostics()` adds match attribution and warnings. -2. Handles policy absence without widening its restrictive baseline. It - reviews the composed policy and uses the diagnostics operation when it - needs contributing identities, revisions, and warnings, applying the - consumer obligations in [§5.3](#53-consumer-obligations). -3. Supplies its final policy to the SDK's existing sandbox APIs. The lookup - APIs do not launch a sandbox. - -An SDK release reports its bundled `catalogRevision`; the SDK version does not -identify the catalog revision or embedded `SandboxPolicy.version`. Updating -the SDK does not rewrite a consumer's previously accepted per-tool policies. - -### 6.2 Implementation and cross-language consistency - -There is one resolver implementation, in Rust (`src/core/mxc_policy_store`), -which also owns the bundled catalog data, its JSON schemas, and the shared -conformance fixtures. The Rust SDK re-exports it. The Node and C# SDKs reach it -through the existing `mxc_ffi` C ABI, the same native library they already -load, so all three SDKs agree on matching, variant selection, dependency -metadata, resolved policy, warnings, and failure categories by construction. -Language-specific absence and error types preserve those distinctions. - -Resolution stays outside `mxc_engine`: it never selects a backend or launches -a sandbox, so it does not belong in the execution engine. - -Shared fixtures cover platform path semantics as well as ordinary lookup, and -each SDK's own test suite exercises its binding against them. - -## 7. Contribution and review - -- Catalog contributions are pull requests against `microsoft/mxc`. No client - or SDK can write a catalog entry at runtime. -- Every entry change includes identity evidence, supported tool version - range(s), platform evidence, a minimized requirement set, test fixtures, - and provenance. -- CI validates the catalog contract, exact `SandboxPolicy` version - registration, entry-ID uniqueness, one default per entry, non-overlapping - version ranges, additive-only overlays, dependency closure and - cycle-freedom, symbol validity, absence of unsafe user-specific literal - paths, unsupported-field rejection, and deterministic resolution. It - materializes and validates every effective policy and checks the rendered - reviewer view ([§4.7](#47-build-time-validation)). -- A new entry or a requirement expansion requires one catalog-owner approval - and one security/policy-reviewer approval, plus tool- or scenario-owner - evidence where available. -- A requirement reduction requires regression evidence that every supported - tool version still functions under the narrower requirement. -- SDK API changes follow MXC's normal API review. These contribution - requirements do not require applications to seek maintainer approval to use - the bundled catalog. - -## 8. Relationship to Learning Mode - -Learning Mode is complementary to the policy store, not a replacement for it. -The policy store supplies a reviewed, best-effort starting floor for known -tools before anything has run; Learning Mode observes what a specific workload -actually touches and helps author policy for it, including for tools the -catalog does not know. - -MXC's learning-mode capabilities (`learningModeLogging`, -`permissiveLearningMode`, `captureDenials`; see -[`docs/learning-mode/capabilities.md`](../learning-mode/capabilities.md)) are the -substrate a contributor can use to observe what a tool actually touches, the -same way [#779 §5.1](https://github.com/microsoft/mxc/pull/779) describes for -config floors. That observation workflow is unchanged by this document and -remains **a contributor step that happens before a pull request**, not -consumer runtime behavior and not a catalog-mutation path. - -Whether and how a consumer turns its own runtime capability observations into -a candidate catalog contribution or a locally scoped policy suggestion is -that consumer's design. No runtime submission hook is proposed here. - -## 9. Trust model - -This document tightens #779's trust framing rather than replacing it. Entries -assert *need*, not authorization, but incorrect data has two different -outcomes: - -- An understated floor omits a requirement and can cause the tool or its - end-to-end workflow to fail under the resulting policy. -- An overstated floor can fail against a narrower consumer ceiling. If a - consumer instead approves or adopts it and its ceiling permits the request, - the effective policy contains unnecessary capability. - -What changes from #779 is the review bar. #779 described community-contributed, -unsigned, unwarranted data. This contract requires named-role approval -([§7](#7-contribution-and-review)) before an entry publishes, and publishes -under an immutable revision that CI checks against published history -([§10](#10-immutable-revisions)). -That raises confidence in the data; it does not change what the data *is*. The -catalog still carries no security guarantee or independent authority. A -consumer must review the requirement and intersect it with its own policy -rather than adopt it outright. The catalog can influence a consumer's -decision, so consumer approval and restrictive ceilings remain required even -though the catalog cannot grant capability by itself. See -[#779 §2.1](https://github.com/microsoft/mxc/pull/779) for why that layering is -honest about what such a choice costs. - -## 10. Immutable revisions - -Published catalog revisions are immutable. A correction, including a security -fix to an over-broad entry, publishes a new `catalogRevision` and bumps the -affected `entryRevision`; it never rewrites a revision a consumer may already -have cached or recorded in an audit trail. - -## 11. Backward compatibility - -- No change to `SandboxPolicy` or `ContainerConfig` schema. -- No change to executor behavior. -- The MXC SDK changes are additive. Catalog lookup requires an explicit call - to a new SDK API; existing MXC callers see no behavior change. -- The policy store is not part of MXC 1.0. Once it ships, the catalog schema - stays compatible within MXC 1.x. - -## 12. Test plan - -**Resolver (Rust, exercised directly and through each SDK binding)** - -- shared conformance fixtures produce the same results and failure reasons - across bindings (`mxc_policy_store` and the C# tests replay them; the - `mxc-sdk`, `mxc_ffi`, and Node tests cover the same paths through each - binding) -- a single input and a one-element array are equivalent; the policy-only API - returns the diagnostic API's policy -- every row of the per-pair table in [§4.5](#45-resolution-per-tool-and-intent), - including an out-of-range version with an unsupported intent -- `vers` parsing and comparison for all five schemes -- an embedded purl version is ignored with a warning -- strong identity outranks weak, intent support breaks ties, exact - architecture outranks neutral, and remaining ties fail as `ambiguous_match` -- invocation-name casing per platform -- native-architecture default with its warning; explicit architecture; no - cross-architecture fallback; neutral fallback warning -- unresolved symbols prevent policy output; host-derived symbols only for the - current host platform, overridable by the caller -- composition: read-write supersedes read-only, conflicting filesystem - denies removed with diagnostics, outbound allows unioned without a network - veto, overlapping catalog egress denies removed in full (within one entry - and across entries) while non-overlapping denies stay, mixed policy versions - and inexpressible combinations rejected -- dependency chains compose each entry once; dependency records keep distinct - required ranges; a plain dependency contributes its base only (mode - `none`), a reference naming intents adds them (mode `named`), and a - missing named intent fails validation - -**Data (CI)** - -- the contract and every entry validate, and every effective policy is - materialized and validated ([§4.7](#47-build-time-validation)) -- the reviewer view is current; every entry has a bundled conformance case -- catalog and entry revision monotonicity; published revisions unchanged when - `MXC_POLICY_STORE_BASE_REF` is set - -**Integration** - -- each SDK performs lookup without launching a sandbox or selecting a - backend; lookup requires no network access -- the bundled catalog revision matches `getCatalogInfo()`; selecting an - unavailable revision fails explicitly -- an SDK update leaves previously accepted consumer policies unchanged -- a representative tool that fails under a minimal consumer policy succeeds - once its resolved floor is composed in, and still fails when the consumer's - policy forbids what the floor requests - -## 13. Open questions - -Recommended answers are proposals for review, not decisions. - -| Question | Status | -|---|---| -| Final API names? | Pending API review. Follow MXC's policy-type rename that drops "Sandbox" and align the resolver names with it; current names are kept until then. | -| Exact policy validator and `SandboxPolicy` version mapping used at build time | Open. The prototype validates composed effective policies with the catalog contract's own subset validator. | -| Purl normalization and equality, including purl-embedded versions | Open. The prototype lower-cases the type, compares namespace and name as written, and ignores an embedded version with a warning. | -| Symbol `defaults` metadata and local discovery (caller, then discovery, then documented default) | Specified by the canonical design; not yet implemented in the prototype, which uses caller-supplied symbols and a small set of host-derived ones. | -| `vers` conformance for `pypi` and `nuget` | The prototype implements practical subsets of PEP 440 and NuGet versioning. Exact conformance is open. | -| How does the catalog schema evolve? | Keep it compatible within MXC 1.x; a breaking change waits for a major version. | -| Is invocation-name-only identity accepted automatically? | No: it requires `allowWeakIdentityFallback`. | -| Are private or enterprise catalog overlays in scope? | Deferred until the shared catalog contract and API are stable. | -| Who owns catalog schema, data, and API review? | MXC, through its normal API review, with named catalog and security reviewers. | - -## 14. Related work - -- [`microsoft/mxc#779`](https://github.com/microsoft/mxc/pull/779) - Sandbox - Config Floors feature spec. This document's data model, floor/policy - direction argument, and identity-layering analysis build directly on it. -- [`ChazGo/mxc#1`](https://github.com/ChazGo/mxc/pull/1) - the prototype of - this design inside the MXC SDKs, pending API review. -- [`docs/sandbox-policy/0.8.0/policy.md`](../sandbox-policy/0.8.0/policy.md) - - the `SandboxPolicy` contract every catalog entry embeds. -- [`docs/versioning.md`](../versioning.md) - the versioning model - [§4.1](#41-versions) builds on. +| 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/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/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index bc223c2bb..4d523cbc9 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -115,6 +115,30 @@ 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"); +if let Some(requirements) = resolve_tool_requirements(git, &ResolveContext::default())? { + let request = ContainerRequest::from_requirements(requirements, "git push"); +} +``` + +`resolve_tool_requirements_with_diagnostics` adds per-input statuses, +dependency attribution, and structured warnings. 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/policy_store/README.md b/src/mxc-sdk/policy_store/README.md index d05789ce2..4dce2bd68 100644 --- a/src/mxc-sdk/policy_store/README.md +++ b/src/mxc-sdk/policy_store/README.md @@ -1,57 +1,47 @@ # 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 (for -> example, the names may drop "Sandbox"). It is not part of MXC 1.0; a later -> SDK release is targeted. - -The MXC policy store resolves known tools to a **best-effort floor** -`SandboxPolicy`: the access a known tool typically needs, which a caller -composes with its own policy. It is not a guarantee, and it is complementary -to Learning Mode rather than a replacement for it. - -The V1 catalog in `catalog/` is the single source of truth. `build/build_policy_store.rs` embeds -it with `include_str!`; nothing is downloaded. Each entry has one unversioned -default plus additive platform, version (purl `vers` ranges in the entry's -`versionScheme`), and intent overlays. A lookup takes tool candidates with an -optional `detectedVersion` and `intent`, and reports a per-input status -(`matched_default`, `matched_version`, `version_out_of_range`, -`version_unparseable`, `intent_unsupported`, or `tool_unmatched`). Every -MXC SDK uses this crate: +> 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::policy_store::{resolve_sandbox_policy, resolve_sandbox_policy_with_diagnostics, get_catalog_info, list_catalog_entries}` | -| Node (`@microsoft/mxc-sdk`) | `resolveSandboxPolicy`, `resolveSandboxPolicyWithDiagnostics`, `getCatalogInfo`, `listCatalogEntries` through `mxc_ffi` | -| C# (`Microsoft.Mxc.Sdk`) | `MxcPolicyStore.ResolveSandboxPolicy`, `MxcPolicyStore.ResolveSandboxPolicyWithDiagnostics`, `MxcPolicyStore.GetCatalogInfo`, `MxcPolicyStore.ListCatalogEntries` through `mxc_ffi` | - -This crate returns its own catalog-shaped policy model. `mxc-sdk` converts it -to `mxc_sdk::policy::SandboxPolicy`, and `mxc_ffi` hands the same JSON to the -Node and C# SDKs. +| 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/` | Contract, manifest, immutable published revisions (V1 data), and generated reviewer views (`views/`) | +| `catalog/` | Contract, manifest, immutable published revisions (V1 data), and generated views (`views/`: 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 | -| `src/` | Resolver, effective-policy builder, composition, `vers` schemes, validation, and path rules | -| `tests/` | Library, conformance, vector, and catalog contribution tests | ## Build and test From `src/`: ```sh -cargo test -p mxc_policy_store -cargo clippy -p mxc_policy_store --all-targets -- -D warnings +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: contract, -entry-revision history, every materialized effective policy, deterministic -resolution, conformance coverage, and a current reviewer view -(`MXC_POLICY_STORE_UPDATE_VIEWS=1` regenerates `catalog/views/`). +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. @@ -60,4 +50,4 @@ check that already-published revisions are unchanged. 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 design. +for the prototype's implementation notes. From 511651b3954575e8f0304bb29f66b3b6fe97399c Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Tue, 6 Oct 2026 16:00:34 -0700 Subject: [PATCH 45/50] policy store: one editable entry file per tool, assembled into revisions PROTOTYPE, pending API review. Implements the catalog source layout in design section 6.3: - catalog/entries/{git,node,npm,ssh}.json hold each tool's full entry: identity, default, intents, every platform and version variant, dependencies, and provenance. They were split from 2026-10-06.1 without any data change. - policy_store::assemble collects entry files recursively and assembles the revision in entryId order. It rejects duplicate entryIds and dependencies on entries that no file defines. Paths are used only in error messages. - It is a checked generator, following the existing reviewer-view pattern: default_revision_matches_the_entry_sources fails when revisions/.json is stale, and MXC_POLICY_STORE_UPDATE_REVISION=1 regenerates it. The regenerated 2026-10-06.1 snapshot is byte-identical to the existing one. - Tests cover a duplicate entryId, a dangling dependency (including nested in an overlay), path independence, and deterministic output. - The static, manifest-selected embedding and the public API are unchanged. - The docs describe the authoring workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 36 ++- docs/policy-store/design.md | 6 + src/mxc-sdk/policy_store/README.md | 7 +- .../policy_store/catalog/entries/git.json | 161 ++++++++++++ .../policy_store/catalog/entries/node.json | 28 ++ .../policy_store/catalog/entries/npm.json | 43 ++++ .../policy_store/catalog/entries/ssh.json | 28 ++ src/mxc-sdk/src/policy_store/assemble.rs | 240 ++++++++++++++++++ src/mxc-sdk/src/policy_store/mod.rs | 4 + .../tests/policy_store_catalog_validation.rs | 66 ++++- 10 files changed, 601 insertions(+), 18 deletions(-) create mode 100644 src/mxc-sdk/policy_store/catalog/entries/git.json create mode 100644 src/mxc-sdk/policy_store/catalog/entries/node.json create mode 100644 src/mxc-sdk/policy_store/catalog/entries/npm.json create mode 100644 src/mxc-sdk/policy_store/catalog/entries/ssh.json create mode 100644 src/mxc-sdk/src/policy_store/assemble.rs diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index af3424f56..7e59d52cd 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -134,26 +134,34 @@ Absence is not a failure: the resolve call yields no requirements. ## Changing the catalog -Published revisions are immutable. Never edit a file under -`src/mxc-sdk/policy_store/catalog/revisions/` that is already listed in -`catalog/manifest.json`; publish a new revision instead. - -1. Copy the latest revision to `catalog/revisions/.json` and set - its `catalogRevision`. -2. Make the change. 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. Append `{ catalogRevision, file }` to the manifest and point +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. Regenerate the views with `MXC_POLICY_STORE_UPDATE_VIEWS=1` - (`cargo test -p mxc-sdk --test policy_store_catalog_validation`) and - review them. -6. Run the checks (from `src/`): +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 diff --git a/docs/policy-store/design.md b/docs/policy-store/design.md index 6e8fb6f11..c024f418e 100644 --- a/docs/policy-store/design.md +++ b/docs/policy-store/design.md @@ -16,6 +16,12 @@ The [README](README.md) describes the API and the catalog workflow. (`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 spec §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 diff --git a/src/mxc-sdk/policy_store/README.md b/src/mxc-sdk/policy_store/README.md index 4dce2bd68..d76f9e3ad 100644 --- a/src/mxc-sdk/policy_store/README.md +++ b/src/mxc-sdk/policy_store/README.md @@ -24,7 +24,8 @@ build time; nothing is downloaded. | Path | Contents | |------|----------| -| `catalog/` | Contract, manifest, immutable published revisions (V1 data), and generated views (`views/`: reviewer view and exact MXC 1.0.0 requests) | +| `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 | @@ -38,7 +39,9 @@ cargo test -p mxc-sdk --test policy_store_library --test policy_store_conformanc cargo test -p mxc_ffi --test policy_store ``` -`tests/policy_store_catalog_validation.rs` is the contribution gate: contract, +`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/`). 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/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/mod.rs b/src/mxc-sdk/src/policy_store/mod.rs index cc8456c59..505891b75 100644 --- a/src/mxc-sdk/src/policy_store/mod.rs +++ b/src/mxc-sdk/src/policy_store/mod.rs @@ -25,6 +25,7 @@ //! `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; @@ -70,6 +71,9 @@ pub use store::{ /// 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, diff --git a/src/mxc-sdk/tests/policy_store_catalog_validation.rs b/src/mxc-sdk/tests/policy_store_catalog_validation.rs index 8f139649f..336bc8762 100644 --- a/src/mxc-sdk/tests/policy_store_catalog_validation.rs +++ b/src/mxc-sdk/tests/policy_store_catalog_validation.rs @@ -11,6 +11,8 @@ //! - 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 @@ -24,8 +26,8 @@ mod common; use common::{crate_dir, read_json}; use mxc_sdk::__policy_store::tooling::{ - canonical_json, render_exact_requests, render_reviewer_view, validate_bundled_catalog, - IdentityPredicate, Json, + 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, @@ -246,3 +248,63 @@ fn reviewer_views_are_current() { ); } } + +/// `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()) + ); +} From 1103de89cf83736179ce207a6096ac0e69b86dc3 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Wed, 7 Oct 2026 07:58:39 -0700 Subject: [PATCH 46/50] policy store: reconcile with spec 5c2ba8e (API contract split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit microsoft/mxc#1309 at 5c2ba8e splits the caller-facing contract into docs/mxc-policy-store-api.md and keeps the catalog design in docs/mxc-policy-store.md. Section numbers in the design doc are unchanged. Every API field, status, warning code, and error reason already matches. - Node: export PlatformVariantSelector and EgressRule, which the API spec now names as exported types, and use them in the catalog metadata and NetworkRequirement types. The shapes are unchanged. - Rust doc comments: point the old design §5 references at the matching API spec sections, and define both references in the module docs. - docs/policy-store README and design.md: link both spec documents at 5c2ba8e. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 8 +++++--- docs/policy-store/design.md | 9 +++++---- sdk/node/src/v1/index.ts | 2 ++ sdk/node/src/v1/tool-requirements.ts | 13 +++++++++++-- src/mxc-sdk/src/policy_store/mod.rs | 4 ++++ src/mxc-sdk/src/policy_store/model.rs | 16 ++++++++-------- src/mxc-sdk/src/policy_store/resolver.rs | 6 +++--- src/mxc-sdk/src/policy_store/sdk.rs | 2 +- 8 files changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index 7e59d52cd..334530dc2 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -15,9 +15,11 @@ 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 design spec is [`docs/mxc-policy-store.md` in - microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309); this - prototype tracks its head `f7a450c`. +- 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 diff --git a/docs/policy-store/design.md b/docs/policy-store/design.md index c024f418e..3eab87002 100644 --- a/docs/policy-store/design.md +++ b/docs/policy-store/design.md @@ -3,9 +3,10 @@ > **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 design spec is `docs/mxc-policy-store.md` in -[microsoft/mxc#1309](https://github.com/microsoft/mxc/pull/1309). This -prototype tracks spec head `f7a450c`. The spec is the source of truth; this +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. @@ -16,7 +17,7 @@ The [README](README.md) describes the API and the catalog workflow. (`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 spec §6.3: one editable file per tool in +- 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 diff --git a/sdk/node/src/v1/index.ts b/sdk/node/src/v1/index.ts index ad8785af7..38e9226b9 100644 --- a/sdk/node/src/v1/index.ts +++ b/sdk/node/src/v1/index.ts @@ -77,6 +77,8 @@ export type { ContainerRequirements, CatalogPlatform, CatalogArchitecture, + PlatformVariantSelector, + EgressRule, ToolCandidate, ToolInput, ResolveContext, diff --git a/sdk/node/src/v1/tool-requirements.ts b/sdk/node/src/v1/tool-requirements.ts index 858f57b5c..ac4dac5e8 100644 --- a/sdk/node/src/v1/tool-requirements.ts +++ b/sdk/node/src/v1/tool-requirements.ts @@ -43,6 +43,12 @@ 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 { /** @@ -170,9 +176,12 @@ export interface PathRequirement { entryIds: string[]; } +/** An outbound rule the composed requirements carry. */ +export type EgressRule = NetworkRuleConfig; + /** An egress rule with its source entries. */ export interface NetworkRequirement { - rule: NetworkRuleConfig; + rule: EgressRule; entryIds: string[]; } @@ -276,7 +285,7 @@ export interface CatalogEntryMetadata { intents: CatalogIntentMetadata[]; }; platformVariants: Array< - CatalogAdditionsMetadata & { platform: CatalogPlatform; architecture?: CatalogArchitecture } + CatalogAdditionsMetadata & PlatformVariantSelector >; versionVariants: Array; provenance: { method: string; sourceRevision: string }; diff --git a/src/mxc-sdk/src/policy_store/mod.rs b/src/mxc-sdk/src/policy_store/mod.rs index 505891b75..3e322613e 100644 --- a/src/mxc-sdk/src/policy_store/mod.rs +++ b/src/mxc-sdk/src/policy_store/mod.rs @@ -10,6 +10,10 @@ //! 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`], diff --git a/src/mxc-sdk/src/policy_store/model.rs b/src/mxc-sdk/src/policy_store/model.rs index 9612a252e..179d62ceb 100644 --- a/src/mxc-sdk/src/policy_store/model.rs +++ b/src/mxc-sdk/src/policy_store/model.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Public request and result types (design §5). Every result type renders to +//! 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`. @@ -70,7 +70,7 @@ impl fmt::Display for Architecture { } } -/// Runtime lookup input (design §5.1). `package_url` is the strong +/// 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`; @@ -238,7 +238,7 @@ impl, V: Into> FromIterator<(K, V)> for SymbolMap { } } -/// Runtime lookup context (design §5.1). `platform` and `architecture` are +/// 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)] @@ -597,7 +597,7 @@ impl ToolWarningKind { } } -/// A structured per-input warning (design §5.1 `ToolResolutionWarning`). +/// A structured per-input warning (API spec §2 `ToolResolutionWarning`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ToolResolutionWarning { pub input_index: usize, @@ -754,7 +754,7 @@ impl DetailWarningKind { } } -/// A structured warning about shared resolution (design §5.1 +/// A structured warning about shared resolution (API spec §2 /// `ResolutionDetailWarning`), scoped to sorted, distinct inputs and entries. #[derive(Clone, Debug, PartialEq)] pub struct ResolutionDetailWarning { @@ -994,7 +994,7 @@ impl Diagnostics { } } -/// Result of a diagnostics lookup (design §5.1 `ToolRequirementsResolution`) +/// Result of a diagnostics lookup (API spec §2 `ToolRequirementsResolution`) /// in the catalog's JSON model. #[derive(Clone, Debug, PartialEq)] pub struct RequirementsResolution { @@ -1014,7 +1014,7 @@ impl RequirementsResolution { } } -/// Inspection metadata for one identity predicate (design §5.2). +/// Inspection metadata for one identity predicate (API spec §2, inspection metadata). #[derive(Clone, Debug, PartialEq, Eq)] pub enum CatalogIdentityMetadata { Purl { value: String }, @@ -1176,7 +1176,7 @@ impl CatalogEntryMetadata { } } -/// Result of `get_catalog_info` (design §5.2). +/// Result of `get_catalog_info` (API spec §2, inspection metadata). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CatalogInfo { pub catalog_schema_version: String, diff --git a/src/mxc-sdk/src/policy_store/resolver.rs b/src/mxc-sdk/src/policy_store/resolver.rs index c111e6c56..197497a28 100644 --- a/src/mxc-sdk/src/policy_store/resolver.rs +++ b/src/mxc-sdk/src/policy_store/resolver.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Runtime lookup and inspection (design §4.3–§5.2): per input, match one +//! 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. @@ -174,7 +174,7 @@ impl PolicyCatalog { } // ----------------------------------------------------------------------- - // Setup and inspection (design §5.2) + // Setup and inspection (API spec §1) // ----------------------------------------------------------------------- pub fn get_catalog_info(&self) -> Result { @@ -243,7 +243,7 @@ impl PolicyCatalog { } // ----------------------------------------------------------------------- - // Runtime lookup (design §5.1) + // Runtime lookup (API spec §1, §3) // ----------------------------------------------------------------------- /// The composed requirements, or `None` when none can be resolved. diff --git a/src/mxc-sdk/src/policy_store/sdk.rs b/src/mxc-sdk/src/policy_store/sdk.rs index 8c28475ad..12d528fd8 100644 --- a/src/mxc-sdk/src/policy_store/sdk.rs +++ b/src/mxc-sdk/src/policy_store/sdk.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! The public v1 surface (design §5): typed [`ContainerRequirements`] over +//! 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 From fb776d43f64aa60d1dd4c4dbcd2916d9c1a73831 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Thu, 8 Oct 2026 14:53:25 -0700 Subject: [PATCH 47/50] policy store: align core and Rust API with spec 2c682f35 Per-input diagnostics become {inputIndex, contributes, status, selection?}; contributes is true only for pairs that added access. Rust entry points take a ToolCandidate slice and Option<&ResolveContext>, with From<&str> for bare names. projectRoot and symbols.project_root both bind project_root (equal values accepted, different values fail invalid_context), caller symbols report symbol_resolved, and ui/timeoutMs compose from a single source or fail composition_conflict. Conformance fixtures regenerated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ffi/mxc_ffi/tests/policy_store.rs | 6 +- src/mxc-sdk/README.md | 7 +- .../conformance/fixtures/bundled-catalog.json | 1938 +++++++++++------ .../fixtures/composition-catalog.json | 276 ++- .../fixtures/synthetic-catalog.json | 896 +++++--- src/mxc-sdk/src/lib.rs | 13 +- src/mxc-sdk/src/policy_store/compose.rs | 43 +- src/mxc-sdk/src/policy_store/mod.rs | 10 +- src/mxc-sdk/src/policy_store/model.rs | 78 +- src/mxc-sdk/src/policy_store/resolver.rs | 100 +- src/mxc-sdk/src/policy_store/sdk.rs | 20 +- .../tests/policy_store_catalog_validation.rs | 15 +- src/mxc-sdk/tests/policy_store_library.rs | 150 +- src/mxc-sdk/tests/policy_store_sdk.rs | 80 +- 14 files changed, 2416 insertions(+), 1216 deletions(-) diff --git a/src/ffi/mxc_ffi/tests/policy_store.rs b/src/ffi/mxc_ffi/tests/policy_store.rs index b61f46b0a..b644d2088 100644 --- a/src/ffi/mxc_ffi/tests/policy_store.rs +++ b/src/ffi/mxc_ffi/tests/policy_store.rs @@ -128,9 +128,10 @@ fn diagnostics_include_attribution() { assert_eq!(status, MXC_STATUS_SUCCESS); let json: serde_json::Value = serde_json::from_str(&json.unwrap()).unwrap(); assert_eq!( - json["diagnostics"]["tools"][0]["matches"][0]["entryId"], + json["diagnostics"]["tools"][0]["selection"]["entryId"], "tool:git" ); + assert_eq!(json["diagnostics"]["tools"][0]["contributes"], true); assert!(json["requirements"].is_object()); } @@ -149,7 +150,8 @@ fn intent_and_version_cross_the_boundary() { 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["matches"][0]["intentSelection"]["requested"], "fetch"); + 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); diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index 4d523cbc9..109ccd2f4 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -130,13 +130,16 @@ let git = ToolCandidate::new("git") .with_package_url("pkg:generic/git") .with_detected_version("2.45.1") .with_intent("push"); -if let Some(requirements) = resolve_tool_requirements(git, &ResolveContext::default())? { +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. The resolver is the internal +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 diff --git a/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json index 503a8c26e..bd81af257 100644 --- a/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json +++ b/src/mxc-sdk/policy_store/conformance/fixtures/bundled-catalog.json @@ -65,30 +65,29 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "fetch", - "local", - "push" - ] + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] } - ] + } } ], "resolvedDependencies": [], @@ -99,6 +98,45 @@ "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" } ] } @@ -135,26 +173,25 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:node", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -165,6 +202,19 @@ "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" } ] } @@ -204,30 +254,29 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:npm", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "mode": "all", - "selected": [] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -255,6 +304,58 @@ "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" } ] } @@ -331,99 +432,95 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "fetch", - "local", - "push" - ] + "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", - "matches": [ - { - "entryId": "tool:npm", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "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", - "matches": [ - { - "entryId": "tool:node", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "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", - "matches": [ - { - "entryId": "tool:npm", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -471,6 +568,78 @@ "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" } ] } @@ -540,35 +709,34 @@ "tools": [ { "inputIndex": 0, - "status": "tool_unmatched", - "matches": [] + "contributes": false, + "status": "tool_unmatched" }, { "inputIndex": 1, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "fetch", - "local", - "push" - ] + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] } - ] + } } ], "resolvedDependencies": [], @@ -585,6 +753,32 @@ "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" } ] } @@ -609,8 +803,8 @@ "tools": [ { "inputIndex": 0, - "status": "tool_unmatched", - "matches": [] + "contributes": false, + "status": "tool_unmatched" } ], "resolvedDependencies": [], @@ -658,30 +852,29 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "fetch", - "local", - "push" - ] + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "fetch", + "local", + "push" + ] } - ] + } } ], "resolvedDependencies": [], @@ -693,6 +886,19 @@ "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": [ @@ -802,53 +1008,51 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "fetch", - "local", - "push" - ] + "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", - "matches": [ - { - "entryId": "tool:node", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -867,6 +1071,45 @@ "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": [ @@ -952,37 +1195,63 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "push", - "mode": "named", - "selected": [ - "push" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] } - ] + } } ], "resolvedDependencies": [], - "warnings": [] + "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" + } + ] } } }, @@ -1041,35 +1310,34 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_version", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "push", - "mode": "named", - "selected": [ - "push" - ] + { + "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": [ @@ -1088,7 +1356,47 @@ } } ], - "warnings": [] + "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" + } + ] } } }, @@ -1147,39 +1455,78 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_version", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "bundle-fetch", - "mode": "named", - "selected": [ - "bundle-fetch" - ] + { + "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": [] + "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" + } + ] } } }, @@ -1207,33 +1554,32 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "intent_unsupported", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "bundle-fetch", - "mode": "unsupported", - "selected": [] + { + "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": [], @@ -1273,32 +1619,31 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "intent_unsupported", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "bundle-fetch", - "mode": "unsupported", - "selected": [] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "version_out_of_range", + "detectedVersion": "2.30.0" + }, + "intentSelection": { + "requested": "bundle-fetch", + "mode": "unsupported", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -1345,27 +1690,26 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "version_unparseable", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "version_unparseable", - "detectedVersion": "banana" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" + }, + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "version_unparseable", + "detectedVersion": "banana" } - ] + } } ], "resolvedDependencies": [], @@ -1461,41 +1805,80 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_version", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "mode": "all", - "selected": [ - "bundle-fetch", - "fetch", - "local", - "push" - ] + { + "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": [] + "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" + } + ] } } }, @@ -1532,37 +1915,76 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "local", - "mode": "named", - "selected": [ - "local" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] } - ] + } } ], "resolvedDependencies": [], - "warnings": [] + "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" + } + ] } } }, @@ -1645,98 +2067,95 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "local", - "mode": "named", - "selected": [ - "local" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] } - ] + } }, { "inputIndex": 1, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "fetch", - "mode": "named", - "selected": [ - "fetch" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "fetch", + "mode": "named", + "selected": [ + "fetch" + ] } - ] + } }, { "inputIndex": 2, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "push", - "mode": "named", - "selected": [ - "push" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "push", + "mode": "named", + "selected": [ + "push" + ] } - ] + } }, { "inputIndex": 3, - "status": "tool_unmatched", - "matches": [] + "contributes": false, + "status": "tool_unmatched" } ], "resolvedDependencies": [], @@ -1746,6 +2165,36 @@ "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" } ] } @@ -1807,31 +2256,30 @@ "tools": [ { "inputIndex": 0, - "status": "tool_unmatched", - "matches": [] + "contributes": false, + "status": "tool_unmatched" }, { "inputIndex": 1, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:node", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -1848,6 +2296,19 @@ "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" } ] } @@ -1884,33 +2345,32 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "local", - "mode": "named", - "selected": [ - "local" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] } - ] + } } ], "resolvedDependencies": [], @@ -1925,6 +2385,32 @@ "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" } ] } @@ -1969,30 +2455,29 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:npm", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:npm", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "mode": "all", - "selected": [] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -2025,6 +2510,19 @@ "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": [ @@ -2096,79 +2594,76 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "filesystem_identity_unresolved", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "local", - "mode": "named", - "selected": [ - "local" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] } - ] + } }, { "inputIndex": 1, + "contributes": false, "status": "filesystem_identity_unresolved", - "matches": [ - { - "entryId": "tool:node", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "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", - "matches": [ - { - "entryId": "tool:ssh", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:ssh", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -2187,6 +2682,58 @@ "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": [ @@ -2254,56 +2801,54 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "local", - "mode": "named", - "selected": [ - "local" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "local", + "mode": "named", + "selected": [ + "local" + ] } - ] + } }, { "inputIndex": 1, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:node", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:node", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -2315,6 +2860,45 @@ "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": [ @@ -2374,37 +2958,75 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "filesystem_identity_unresolved", - "matches": [ - { - "entryId": "tool:git", - "entryRevision": 3, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:git", + "entryRevision": 3, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "local", - "mode": "named", - "selected": [ - "local" - ] + { + "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": [ diff --git a/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json index 5107aaefe..dadfbeb7e 100644 --- a/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json +++ b/src/mxc-sdk/policy_store/conformance/fixtures/composition-catalog.json @@ -247,26 +247,25 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:fetcher", - "entryRevision": 1, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:fetcher", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -294,6 +293,20 @@ "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" } ] } @@ -352,26 +365,25 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:locker", - "entryRevision": 1, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:locker", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -397,6 +409,20 @@ "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" } ] } @@ -468,52 +494,50 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:helper", - "entryRevision": 1, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "store", - "sync" - ] + "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", - "matches": [ - { - "entryId": "tool:fetcher", - "entryRevision": 1, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:fetcher", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -548,6 +572,34 @@ "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" } ] } @@ -632,49 +684,47 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:fetcher", - "entryRevision": 1, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "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", - "matches": [ - { - "entryId": "tool:locker", - "entryRevision": 1, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:locker", + "entryRevision": 1, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [ @@ -724,6 +774,22 @@ "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": [ diff --git a/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json b/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json index 2de347f57..81506360f 100644 --- a/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json +++ b/src/mxc-sdk/policy_store/conformance/fixtures/synthetic-catalog.json @@ -283,35 +283,34 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_version", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "build", - "mode": "named", - "selected": [ - "build" - ] + { + "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": [ @@ -345,7 +344,35 @@ } } ], - "warnings": [] + "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" + } + ] } } }, @@ -402,33 +429,32 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "mode": "all", - "selected": [ - "build", - "serve" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "build", + "serve" + ] } - ] + } } ], "resolvedDependencies": [ @@ -460,6 +486,33 @@ "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" } ] } @@ -519,34 +572,33 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "version_out_of_range", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "serve", - "mode": "named", - "selected": [ - "serve" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "version_out_of_range", + "detectedVersion": "1.0.0" + }, + "intentSelection": { + "requested": "serve", + "mode": "named", + "selected": [ + "serve" + ] } - ] + } } ], "resolvedDependencies": [ @@ -585,6 +637,32 @@ "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" } ] } @@ -613,27 +691,26 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "version_unparseable", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "version_unparseable", - "detectedVersion": "not a version" + "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": [], @@ -684,35 +761,34 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_version", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "publish", - "mode": "named", - "selected": [ - "publish" - ] + { + "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": [ @@ -744,6 +820,45 @@ "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" } ] } @@ -772,33 +887,32 @@ "tools": [ { "inputIndex": 0, + "contributes": false, "status": "intent_unsupported", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "publish", - "mode": "unsupported", - "selected": [] + { + "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": [], @@ -881,35 +995,34 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_version", - "matches": [ - { - "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" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "serve", - "mode": "named", - "selected": [ - "serve" - ] + { + "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": [ @@ -956,6 +1069,32 @@ "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" } ] } @@ -995,33 +1134,32 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "build", - "mode": "named", - "selected": [ - "build" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] } - ] + } } ], "resolvedDependencies": [ @@ -1062,6 +1200,33 @@ "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" } ] } @@ -1112,26 +1277,25 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:base", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:base", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -1154,6 +1318,19 @@ "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" } ] } @@ -1187,26 +1364,25 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:base", - "entryRevision": 2, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [] + "selection": { + "entryId": "tool:base", + "entryRevision": 2, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [] } - ] + } } ], "resolvedDependencies": [], @@ -1217,6 +1393,19 @@ "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" } ] } @@ -1272,29 +1461,28 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" - }, - "intentSelection": { - "mode": "all", - "selected": [ - "build", - "serve" - ] + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "mode": "all", + "selected": [ + "build", + "serve" + ] } - ] + } } ], "resolvedDependencies": [ @@ -1320,6 +1508,33 @@ "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" } ] } @@ -1344,8 +1559,8 @@ "tools": [ { "inputIndex": 0, - "status": "tool_unmatched", - "matches": [] + "contributes": false, + "status": "tool_unmatched" } ], "resolvedDependencies": [], @@ -1421,63 +1636,61 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "serve", - "mode": "named", - "selected": [ - "serve" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "serve", + "mode": "named", + "selected": [ + "serve" + ] } - ] + } }, { "inputIndex": 1, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "build", - "mode": "named", - "selected": [ - "build" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] } - ] + } } ], "resolvedDependencies": [ @@ -1511,6 +1724,35 @@ "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" } ] } @@ -1545,33 +1787,32 @@ "tools": [ { "inputIndex": 0, + "contributes": true, "status": "matched_default", - "matches": [ - { - "entryId": "tool:app", - "entryRevision": 5, - "matchedIdentities": [ - { - "kind": "purl", - "strength": "strong" - }, - { - "kind": "invocation-name", - "strength": "weak" - } - ], - "versionSelection": { - "status": "matched_default" + "selection": { + "entryId": "tool:app", + "entryRevision": 5, + "matchedIdentities": [ + { + "kind": "purl", + "strength": "strong" }, - "intentSelection": { - "requested": "build", - "mode": "named", - "selected": [ - "build" - ] + { + "kind": "invocation-name", + "strength": "weak" } + ], + "versionSelection": { + "status": "matched_default" + }, + "intentSelection": { + "requested": "build", + "mode": "named", + "selected": [ + "build" + ] } - ] + } } ], "resolvedDependencies": [ @@ -1604,6 +1845,33 @@ "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": [ @@ -1709,8 +1977,8 @@ "tools": [ { "inputIndex": 0, - "status": "tool_unmatched", - "matches": [] + "contributes": false, + "status": "tool_unmatched" } ], "resolvedDependencies": [], diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index 87905e68c..12bd0e0d1 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -303,13 +303,12 @@ pub mod v1 { pub use crate::policy_store::{ Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, - DependencyRecord, DetailWarningKind, Diagnostics, EntryMatchRecord, IdentityStrength, - IntentMode, IntentSelection, MatchedIdentity, NetworkRequirement, PathAccess, - PathRequirement, Platform, PlatformVariantMetadata, Provenance, PurlComponent, - ResolutionDetailWarning, ResolveContext, SymbolMap, SymbolValueSource, ToolCandidate, - ToolInput, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, - ToolWarningKind, VersionScheme, VersionSelection, VersionStatus, - VersionVariantMetadata, Warning, + 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, }; } diff --git a/src/mxc-sdk/src/policy_store/compose.rs b/src/mxc-sdk/src/policy_store/compose.rs index 26c112f0c..8f6e8a0b8 100644 --- a/src/mxc-sdk/src/policy_store/compose.rs +++ b/src/mxc-sdk/src/policy_store/compose.rs @@ -158,7 +158,7 @@ pub fn compose_check(contributions: &[Contribution<'_>]) -> Result<(), String> { } for group in &groups { for key in group.base.into_iter().flat_map(|b| b.raw().keys()) { - if !matches!(key, "filesystem" | "network") { + if !matches!(key, "filesystem" | "network" | "ui" | "timeoutMs") { return Err(format!( "'{}' uses '{key}', which has no v1 cross-policy composition rule", group.entry_id @@ -166,6 +166,21 @@ pub fn compose_check(contributions: &[Contribution<'_>]) -> Result<(), String> { } } } + // 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(()); } @@ -197,6 +212,16 @@ pub fn compose_check(contributions: &[Contribution<'_>]) -> Result<(), String> { 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 { @@ -535,23 +560,15 @@ pub fn compose( }); let groups = groups(contributions); - let passthrough = is_passthrough(&groups); - let root = groups.first().and_then(|g| g.base); let network = compose_network(contributions, &groups, &mut warnings); Composed { requirements: Requirements { filesystem, network, - ui: passthrough - .then(|| root.and_then(|r| r.field("ui").cloned())) - .flatten(), - timeout_ms: passthrough - .then(|| { - root.and_then(|r| r.field("timeoutMs")) - .and_then(Json::as_f64) - .map(|n| n as u32) - }) - .flatten(), + 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, diff --git a/src/mxc-sdk/src/policy_store/mod.rs b/src/mxc-sdk/src/policy_store/mod.rs index 3e322613e..7c575372a 100644 --- a/src/mxc-sdk/src/policy_store/mod.rs +++ b/src/mxc-sdk/src/policy_store/mod.rs @@ -57,11 +57,11 @@ pub use host::{FixedHost, HostEnvironment, SystemHost}; pub use model::{ Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, DependencyRecord, - DetailWarningKind, Diagnostics, EntryMatchRecord, FilesystemRequirements, IdentityStrength, - IntentMode, IntentSelection, MatchedIdentity, NetworkRequirement, PathAccess, PathRequirement, - Platform, PlatformVariantMetadata, Provenance, PurlComponent, Requirements, - RequirementsResolution, ResolutionDetailWarning, ResolveContext, SymbolMap, SymbolValueSource, - ToolCandidate, ToolInput, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, + 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::{ diff --git a/src/mxc-sdk/src/policy_store/model.rs b/src/mxc-sdk/src/policy_store/model.rs index 179d62ceb..5336ff189 100644 --- a/src/mxc-sdk/src/policy_store/model.rs +++ b/src/mxc-sdk/src/policy_store/model.rs @@ -107,6 +107,18 @@ impl ToolCandidate { } } +/// 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 { @@ -181,6 +193,12 @@ impl From<&[ToolInput]> for ToolInputs { } } +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()) @@ -493,8 +511,10 @@ pub struct MatchedIdentity { 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 EntryMatchRecord { +pub struct ToolSelection { pub entry_id: String, pub entry_revision: f64, pub matched_identities: Vec, @@ -506,9 +526,13 @@ pub struct EntryMatchRecord { #[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, - /// At most one entry; empty for `tool_unmatched`. - pub matches: Vec, + /// The selected entry; `None` when no entry was selected. + pub selection: Option, } #[derive(Clone, Debug, PartialEq)] @@ -938,33 +962,29 @@ impl Diagnostics { .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()); - let matches = tool - .matches - .iter() - .map(|m| { - 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()); - } - Json::Object(o) - }) - .collect(); - record.insert("matches", Json::Array(matches)); + 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(); diff --git a/src/mxc-sdk/src/policy_store/resolver.rs b/src/mxc-sdk/src/policy_store/resolver.rs index 197497a28..2c238d89f 100644 --- a/src/mxc-sdk/src/policy_store/resolver.rs +++ b/src/mxc-sdk/src/policy_store/resolver.rs @@ -25,11 +25,11 @@ use crate::policy_store::json::cmp_utf16; use crate::policy_store::model::{ Architecture, ArchitectureFallback, CatalogAdditionsMetadata, CatalogEntryMetadata, CatalogIdentityMetadata, CatalogInfo, CatalogIntentMetadata, DefaultMetadata, - DetailWarningKind, Diagnostics, EntryMatchRecord, IntentMode, IntentSelection, MatchedIdentity, - Platform, PlatformVariantMetadata, Provenance, PurlComponent, Requirements, - RequirementsResolution, ResolutionDetailWarning, ResolveContext, SymbolValueSource, - ToolCandidate, ToolInputs, ToolRecord, ToolResolutionStatus, ToolResolutionWarning, - ToolWarningKind, VersionSelection, VersionStatus, VersionVariantMetadata, Warning, + 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}; @@ -300,8 +300,9 @@ impl PolicyCatalog { let unmatched = |records: &mut Vec| { records.push(ToolRecord { input_index, + contributes: false, status: ToolResolutionStatus::ToolUnmatched, - matches: Vec::new(), + selection: None, }); }; // Identity (design §4.3): an invalid candidate PURL leaves the @@ -406,7 +407,7 @@ impl PolicyCatalog { strength: p.strength(), }) .collect(); - let mut record = EntryMatchRecord { + let mut record = ToolSelection { entry_id: entry.entry_id.clone(), entry_revision: entry.entry_revision, matched_identities, @@ -439,10 +440,11 @@ impl PolicyCatalog { )); tool_records.push(ToolRecord { input_index, + contributes: false, status: ToolResolutionStatus::Version( VersionStatus::VersionUnparseable, ), - matches: vec![record], + selection: Some(record), }); continue; } @@ -513,8 +515,9 @@ impl PolicyCatalog { )); tool_records.push(ToolRecord { input_index, + contributes: false, status: ToolResolutionStatus::IntentUnsupported, - matches: vec![record], + selection: Some(record), }); continue; }; @@ -528,10 +531,12 @@ impl PolicyCatalog { 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, - matches: vec![record], + selection: Some(record), }); match roots.iter_mut().find(|(e, _, _)| std::ptr::eq(*e, entry)) { Some((_, _, owners)) => owners.push(input_index), @@ -547,6 +552,10 @@ impl PolicyCatalog { 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, @@ -642,6 +651,10 @@ impl PolicyCatalog { &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 { @@ -692,6 +705,7 @@ impl PolicyCatalog { for record in &mut diagnostics.tools { if excluded.contains(&record.input_index) { record.status = ToolResolutionStatus::FilesystemIdentityUnresolved; + record.contributes = false; } } diagnostics.resolved_dependencies = closure @@ -715,6 +729,12 @@ impl PolicyCatalog { Some(composed.requirements) } }; + if requirements.is_none() { + diagnostics + .tools + .iter_mut() + .for_each(|t| t.contributes = false); + } diagnostics.warnings = warnings; Ok(RequirementsResolution { requirements, @@ -843,16 +863,27 @@ impl PolicyCatalog { )); } let contract = self.store.contract(); - for (name, _) in ctx.symbols.iter().flat_map(|s| s.iter()) { + 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 { - return Err(invalid_context(format!( - "symbol '{name}' is supplied through ResolveContext.projectRoot, not symbols" - ))); + 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)) @@ -887,6 +918,7 @@ impl PolicyCatalog { 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)) @@ -936,26 +968,26 @@ impl PolicyCatalog { source.as_str() ))); } - if source != SymbolValueSource::Caller { - 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", - } - ), - )); - } + // 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() { diff --git a/src/mxc-sdk/src/policy_store/sdk.rs b/src/mxc-sdk/src/policy_store/sdk.rs index 12d528fd8..7e8ce27b3 100644 --- a/src/mxc-sdk/src/policy_store/sdk.rs +++ b/src/mxc-sdk/src/policy_store/sdk.rs @@ -13,7 +13,7 @@ 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, ToolInputs, + CatalogEntryMetadata, CatalogInfo, Diagnostics, ResolveContext, ToolCandidate, }; use crate::policy_store::resolver; @@ -49,21 +49,27 @@ fn typed( /// 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: impl Into, - ctx: &ResolveContext, + tools: &[ToolCandidate], + context: Option<&ResolveContext>, ) -> Result, Error> { - typed(resolver::resolve_requirements(tools, ctx).map_err(to_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: impl Into, - ctx: &ResolveContext, + tools: &[ToolCandidate], + context: Option<&ResolveContext>, ) -> Result { + let default = ResolveContext::default(); let resolution = - resolver::resolve_requirements_with_diagnostics(tools, ctx).map_err(to_error)?; + resolver::resolve_requirements_with_diagnostics(tools, context.unwrap_or(&default)) + .map_err(to_error)?; Ok(ToolRequirementsResolution { requirements: typed(resolution.requirements)?, diagnostics: resolution.diagnostics, diff --git a/src/mxc-sdk/tests/policy_store_catalog_validation.rs b/src/mxc-sdk/tests/policy_store_catalog_validation.rs index 336bc8762..e127c48f9 100644 --- a/src/mxc-sdk/tests/policy_store_catalog_validation.rs +++ b/src/mxc-sdk/tests/policy_store_catalog_validation.rs @@ -133,10 +133,10 @@ fn every_entry_resolves_deterministically_through_its_own_identity() { )); } else if first.to_json() != second.to_json() { failures.push(format!("{label} did not resolve deterministically")); - } else if !first.diagnostics.tools[0] - .matches - .iter() - .any(|m| m.entry_id == entry.entry_id) + } 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")); @@ -182,12 +182,7 @@ fn every_entry_has_a_bundled_conformance_case() { .and_then(|d| d.get("tools")) .and_then(Json::as_array); for tool in tools.into_iter().flatten() { - for m in tool - .get("matches") - .and_then(Json::as_array) - .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()); } diff --git a/src/mxc-sdk/tests/policy_store_library.rs b/src/mxc-sdk/tests/policy_store_library.rs index 5361cb833..d376a8dd0 100644 --- a/src/mxc-sdk/tests/policy_store_library.rs +++ b/src/mxc-sdk/tests/policy_store_library.rs @@ -133,7 +133,12 @@ fn explicit_architecture_wins_and_suppresses_the_host_default_warning() { fs_json(&result.requirements), j(r#"{"readonlyPaths":["C:\\g\\x64"],"readwritePaths":["C:\\p"]}"#) ); - assert!(result.diagnostics.warnings.is_empty()); + // Only the caller-supplied symbols are reported; no architecture warning. + assert!(result + .diagnostics + .warnings + .iter() + .all(|w| w.code() == "symbol_resolved")); } #[test] @@ -167,8 +172,8 @@ fn another_architecture_is_never_used_and_neutral_additions_are_reported() { .warnings .iter() .filter_map(|w| match w { - Warning::Detail(d) => Some(d.kind.clone()), - Warning::Tool(_) => None, + Warning::Detail(d) if w.code() != "symbol_resolved" => Some(d.kind.clone()), + _ => None, }) .collect() }; @@ -271,12 +276,45 @@ fn never_fabricates_project_root_or_caller_symbols() { .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( @@ -437,7 +475,19 @@ fn invalid_context_and_inputs_are_failures_not_absence() { ("git".into(), ResolveContext::new().symbol("nope", "/x")), ( "git".into(), - ResolveContext::new().symbol("project_root", "/x"), + 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(), @@ -589,7 +639,9 @@ fn package_identity_then_intent_then_architecture_and_ties_are_ambiguous() { .unwrap() .diagnostics .tools[0] - .matches[0] + .selection + .as_ref() + .unwrap() .entry_id .clone() }; @@ -628,7 +680,14 @@ fn exact_architecture_outranks_neutral_and_default() { let result = catalog .resolve_requirements_with_diagnostics("x", &weak().project_root("/p")) .unwrap(); - assert_eq!(result.diagnostics.tools[0].matches[0].entry_id, "tool:b"); + assert_eq!( + result.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() + .entry_id, + "tool:b" + ); } #[test] @@ -681,7 +740,7 @@ fn resolve_git(tools: impl Into) -> RequirementsResolution { #[test] fn version_selection_statuses() { let none = resolve_git(git(None, Some("push"))); - let record = &none.diagnostics.tools[0].matches[0]; + let record = &none.diagnostics.tools[0].selection.as_ref().unwrap(); assert_eq!( none.diagnostics.tools[0].status, ToolResolutionStatus::Version(VersionStatus::MatchedDefault) @@ -690,7 +749,7 @@ fn version_selection_statuses() { assert!(none.diagnostics.resolved_dependencies.is_empty()); let inside = resolve_git(git(Some("2.45.1"), Some("push"))); - let record = &inside.diagnostics.tools[0].matches[0]; + let record = &inside.diagnostics.tools[0].selection.as_ref().unwrap(); assert_eq!( record.version_selection.status, VersionStatus::MatchedVersion @@ -736,13 +795,23 @@ fn version_selection_statuses() { bad.diagnostics.tools[0].status, ToolResolutionStatus::Version(VersionStatus::VersionUnparseable) ); - assert_eq!(bad.diagnostics.tools[0].matches[0].intent_selection, None); + 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].matches[0] + let selection = all.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() .intent_selection .clone() .unwrap(); @@ -772,11 +841,17 @@ fn intent_selection_and_unsupported_intents() { let tool = &unsupported.diagnostics.tools[0]; assert_eq!(tool.status, ToolResolutionStatus::IntentUnsupported); assert_eq!( - tool.matches[0].version_selection.status, + tool.selection.as_ref().unwrap().version_selection.status, VersionStatus::MatchedVersion ); assert_eq!( - tool.matches[0].intent_selection.as_ref().unwrap().mode, + tool.selection + .as_ref() + .unwrap() + .intent_selection + .as_ref() + .unwrap() + .mode, IntentMode::Unsupported ); assert_eq!(unsupported.requirements, None); @@ -785,7 +860,7 @@ fn intent_selection_and_unsupported_intents() { let tool = &both.diagnostics.tools[0]; assert_eq!(tool.status, ToolResolutionStatus::IntentUnsupported); assert_eq!( - tool.matches[0].version_selection.status, + tool.selection.as_ref().unwrap().version_selection.status, VersionStatus::VersionOutOfRange ); let codes: Vec<&str> = both @@ -817,6 +892,16 @@ fn pairs_compose_and_a_tool_without_network_does_not_veto_another() { 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 @@ -1040,6 +1125,9 @@ fn unsupported_combinations_fail_rather_than_broaden() { ), ), 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"}}}"#, ""), @@ -1064,10 +1152,22 @@ fn unsupported_combinations_fail_rather_than_broaden() { reason_of(catalog.resolve_requirements(vec!["i", "n"], &weak())), ErrorReason::CompositionConflict ); + // One source's ui/timeoutMs is kept; omission elsewhere adds nothing. assert_eq!( - reason_of(catalog.resolve_requirements(vec!["u", "b"], &weak())), - ErrorReason::CompositionConflict + 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 @@ -1137,7 +1237,7 @@ fn module_level_functions_use_the_bundled_catalog() { assert_eq!(result.requirements, None); assert_eq!( result.diagnostics.to_json().get("tools").unwrap(), - &j(r#"[{"inputIndex":0,"status":"tool_unmatched","matches":[]}]"#) + &j(r#"[{"inputIndex":0,"contributes":false,"status":"tool_unmatched"}]"#) ); assert_eq!( reason_of(resolve_requirements( @@ -1469,11 +1569,19 @@ fn validation_materializes_composition_limits() { }; err( vec![ - entry("tool:a", &dep("tool:b")), + entry( + "tool:a", + r#"{"default":{"requirements":{"timeoutMs":5},"dependencies":[{"entryId":"tool:b"}]}}"#, + ), entry("tool:b", &with_default(r#"{"timeoutMs":5}"#, "")), ], - "'tool:b' uses 'timeoutMs', which has no v1 cross-policy composition rule", + "'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", @@ -1781,7 +1889,11 @@ fn explicit_revisions_are_never_substituted() { .unwrap(); ( r.diagnostics.catalog_revision.clone(), - r.diagnostics.tools[0].matches[0].entry_revision, + r.diagnostics.tools[0] + .selection + .as_ref() + .unwrap() + .entry_revision, ) }; assert_eq!(of(&ctx), ("2000-01-02.1".into(), 2.0)); diff --git a/src/mxc-sdk/tests/policy_store_sdk.rs b/src/mxc-sdk/tests/policy_store_sdk.rs index 14f8ea6b1..e6809149d 100644 --- a/src/mxc-sdk/tests/policy_store_sdk.rs +++ b/src/mxc-sdk/tests/policy_store_sdk.rs @@ -4,7 +4,9 @@ //! The public `mxc_sdk::v1` tool-requirements surface: error mapping, //! catalog metadata, and command-free requirements. -use mxc_sdk::v1::tool_requirements::{ResolveContext, ToolCandidate, ToolResolutionStatus}; +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, @@ -12,22 +14,78 @@ use mxc_sdk::v1::{ #[test] fn invalid_context_maps_to_malformed_request() { - let error = resolve_tool_requirements("/usr/bin/git", &ResolveContext::new()).unwrap_err(); + let error = resolve_tool_requirements(&["/usr/bin/git".into()], None).unwrap_err(); assert_eq!(error.code, ErrorCode::MalformedRequest); } #[test] -fn unmatched_tools_resolve_to_none_with_status() { - let result = resolve_tool_requirements_with_diagnostics( - ToolCandidate::new("no-such-tool-xyz"), - &ResolveContext::new(), - ) - .unwrap(); - assert!(result.requirements.is_none()); +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!( - result.diagnostics.tools[0].status, - ToolResolutionStatus::ToolUnmatched + 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] From 61c618a177d3bc734c114cc947a8ad714b609d4e Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Thu, 8 Oct 2026 14:53:26 -0700 Subject: [PATCH 48/50] node sdk: diagnostics contributes/selection and boundary validation Map the 2c682f35 per-input diagnostics shape and reject malformed resolution shapes at the SDK boundary with backend_error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/node/src/v1/tool-requirements.ts | 81 +++++++++++++++++++++++- sdk/node/tests/unit/policy-store.test.ts | 80 +++++++++++++++++++++-- 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/sdk/node/src/v1/tool-requirements.ts b/sdk/node/src/v1/tool-requirements.ts index ac4dac5e8..13f8a19a6 100644 --- a/sdk/node/src/v1/tool-requirements.ts +++ b/sdk/node/src/v1/tool-requirements.ts @@ -29,6 +29,7 @@ */ 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. */ @@ -220,17 +221,28 @@ export type PolicyResolutionWarning = ToolResolutionWarning | ResolutionDetailWa 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; - /** At most one entry; empty for `tool_unmatched`. */ - matches: Array<{ + /** + * 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; @@ -351,9 +363,72 @@ export async function resolveToolRequirementsWithDiagnostics( '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. diff --git a/sdk/node/tests/unit/policy-store.test.ts b/sdk/node/tests/unit/policy-store.test.ts index ff253756d..a268b66c8 100644 --- a/sdk/node/tests/unit/policy-store.test.ts +++ b/sdk/node/tests/unit/policy-store.test.ts @@ -18,6 +18,7 @@ import { 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. @@ -128,8 +129,9 @@ describe('policy store (prototype)', { skip }, () => { assert.ok(withOptIn.requirements !== undefined); const [tool] = withOptIn.diagnostics.tools; assert.strictEqual(tool.inputIndex, 0); - assert.strictEqual(tool.matches[0].entryId, 'tool:git'); - assert.strictEqual(tool.matches[0].matchedIdentities[0].strength, 'weak'); + 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'); @@ -140,10 +142,10 @@ describe('policy store (prototype)', { skip }, () => { const [tool] = push.diagnostics.tools; assert.strictEqual(tool.status, 'matched_version'); assert.strictEqual( - tool.matches[0].versionSelection.selectedVersionRange, + tool.selection?.versionSelection.selectedVersionRange, 'vers:intdot/>=2.40|<2.50', ); - assert.deepStrictEqual(tool.matches[0].intentSelection, { + assert.deepStrictEqual(tool.selection?.intentSelection, { requested: 'push', mode: 'named', selected: ['push'], @@ -169,6 +171,9 @@ describe('policy store (prototype)', { skip }, () => { 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'); @@ -201,6 +206,15 @@ describe('policy store (prototype)', { skip }, () => { ); 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 () => { @@ -218,4 +232,62 @@ describe('policy store (prototype)', { skip }, () => { (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); + }); }); From 2a279c263a7bac7bd85cd7c22e6336b10eb573ff Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Thu, 8 Oct 2026 14:53:26 -0700 Subject: [PATCH 49/50] .NET sdk: ToolCandidate class, ToolSelection, diagnostics validation ToolCandidate becomes a sealed class per the spec (null name throws ArgumentNullException, implicit conversion from string). ToolDiagnostics carries required Contributes and optional Selection; malformed results fail with backend_error. AOT smoke covers the new shapes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs | 45 +++++++++++ .../V1/ToolRequirementsTests.cs | 80 +++++++++++++++++-- .../V1/MxcContainer.ToolRequirements.cs | 48 +++++++++++ .../Microsoft.Mxc.Sdk/V1/ToolRequirements.cs | 49 +++++++++--- 4 files changed, 207 insertions(+), 15 deletions(-) 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/V1/ToolRequirementsTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs index ba2b2a0d5..7e2731430 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/ToolRequirementsTests.cs @@ -118,8 +118,9 @@ public void WeakIdentity_RequiresTheOptIn() Assert.NotNull(resolution.Requirements); var tool = Assert.Single(resolution.Diagnostics.Tools); Assert.Equal(0, tool.InputIndex); - Assert.Equal("tool:git", tool.Matches[0].EntryId); - Assert.Equal("weak", tool.Matches[0].MatchedIdentities[0].Strength); + 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); @@ -135,10 +136,10 @@ public async Task VersionAndIntent_SelectARangeAndAttributeDependencies() var tool = Assert.Single(push.Diagnostics.Tools); Assert.Equal("matched_version", tool.Status); - Assert.Equal("vers:intdot/>=2.40|<2.50", tool.Matches[0].VersionSelection.SelectedVersionRange); - Assert.Equal("named", tool.Matches[0].IntentSelection!.Mode); - Assert.Equal("push", tool.Matches[0].IntentSelection!.Requested); - Assert.Equal(new[] { "push" }, tool.Matches[0].IntentSelection!.Selected); + 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); @@ -161,6 +162,8 @@ public void PerPairOutcomes_CarryStructuredWarnings() 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); @@ -193,6 +196,71 @@ public void Composition_AToolWithoutNetworkDoesNotVetoAnother() 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] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs index 8a7dd3ff5..ec48708e7 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.ToolRequirements.cs @@ -289,11 +289,59 @@ internal static ToolRequirementsResolution ParseToolRequirementsResolution( 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 diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs index 629bf7c0b..d738c239c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ToolRequirements.cs @@ -63,12 +63,28 @@ public static class CatalogArchitectures /// PROTOTYPE, pending API review. A tool to look up. A plain string /// converts implicitly to a candidate with only . /// -/// -/// The bare invocation name, for example git. A weak identity, used only -/// when is set. -/// -public sealed record ToolCandidate(string InvocationName) +/// +/// 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 @@ -149,8 +165,11 @@ public sealed record IntentSelection(string Mode, IReadOnlyList Selected public string? Requested { get; init; } } -/// The entry matched by one input. -public sealed record ToolMatch( +/// +/// 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, @@ -163,8 +182,20 @@ public sealed record ToolMatch( /// The pair's version status, or intent_unsupported, /// tool_unmatched, or filesystem_identity_unresolved. /// -/// At most one entry; empty for tool_unmatched. -public sealed record ToolDiagnostics(int InputIndex, string Status, IReadOnlyList Matches); +/// +/// 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. From 3bb563dafdd50952ad28f2930b9a1328505f2ff1 Mon Sep 17 00:00:00 2001 From: "Chaz Gordish (Agent)" Date: Thu, 8 Oct 2026 14:53:27 -0700 Subject: [PATCH 50/50] docs: policy store diagnostics, context binding, and composition rules Document the per-input diagnostics shape, projectRoot/symbols.project_root binding, single-source ui/timeoutMs, and the slice-based Rust signature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/policy-store/README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/policy-store/README.md b/docs/policy-store/README.md index 334530dc2..484b96d54 100644 --- a/docs/policy-store/README.md +++ b/docs/policy-store/README.md @@ -56,7 +56,7 @@ const { requirements, diagnostics } = await resolveToolRequirementsWithDiagnosti { 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].status === 'matched_version' +// 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' }; @@ -98,6 +98,17 @@ and `intent`), or a bare name. | 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 @@ -105,7 +116,10 @@ and `intent`), or a bare name. - 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. Alias paths are preserved as written. Paths whose filesystem + 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.