Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 141 additions & 5 deletions packages/core/scripts/sync-task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
// <manifest::v0_1::Payload as trust_tasks_rs::Payload>::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";
Expand Down Expand Up @@ -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*"([^"]+)"/;
Expand All @@ -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*$/;

/**
* `<alias::…::vN_M::(Payload|Response) as …Payload>::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<string, {uri: string, consts: Set<string>, deprecated?: string, files: Set<string>}>} */
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
Expand Down Expand Up @@ -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.
Expand All @@ -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)) {
Expand All @@ -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;
}
Expand All @@ -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. " +
Expand Down
178 changes: 178 additions & 0 deletions packages/core/tests/task-surface-sync.mjs
Original file line number Diff line number Diff line change
@@ -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 =
<manifest::v0_1::Payload as trust_tasks_rs::Payload>::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 =
<manifest::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const M_RESPONSE_TYPE: &str =
<manifest::v0_1::Response as trust_tasks_rs::Payload>::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 = <request::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const B: &str = <session::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const C: &str = <decline::v0_1::Payload as trust_tasks_rs::Payload>::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 = <vetters::grant::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const R: &str = <revoke_statement::v0_1::Payload as trust_tasks_rs::Payload>::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 =
<nowhere::v0_1::Payload as trust_tasks_rs::Payload>::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");
});