diff --git a/packages/core/scripts/sync-task-surface.mjs b/packages/core/scripts/sync-task-surface.mjs index 65661a8..68b014a 100644 --- a/packages/core/scripts/sync-task-surface.mjs +++ b/packages/core/scripts/sync-task-surface.mjs @@ -16,6 +16,30 @@ // silent failure — `tests/task-surface.mjs` reports the version it is checking // against, and a task this library targets that the SDK has since renamed or // deprecated fails the test rather than the deployment. +// +// ## Not every constant is a literal, and the ones that are not were vanishing +// +// `vta-sdk` increasingly derives a task constant from the generated payload +// type rather than writing the URI out: +// +// pub const JOIN_REQUEST_MANIFEST_TYPE: &str = +// ::TYPE_URI; +// +// There is no string to match, so the scan missed it and the task simply was +// not in the snapshot — and `tests/task-surface.mjs` then reports it as a URI +// this library names that **the SDK does not have**, which is the sentence it +// uses for a typo or a task that was dropped. That is the worst possible +// wording for a scanner limitation: it is indistinguishable from a real +// version-left-behind, and the one it was mistaken for +// (`vtc/join-requests/manifest/0.1`) is still exported by the SDK and still +// dispatched by vtc-service. +// +// So the resolver below follows the `pub use trust_tasks_rs::specs::…` aliases +// and reconstructs the URI from the module path. And where it **cannot** +// resolve one, this script now fails rather than writing a snapshot with a hole +// in it. Silent omission is what made the limitation look like a finding; a +// loud stop is a scanner that says "I do not understand this", which is the +// true statement. import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -48,6 +72,8 @@ function rustFiles(dir) { return out; } +const SPEC_PREFIX = "https://trusttasks.org/spec/"; + const URI = /"(https:\/\/trusttasks\.org\/spec\/[^"]+)"/g; /** `pub const NAME: &str = "…"` — the constant a deprecation attaches to. */ const CONST_DECL = /(?:pub\s+)?const\s+([A-Z0-9_]+)\s*:\s*&'?\w*\s*str\s*=\s*"([^"]+)"/; @@ -64,10 +90,67 @@ const CONST_DECL = /(?:pub\s+)?const\s+([A-Z0-9_]+)\s*:\s*&'?\w*\s*str\s*=\s*"([ */ const CONST_DECL_OPEN = /(?:pub\s+)?const\s+([A-Z0-9_]+)\s*:\s*&'?\w*\s*str\s*=\s*$/; +/** + * `::TYPE_URI` — a constant + * whose value is the generated type's own URI. `Response` resolves to the same + * task: `record` folds `#response` onto the base, exactly as it does for the + * literal pairs. + */ +const COMPUTED_TYPE_URI = + /<\s*([A-Za-z0-9_:]+)::(v\d+_\d+)::(?:Payload|Response)\s+as\s+[A-Za-z0-9_:]*Payload\s*>::TYPE_URI/; + +/** A `&str` const built from a file rather than from a task. Recognised so the + * unresolved-value stop below does not fire on one. */ +const NOT_A_TASK_VALUE = /^\s*(?:include_str!|concat!)/; + +/** + * Spec-module aliases in one file: `manifest` → `vtc/join_requests/manifest`. + * + * A `pub use` that reaches *inside* a version module is importing types, not + * aliasing a spec — `…::manifest::v0_2::{VettingRequirements, …}` names two + * structs — so a path carrying a `vN_M` segment is skipped. Without that, the + * braced names would be registered as spec modules and resolve to invented + * URIs. + */ +function specAliases(source) { + const aliases = new Map(); + // `[\s\S]` rather than `.`: rustfmt wraps a braced group across lines the + // moment it is long enough, and the vetting protocol has one that does. + for (const m of source.matchAll(/pub\s+use\s+trust_tasks_rs::specs::([\s\S]*?);/g)) { + const path = m[1].replace(/\s+/g, ""); + const braced = /^(.*?)::\{(.+)\}$/.exec(path); + const prefix = braced ? braced[1] : path.replace(/::[A-Za-z0-9_]+$/, ""); + const names = braced + ? braced[2].split(",").filter(Boolean) + : [path.slice(path.lastIndexOf("::") + 2)]; + const prefixSegs = prefix.split("::").filter(Boolean); + if (prefixSegs.some((seg) => /^v\d+_\d+$/.test(seg))) continue; + for (const name of names) { + if (/^v\d+_\d+$/.test(name)) continue; + aliases.set(name, [...prefixSegs, name]); + } + } + return aliases; +} + +/** + * Turn a resolved module path and version module into a task URI. + * + * Rust modules are snake_case and spec slugs are kebab-case, segment for + * segment (`join_requests` → `join-requests`); `v0_1` → `0.1`. + */ +function uriFromModulePath(segments, versionMod) { + const slug = segments.map((seg) => seg.replace(/_/g, "-")).join("/"); + const version = versionMod.slice(1).replace("_", "."); + return `${SPEC_PREFIX}${slug}/${version}`; +} + /** @type {Map, deprecated?: string, files: Set}>} */ const tasks = new Map(); -const SPEC_PREFIX = "https://trusttasks.org/spec/"; +/** Constants whose value this script could not turn into a URI. Fatal — see + * the header. */ +const unresolved = []; function record(uri, file) { // Only task URIs. `CONST_DECL` matches every `const NAME: &str = "…"` in the @@ -105,7 +188,25 @@ function attach(entry, constName, deprecation) { for (const file of rustFiles(sdkSrc)) { const rel = file.slice(sdkRoot.length + 1); - const lines = readFileSync(file, "utf8").split("\n"); + const source = readFileSync(file, "utf8"); + const lines = source.split("\n"); + const aliases = specAliases(source); + + /** Resolve a computed `…::TYPE_URI` value, or record why it could not be. */ + const resolveComputed = (value, constName, lineNo) => { + const m = COMPUTED_TYPE_URI.exec(value); + if (!m) return null; + const segs = m[1].split("::").filter(Boolean); + const head = aliases.get(segs[0]); + if (!head) { + unresolved.push( + `${rel}:${lineNo} ${constName} — no \`pub use trust_tasks_rs::specs::…\` ` + + `in this file brings \`${segs[0]}\` into scope`, + ); + return null; + } + return uriFromModulePath([...head, ...segs.slice(1)], m[2]); + }; // A `#[deprecated…]` attribute may wrap across lines; hold it until the // declaration it applies to arrives, and drop it on any other statement. @@ -114,15 +215,28 @@ for (const file of rustFiles(sdkSrc)) { /** A `const NAME: &str =` whose literal is on the line still to come. */ let pendingConst = null; - for (const line of lines) { + for (const [index, line] of lines.entries()) { + const lineNo = index + 1; if (pendingConst) { const literal = /^\s*"([^"]+)"/.exec(line); - const { name, deprecation } = pendingConst; + const { name, deprecation, lineNo } = pendingConst; pendingConst = null; if (literal) { attach(record(literal[1], rel), name, deprecation); continue; } + const computed = resolveComputed(line, name, lineNo); + if (computed) { + attach(record(computed, rel), name, deprecation); + continue; + } + // Neither a literal nor a type we can resolve. `include_str!` and friends + // are `&str` constants that are not tasks at all; anything else is a form + // this scanner has not been taught, and writing the snapshot without it + // is the silent omission the header describes. + if (!NOT_A_TASK_VALUE.test(line) && !COMPUTED_TYPE_URI.test(line)) { + unresolved.push(`${rel}:${lineNo} ${name} — unrecognised value: ${line.trim()}`); + } } if (inDeprecation || /^\s*#\[deprecated/.test(line)) { @@ -138,11 +252,20 @@ for (const file of rustFiles(sdkSrc)) { continue; } + // The same declaration, computed and short enough not to wrap. + const inlineComputed = /(?:pub\s+)?const\s+([A-Z0-9_]+)\s*:\s*&'?\w*\s*str\s*=\s*(<.+)$/.exec(line); + if (inlineComputed) { + const uri = resolveComputed(inlineComputed[2], inlineComputed[1], lineNo); + if (uri) attach(record(uri, rel), inlineComputed[1], pendingDeprecation); + pendingDeprecation = null; + continue; + } + const open = CONST_DECL_OPEN.exec(line); if (open) { // Carry the deprecation across the wrap rather than letting the // clear-on-any-statement rule below eat it. - pendingConst = { name: open[1], deprecation: pendingDeprecation }; + pendingConst = { name: open[1], deprecation: pendingDeprecation, lineNo }; pendingDeprecation = null; continue; } @@ -159,6 +282,19 @@ const sdkVersion = : "", )?.[1] ?? "unknown"; +if (unresolved.length > 0) { + console.error( + `Cannot resolve ${unresolved.length} \`&str\` constant(s) to a task URI:\n ` + + `${unresolved.join("\n ")}\n\n` + + `Writing the snapshot without them would drop those tasks, and ` + + `tests/task-surface.mjs would then report any this library calls as tasks ` + + `the SDK does not have — which is what it says for a typo or a dropped ` + + `task, not for a form this script has not been taught. Teach it the form, ` + + `or fix the constant.`, + ); + process.exit(1); +} + const snapshot = { $comment: "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. " + diff --git a/packages/core/tests/task-surface-sync.mjs b/packages/core/tests/task-surface-sync.mjs new file mode 100644 index 0000000..c76ec29 --- /dev/null +++ b/packages/core/tests/task-surface-sync.mjs @@ -0,0 +1,178 @@ +// The snapshot scanner, over fixture SDKs. +// +// `sync-task-surface.mjs` grew real logic — it follows `pub use +// trust_tasks_rs::specs::…` aliases to resolve a constant whose value is a +// generated type rather than a string — and the failure it replaced was silent: +// a task the scanner could not see simply was not in the snapshot, and +// `task-surface.mjs` then reported it as a URI **the SDK does not have**. That +// is the wording for a typo or a dropped task, so a scanner limitation read as +// a live finding. It cost an investigation before it was recognised. +// +// So the property under test is not only "resolves the forms we know" but +// "stops rather than writing a snapshot with a hole in it". +// +// Driven as a subprocess against a fixture tree rather than by importing the +// helpers, because the thing that went wrong was end-to-end: what lands in the +// file, and what the exit code is. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const SCRIPT = resolve(import.meta.dirname, "../scripts/sync-task-surface.mjs"); +const SNAPSHOT = resolve(import.meta.dirname, "../task-surface.json"); + +/** Build a throwaway `vta-sdk` whose `src/` holds `files`, and sync from it. */ +function syncFrom(files) { + const root = mkdtempSync(join(tmpdir(), "fake-sdk-")); + const saved = readFileSync(SNAPSHOT, "utf8"); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "Cargo.toml"), 'version = "9.9.9"\n'); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(root, "src", name), body); + } + try { + let status = 0; + let stderr = ""; + try { + execFileSync(process.execPath, [SCRIPT, root], { stdio: ["ignore", "pipe", "pipe"] }); + } catch (e) { + status = e.status ?? 1; + stderr = String(e.stderr ?? ""); + } + return { status, stderr, snapshot: JSON.parse(readFileSync(SNAPSHOT, "utf8")) }; + } finally { + // The script writes the real snapshot, so put it back however this ends. + writeFileSync(SNAPSHOT, saved); + rmSync(root, { recursive: true, force: true }); + } +} + +const uris = (snapshot) => snapshot.tasks.map((t) => t.uri); + +test("a constant derived from its generated type is resolved, not dropped", () => { + const { status, snapshot } = syncFrom({ + "p.rs": ` +pub use trust_tasks_rs::specs::vtc::join_requests::manifest; + +pub const JOIN_REQUEST_MANIFEST_TYPE: &str = + ::TYPE_URI; +`, + }); + assert.equal(status, 0); + // snake_case module, kebab-case slug — the segment-for-segment rule. + assert.deepEqual(uris(snapshot), ["https://trusttasks.org/spec/vtc/join-requests/manifest/0.1"]); + assert.deepEqual(snapshot.tasks[0].consts, ["JOIN_REQUEST_MANIFEST_TYPE"]); +}); + +test("the response half lands on the same task, as the literal pairs do", () => { + const { snapshot } = syncFrom({ + "p.rs": ` +pub use trust_tasks_rs::specs::vtc::join_requests::manifest; + +pub const M_TYPE: &str = + ::TYPE_URI; +pub const M_RESPONSE_TYPE: &str = + ::TYPE_URI; +`, + }); + assert.equal(snapshot.tasks.length, 1, "the response was recorded as a second task"); + assert.deepEqual(snapshot.tasks[0].consts, ["M_RESPONSE_TYPE", "M_TYPE"]); +}); + +test("a braced alias group resolves each of its names, across a wrap", () => { + const { snapshot } = syncFrom({ + "p.rs": ` +pub use trust_tasks_rs::specs::vetting::{ + decline, request, session, +}; + +pub const A: &str = ::TYPE_URI; +pub const B: &str = ::TYPE_URI; +pub const C: &str = ::TYPE_URI; +`, + }); + assert.deepEqual(uris(snapshot).sort(), [ + "https://trusttasks.org/spec/vetting/decline/0.1", + "https://trusttasks.org/spec/vetting/request/0.1", + "https://trusttasks.org/spec/vetting/session/0.1", + ]); +}); + +test("an alias carries deeper module segments", () => { + const { snapshot } = syncFrom({ + "p.rs": ` +pub use trust_tasks_rs::specs::vtc::vetting::{revoke_statement, vetters}; + +pub const G: &str = ::TYPE_URI; +pub const R: &str = ::TYPE_URI; +`, + }); + assert.deepEqual(uris(snapshot).sort(), [ + "https://trusttasks.org/spec/vtc/vetting/revoke-statement/0.1", + "https://trusttasks.org/spec/vtc/vetting/vetters/grant/0.1", + ]); +}); + +// A `pub use` reaching *inside* a version module imports types, not specs. +// Treating `VettingRequirements` as a spec module would invent a URI for it. +test("a use that reaches inside a version module registers no alias", () => { + const { status, stderr, snapshot } = syncFrom({ + "p.rs": ` +pub use trust_tasks_rs::specs::vtc::join_requests::manifest::v0_2::{ + VettingRequirements, Branding, +}; +`, + }); + assert.equal(status, 0, stderr); + assert.deepEqual(uris(snapshot), []); +}); + +// The property the whole change exists for. +test("an unresolvable constant stops the sync instead of vanishing from it", () => { + const { status, stderr, snapshot } = syncFrom({ + "p.rs": ` +pub const ORPHAN_TYPE: &str = + ::TYPE_URI; +`, + }); + assert.equal(status, 1, "the sync succeeded with a task missing from the snapshot"); + assert.match(stderr, /nowhere/); + // The *declaration* line, not the value line below it: that is where the + // constant is and where someone would edit it. + assert.match(stderr, /p\.rs:2/, "the message does not say where to look"); + // And the snapshot on disk is untouched — a failed sync must not half-write. + assert.ok(snapshot.tasks.length > 0, "the real snapshot was overwritten by a failed run"); +}); + +// `include_str!` constants are `&str` and are not tasks. The stop above must +// not fire on them, or no sync ever completes. +test("a &str constant built from a file is not mistaken for a task", () => { + const { status, stderr } = syncFrom({ + "p.rs": ` +pub const CONTEXT: &str = + include_str!("../../contexts/vta-authorization-v1.jsonld"); +`, + }); + assert.equal(status, 0, stderr); +}); + +test("literal constants still work, wrapped or not", () => { + const { snapshot } = syncFrom({ + "p.rs": ` +pub const SHORT: &str = "https://trusttasks.org/spec/acl/grant/0.1"; +#[deprecated(since = "0.1.0", note = "use grant/0.2")] +pub const WRAPPED_AND_DEPRECATED: &str = + "https://trusttasks.org/spec/acl/revoke/0.1"; +`, + }); + assert.deepEqual(uris(snapshot).sort(), [ + "https://trusttasks.org/spec/acl/grant/0.1", + "https://trusttasks.org/spec/acl/revoke/0.1", + ]); + const revoke = snapshot.tasks.find((t) => t.uri.endsWith("revoke/0.1")); + assert.equal(revoke.deprecated, "use grant/0.2"); +});