@rightkit/release 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +59 -7
  2. package/adhoc-entitlements.mjs +8 -0
  3. package/adoption-evidence.mjs +650 -0
  4. package/advisory-exceptions.mjs +371 -0
  5. package/apple-profile-create.mjs +113 -10
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +1079 -0
  8. package/binary-license-notices.mjs +161 -0
  9. package/cargo-license-notices.mjs +111 -0
  10. package/cli/right-release.mjs +39 -0
  11. package/deterministic-scans.mjs +966 -0
  12. package/development-signing.mjs +2 -1
  13. package/host-upload-refusal.mjs +90 -0
  14. package/local-publish.mjs +142 -19
  15. package/local-qualify.mjs +728 -0
  16. package/local-runtime.mjs +319 -0
  17. package/lock-graph.mjs +51 -0
  18. package/managed-cargo.mjs +4 -1
  19. package/ownership-approval.mjs +23 -6
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/ownership-mandatory-set.json +158 -68
  22. package/ownership-mandatory-set.mjs +82 -6
  23. package/package.json +6 -2
  24. package/pipeline-admission.mjs +375 -0
  25. package/pipeline-config.mjs +100 -0
  26. package/pipeline-generators.json +4 -0
  27. package/pipeline-generators.mjs +137 -0
  28. package/pipeline-receipts.mjs +302 -0
  29. package/pipeline-scan.mjs +285 -0
  30. package/pipeline-tools.mjs +171 -0
  31. package/production-mode.mjs +42 -0
  32. package/publication-gate.mjs +94 -32
  33. package/publish-cargo.mjs +24 -7
  34. package/publish-swift.mjs +12 -3
  35. package/qa-control-scan.mjs +4 -0
  36. package/release-config.mjs +330 -0
  37. package/release-switch-scan.mjs +44 -5
  38. package/release.mjs +14 -4
  39. package/rightkit-ownership.json +57 -0
  40. package/rightkit-sync.mjs +8 -0
  41. package/rightkit-versions.json +191 -131
  42. package/runtime-budget-classes.json +8 -0
  43. package/runtime-budget-crates.mjs +14 -0
  44. package/runtime-budgets.mjs +128 -31
  45. package/scan-digest.mjs +107 -0
  46. package/sign-qa-bundle.mjs +210 -0
  47. package/stage-sidecar.mjs +99 -15
  48. package/swap-helper-bundle.mjs +7 -3
  49. package/workflow-yaml.mjs +177 -0
  50. package/bin/darwin/rightkit-swap.sign.json +0 -6
package/stage-sidecar.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  // metadata`) or { npm: "<pkg>/<file>" }. The wrapper form { sidecars, profiles: { dev, release } } sets the default cargo
24
24
  // profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
25
25
  // both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`. `ffmpeg` and `onnxruntime` do the same from their
26
- // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged), a `cmake` entry builds with
26
+ // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged; an onnxruntime `universal` macOS row is staged once, lipo -archs checked, no merge), a `cmake` entry builds with
27
27
  // CMAKE_OSX_ARCHITECTURES="arm64;x86_64". A `prebuilt` entry does it from per-arch host-built inputs (`from: { aarch64, x86_64 }`, or the
28
28
  // staged `<name>-<slice triple>` files), each verified against its own manifest entry and then lipo-merged. An `ffmpeg` source may be a
29
29
  // local path (`sources.ffmpeg: { path: "../vendor/ffmpeg-lgpl" }`) instead of a pinned https archive.
@@ -73,6 +73,28 @@ const MODES = new Set(["dev", "release"]);
73
73
  const PROFILE = /^[a-z][a-z0-9_-]*$/;
74
74
  const PLATFORMS = new Set(["mac", "win", "linux"]);
75
75
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
76
+ /** One path segment of an archive member path: no glob metacharacters, no separators, no drive colon. */
77
+ const MEMBER_SEGMENT = /^[A-Za-z0-9_.+@~-]+$/;
78
+
79
+ /**
80
+ * Normalize and guard a requested archive member. A bare file name (no `/`) selects the one member with that base name; a path
81
+ * (contains `/`) selects the one member with exactly that archive path. A leading `./` is stripped. Absolute paths, drive
82
+ * prefixes, backslashes, empty, `.` and `..` segments and glob metacharacters are refused. Returns { value, byPath } or
83
+ * { error }. The member never influences where the extracted file is written.
84
+ */
85
+ export function normalizeArchiveMember(member) {
86
+ if (typeof member !== "string" || !member) return { error: "must be a non-empty string" };
87
+ if (member.includes("\\")) return { error: "must not contain a backslash" };
88
+ const value = member.startsWith("./") ? member.slice(2) : member;
89
+ if (value.startsWith("/")) return { error: "must not be an absolute path" };
90
+ if (/^[A-Za-z]:/.test(value)) return { error: "must not start with a drive prefix" };
91
+ const segments = value.split("/");
92
+ if (segments.some((segment) => segment === "")) return { error: "must not contain an empty segment" };
93
+ if (segments.some((segment) => segment === "." || segment === "..")) return { error: "must not contain a '.' or '..' segment" };
94
+ // A bare file name keeps today's matching (any characters but the refused ones above); a path is held to a strict segment alphabet so it can never act as a tar glob.
95
+ if (segments.length > 1 && !segments.every((segment) => MEMBER_SEGMENT.test(segment))) return { error: "has a segment with characters outside A-Z a-z 0-9 _ . + @ ~ -" };
96
+ return { value, byPath: segments.length > 1 };
97
+ }
76
98
  const KEYS = Object.freeze({
77
99
  "cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
78
100
  ffmpeg: ["kind", "variant", "variants", "tools", "notices", "catalog", "stage", "bundleDir", "sources", "probe", "platforms"],
@@ -390,7 +412,7 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
390
412
  if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
391
413
  const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
392
414
  if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
393
- if (item.extract !== undefined && (typeof item.extract !== "string" || !NAME.test(item.extract))) errors.push(`${where}: extract must be the bare file name of one archive member (no path)`);
415
+ if (item.extract !== undefined) { const member = normalizeArchiveMember(item.extract); if (member.error || (!member.byPath && !NAME.test(member.value))) errors.push(`${where}: extract must be the bare file name of one archive member or its archive path (posix, no leading /, no .. segment): ${member.error ?? "invalid file name"}`); }
394
416
  if (item.memberSha256 !== undefined && (item.extract === undefined || !SHA256.test(item.memberSha256 ?? ""))) errors.push(`${where}: memberSha256 must be a lowercase 64-hex SHA-256 and needs extract`);
395
417
  if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
396
418
  const clean = cleanOf();
@@ -499,15 +521,31 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
499
521
  });
500
522
  }
501
523
 
502
- /** Default archive member extraction (bsdtar reads tgz and zip/nupkg): the one member whose basename is `fileName`. */
524
+ /**
525
+ * Default archive member extraction (bsdtar reads tgz and zip/nupkg). A bare `fileName` selects the one member whose basename it is;
526
+ * a `fileName` containing `/` selects the one member whose full archive path equals it (case-sensitive, leading `./` ignored on both
527
+ * sides), so archives with the same base name in several directories (ORT DirectML nupkgs) can be addressed. The returned file is
528
+ * always under `destDir`; callers copy it to their own declared output name, so the member path never decides where anything is written.
529
+ * A bare name ignores members inside a `.dSYM` bundle: Apple archives (ORT osx tgz) ship the DWARF copy under the same base name.
530
+ */
531
+ const isDebugSymbolMember = (member) => member.split("/").some((part) => part.toLowerCase().endsWith(".dsym"));
532
+
503
533
  export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun }) {
534
+ const wanted = normalizeArchiveMember(fileName);
535
+ if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
504
536
  const listing = run("tar", ["-tf", archive], { cwd: path.dirname(archive) });
505
537
  if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
506
- const members = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && path.posix.basename(line) === fileName);
507
- if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members named ${fileName}, expected exactly one`);
538
+ const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
539
+ const members = wanted.byPath
540
+ ? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
541
+ : lines.filter((line) => path.posix.basename(line) === wanted.value && !isDebugSymbolMember(line));
542
+ if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members ${wanted.byPath ? "at the path" : "named"} ${wanted.value}, expected exactly one`);
508
543
  const extracted = run("tar", ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
509
544
  if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
510
- return path.join(destDir, ...members[0].split("/"));
545
+ const out = path.join(destDir, ...members[0].split("/"));
546
+ const inside = path.relative(path.resolve(destDir), path.resolve(out));
547
+ if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `member ${members[0]} of ${archive} resolves outside the extraction directory`);
548
+ return out;
511
549
  }
512
550
 
513
551
  async function defaultFetchBytes(url) {
@@ -877,18 +915,48 @@ async function stageFfmpeg(entry, ctx) {
877
915
  }
878
916
  }
879
917
 
918
+ /** Catalog statuses that may serve a staged library; `stale-unverified` and `planned` rows never do. */
919
+ const SERVING_STATUS = new Set(["approved", "pinned"]);
920
+
921
+ /**
922
+ * The one onnxruntime catalog row that serves target `t` (mirrors rightkit-onnx provision.rs select()): an approved or pinned
923
+ * exact-(os, arch) row wins; only when none exists does a macOS `universal` row (both Apple slices) serve an aarch64 or x86_64
924
+ * target. A stale-unverified or planned row is never selected. With nothing serving, the typed error names the exact-arch row's
925
+ * status when there is one, else the missing row.
926
+ */
927
+ function selectOnnxRuntimeRow(loaded, entry, t, where) {
928
+ const candidates = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os);
929
+ const serving = candidates.filter((row) => SERVING_STATUS.has(row.status));
930
+ const exact = serving.filter((row) => row.arch === t.arch);
931
+ const appleThin = t.os === "macos" && (t.arch === "aarch64" || t.arch === "x86_64");
932
+ const hits = exact.length ? exact : appleThin ? serving.filter((row) => row.arch === "universal") : [];
933
+ if (hits.length === 1) return hits[0];
934
+ if (hits.length > 1) return fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${hits.length} ${where} rows`);
935
+ const unserved = candidates.filter((row) => row.arch === t.arch);
936
+ if (unserved.length === 0) return fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
937
+ if (unserved.length > 1) return fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${unserved.length} ${where} rows`);
938
+ return fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${unserved[0].id} is ${unserved[0].status}, not approved or pinned`);
939
+ }
940
+
941
+ /**
942
+ * The archive member of a catalog row: archive.member when present; else, for a macOS `.zip` archive, `<archive name without
943
+ * .zip>/lib/<artifactFileName>` (as provision.rs derives it); else the bare artifactFileName.
944
+ */
945
+ function onnxRuntimeMember(row) {
946
+ if (row.archive.member !== undefined) return row.archive.member;
947
+ const archiveName = path.posix.basename(new URL(row.archive.url).pathname);
948
+ if (row.os === "macos" && archiveName.endsWith(".zip")) return `${archiveName.slice(0, -".zip".length)}/lib/${row.artifactFileName}`;
949
+ return row.artifactFileName;
950
+ }
951
+
880
952
  /**
881
953
  * Stage one architecture of onnxruntime: the approved or pinned catalog row for `t`, its archive (digest-checked), the library
882
954
  * (digest-checked) into `destDir` (default the entry's resourceDir), and the notices. Returns { row, dest, outName, notices }.
883
955
  */
884
956
  async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
885
957
  const { appRoot, fs } = ctx;
886
- const rows = (loaded.catalog?.libraries ?? []).filter((row) => row.library === "onnxruntime" && row.version === entry.version && row.os === t.os && row.arch === t.arch);
887
958
  const where = `onnxruntime ${entry.version} ${t.os}/${t.arch}`;
888
- if (rows.length === 0) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has no ${where} row`);
889
- if (rows.length > 1) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog has ${rows.length} ${where} rows`);
890
- const [row] = rows;
891
- if (!["approved", "pinned"].includes(row.status)) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} is ${row.status}, not approved or pinned`);
959
+ const row = selectOnnxRuntimeRow(loaded, entry, t, where);
892
960
  for (const key of ["sha256", "artifactFileName"]) if (typeof row[key] !== "string" || !row[key]) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has no ${key}`);
893
961
  if (!SHA256.test(row.sha256) || !SHA256.test(row.archive?.sha256 ?? "") || typeof row.archive?.url !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} lacks a valid library digest or archive url and digest`);
894
962
  if (!Array.isArray(row.noticeFiles) || !row.noticeFiles.length) fail("STAGE_SIDECAR_NOTICE_MISSING", `the ${where} row ${row.id} lists no notice files; the licence must ship with the library`);
@@ -900,6 +968,10 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
900
968
  });
901
969
  for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
902
970
 
971
+ // The archive member is the catalog's archive.member (an archive path) when present, else the library file name; the output name never comes from it.
972
+ const member = onnxRuntimeMember(row);
973
+ const memberCheck = normalizeArchiveMember(member);
974
+ if (memberCheck.error) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has an invalid archive member ${JSON.stringify(member)}: ${memberCheck.error}`);
903
975
  const outName = entry.as ?? row.artifactFileName;
904
976
  const dest = path.resolve(appRoot, destDir ?? entry.resourceDir, outName);
905
977
  if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
@@ -915,9 +987,9 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
915
987
  }
916
988
  const scratch = ctx.makeTempDir();
917
989
  try {
918
- const library = await ctx.extractMember({ archive, fileName: row.artifactFileName, destDir: scratch });
990
+ const library = await ctx.extractMember({ archive, fileName: member, destDir: scratch });
919
991
  const actual = sha256Of(fs.readFileSync(library));
920
- if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${row.artifactFileName} extracted as ${actual}, the catalog pins ${row.sha256}; nothing staged`, { expected: row.sha256, actual });
992
+ if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${member} extracted as ${actual}, the catalog pins ${row.sha256}; nothing staged`, { expected: row.sha256, actual });
921
993
  copyInto(ctx, library, dest, { executable: false });
922
994
  } finally {
923
995
  ctx.removeDir(scratch);
@@ -939,7 +1011,19 @@ async function stageOnnxRuntime(entry, ctx) {
939
1011
  const { row, dest, outName, notices } = await stageOnnxRuntimeSlice(entry, ctx, loaded, target);
940
1012
  return [{ kind: "onnxruntime", name: outName, path: rel(ctx, dest), sha256: row.sha256, sizeBytes: fs.statSync(dest).size, changed: null, catalogRow: row.id, version: row.version, archiveSha256: row.archive.sha256, license: row.licenseSpdx ?? null, notices, externalBin: false }];
941
1013
  }
942
- // universal-apple-darwin: each arch row is staged thin under SLICE_ROOT, then merged into the entry's resource file.
1014
+ // universal-apple-darwin: one macOS `universal` row serving both slices is staged once, in place, with no lipo merge.
1015
+ const selected = SLICES.map((triple) => selectOnnxRuntimeRow(loaded, entry, parseTargetTriple(triple), `onnxruntime ${entry.version} macos/${triple.split("-")[0]}`));
1016
+ if (selected.some((row) => row.arch === "universal")) {
1017
+ if (selected[0] !== selected[1]) fail("STAGE_SIDECAR_NOT_APPROVED", `the native catalog mixes a universal row with exact-arch rows (${selected.map((row) => row.id).join(", ")}); universal-apple-darwin takes either one universal row or one row per arch`);
1018
+ const staged = await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(SLICES[0]));
1019
+ requireUniversalFile(ctx, staged.dest, `onnxruntime ${entry.version} ${staged.row.id}`);
1020
+ return [{
1021
+ kind: "onnxruntime", name: staged.outName, path: rel(ctx, staged.dest), sha256: staged.row.sha256, sizeBytes: fs.statSync(staged.dest).size, changed: null, sourceSha256: staged.row.sha256,
1022
+ catalogRow: staged.row.id, version: entry.version, archiveSha256: staged.row.archive.sha256, license: staged.row.licenseSpdx ?? null, notices: staged.notices, externalBin: false,
1023
+ slices: SLICES.map((triple) => ({ triple, sha256: staged.row.sha256, catalogRow: staged.row.id, archiveSha256: staged.row.archive.sha256 })),
1024
+ }];
1025
+ }
1026
+ // Otherwise each arch row is staged thin under SLICE_ROOT, then merged into the entry's resource file.
943
1027
  const slices = [];
944
1028
  for (const triple of SLICES) slices.push({ triple, ...(await stageOnnxRuntimeSlice(entry, ctx, loaded, parseTargetTriple(triple), { destDir: `${SLICE_ROOT}/${triple}` })) });
945
1029
  const names = [...new Set(slices.map((slice) => slice.outName))];
@@ -1042,7 +1126,7 @@ async function ensureCachedDownload(ctx, { url, sha256, size }) {
1042
1126
 
1043
1127
  /**
1044
1128
  * A pinned https download. `dest` is copied only from the verified cache entry. With `extract` the download is an archive
1045
- * (zip, tgz): the one member whose base name is `extract` is unpacked (digest-checked against `memberSha256` when given) and
1129
+ * (zip, tgz): the one member whose base name is `extract` (or, when `extract` contains `/`, whose archive path equals it) is unpacked (digest-checked against `memberSha256` when given) and
1046
1130
  * only that member is staged, so no `command` entry or shell is needed to unpack it.
1047
1131
  */
1048
1132
  async function stageRemote(entry, ctx) {
@@ -1,7 +1,11 @@
1
1
  // The Developer-ID-signed `bin/darwin/rightkit-swap` that @rightkit/release ships (Orthic-Labs/rightkit#258).
2
- // rightkit-swap is the bin target of crates/rightkit-fs (renamex_np RENAME_SWAP). CI has no Apple signing secrets, so
3
- // @rightkit/release is published through `right-release publish npm --local` on the Mac, which calls
4
- // `prepareSwapHelper` before packing. install-app calls `assertShippedSwapHelperSigned` before it trusts the helper.
2
+ // rightkit-swap is the bin target of crates/rightkit-fs (renamex_np RENAME_SWAP). Two signing routes produce it:
3
+ // (a) local publish — `right-release publish npm --local` on the Mac calls `prepareSwapHelper` before packing;
4
+ // (b) CI qualification — `publish.yml` build-swap -> sign-swap -> pack (docs/CI.md), where sign-swap imports the
5
+ // Developer ID from the `release` environment secrets. Route (b) stays blocked until root provisions
6
+ // APPLE_CERTIFICATE_BASE64/APPLE_CERTIFICATE_PASSWORD there; exporting that identity needs an Aqua login session
7
+ // (SSH export fails errSecInteractionNotAllowed/-25308 & the signing agent has no key-export capability — never
8
+ // retry either). install-app calls `assertShippedSwapHelperSigned` before it trusts the helper.
5
9
  import crypto from "node:crypto";
6
10
  import nodeFs from "node:fs";
7
11
  import path from "node:path";
@@ -0,0 +1,177 @@
1
+ // A strict parser for the YAML subset GitHub Actions workflows in this repository use: block
2
+ // mappings, block sequences, literal block scalars (`|` and `|-`, no indentation indicator), flow
3
+ // sequences of scalars on one line, quoted and plain single-line scalars, comments. It parses
4
+ // structure so a contract test can assert on it. It REJECTS everything it does not implement exactly
5
+ // instead of guessing, so an unparsed construct can never pass as "absent" and no scalar can mean
6
+ // something different to GitHub than to the contract: tabs anywhere, folded scalars (`>`) in any
7
+ // form, block scalars with an indentation indicator or `+` chomping, anchors, aliases, tags, merge
8
+ // keys, explicit keys, document markers, flow mappings (except the empty `{}`), flow collections that span lines, multi-line
9
+ // scalars, duplicate keys. Every scalar is returned as a string. Literal block scalars follow the
10
+ // spec: `|` keeps one trailing newline, `|-` none.
11
+
12
+ function fail(no, message) { throw new Error(`yaml line ${no}: ${message}`); }
13
+
14
+ function stripComment(text) {
15
+ let quote = null;
16
+ for (let i = 0; i < text.length; i += 1) {
17
+ const c = text[i];
18
+ if (quote) { if (c === quote) quote = null; continue; }
19
+ if ((c === '"' || c === "'") && (i === 0 || /[\s[,:]/.test(text[i - 1]))) { quote = c; continue; }
20
+ if (c === "#" && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i).trimEnd();
21
+ }
22
+ return text.trimEnd();
23
+ }
24
+
25
+ function scalar(raw, no) {
26
+ const text = raw.trim();
27
+ if (text === "") return "";
28
+ if (/^[&*!]/.test(text)) fail(no, `anchors, aliases and tags are not supported: ${text.slice(0, 40)}`);
29
+ if (/^[|>]/.test(text)) fail(no, `block scalar header ${JSON.stringify(text.slice(0, 12))} is not supported here (only "|" and "|-" after a mapping key)`);
30
+ if (/^(?:[-?:](?:\s|$)|[%@`])/.test(text)) fail(no, `YAML indicator at the start of a plain scalar is not supported: ${text.slice(0, 40)}`);
31
+ // The empty flow mapping alone (`permissions: {}`) is unambiguous; any other flow mapping is refused.
32
+ if (text === "{}") return {};
33
+ if (text.startsWith("{")) fail(no, "flow mappings are not supported");
34
+ if (text.startsWith('"')) {
35
+ if (!/^"(?:[^"\\]|\\.)*"$/.test(text)) fail(no, `unterminated or trailing text after a quoted scalar: ${text.slice(0, 40)}`);
36
+ return JSON.parse(text);
37
+ }
38
+ if (text.startsWith("'")) {
39
+ if (!/^'(?:[^']|'')*'$/.test(text)) fail(no, `unterminated or trailing text after a quoted scalar: ${text.slice(0, 40)}`);
40
+ return text.slice(1, -1).replaceAll("''", "'");
41
+ }
42
+ if (text.startsWith("[")) {
43
+ if (!text.endsWith("]")) fail(no, "flow sequence must close on the same line");
44
+ const inner = text.slice(1, -1);
45
+ const items = []; let current = ""; let quote = null;
46
+ for (const c of inner) {
47
+ if (quote) { current += c; if (c === quote) quote = null; continue; }
48
+ if (c === '"' || c === "'") { quote = c; current += c; continue; }
49
+ if (c === "[" || c === "{") fail(no, "nested flow collections are not supported");
50
+ if (c === ",") { items.push(current); current = ""; continue; }
51
+ current += c;
52
+ }
53
+ if (quote) fail(no, "unterminated quote in flow sequence");
54
+ items.push(current);
55
+ const kept = items.map((item) => item.trim());
56
+ while (kept.length && kept.at(-1) === "") kept.pop(); // a trailing comma
57
+ if (kept.includes("")) fail(no, "empty item in a flow sequence");
58
+ return kept.map((item) => scalar(item, no));
59
+ }
60
+ return text;
61
+ }
62
+
63
+ export function parseYaml(source) {
64
+ const raw = String(source).replace(/\r\n/g, "\n").split("\n");
65
+ raw.forEach((line, i) => { if (line.includes("\t")) fail(i + 1, "tabs are not allowed"); });
66
+ const lines = raw.map((text, i) => ({ no: i + 1, raw: text, indent: /^( *)/.exec(text)[1].length, body: stripComment(text).trim() }));
67
+ let cursor = 0;
68
+
69
+ const skipBlank = () => { while (cursor < lines.length && lines[cursor].body === "") cursor += 1; };
70
+
71
+ function blockScalar(parentIndent, header, no) {
72
+ const strip = header === "|-";
73
+ const body = [];
74
+ let blockIndent = null;
75
+ while (cursor < lines.length) {
76
+ const line = lines[cursor];
77
+ if (line.raw.trim() === "") {
78
+ // A blank line deeper than the block indentation carries content (its spaces); not modeled.
79
+ if (blockIndent !== null && line.raw.length > blockIndent) fail(line.no, "whitespace-only line inside a block scalar is indented past the block; not supported");
80
+ body.push(""); cursor += 1; continue;
81
+ }
82
+ if (line.indent <= parentIndent) break;
83
+ if (blockIndent === null) blockIndent = line.indent;
84
+ if (line.indent < blockIndent) fail(line.no, "block scalar line is indented less than its first line");
85
+ body.push(line.raw.slice(blockIndent));
86
+ cursor += 1;
87
+ }
88
+ while (body.length && body.at(-1) === "") body.pop();
89
+ if (body.length === 0) return "";
90
+ return body.join("\n") + (strip ? "" : "\n");
91
+ }
92
+
93
+ function parseBlock(indent) {
94
+ skipBlank();
95
+ if (cursor >= lines.length) return null;
96
+ const first = lines[cursor];
97
+ if (first.indent !== indent) fail(first.no, `expected indent ${indent}, found ${first.indent}`);
98
+ return /^-(\s|$)/.test(first.body) ? parseSequence(indent) : parseMapping(indent);
99
+ }
100
+
101
+ function parseSequence(indent) {
102
+ const out = [];
103
+ for (;;) {
104
+ skipBlank();
105
+ if (cursor >= lines.length) break;
106
+ const line = lines[cursor];
107
+ if (line.indent < indent) break;
108
+ if (line.indent > indent) fail(line.no, "unexpected indentation inside a sequence");
109
+ if (!/^-(\s|$)/.test(line.body)) break;
110
+ const rest = line.body.slice(1);
111
+ const offset = rest.length - rest.trimStart().length;
112
+ const content = rest.trimStart();
113
+ if (content === "") {
114
+ cursor += 1; skipBlank();
115
+ const next = lines[cursor];
116
+ if (!next || next.indent <= indent) { out.push(""); continue; }
117
+ out.push(parseBlock(next.indent));
118
+ continue;
119
+ }
120
+ // "- key: value" opens a mapping whose keys sit at the column after "- ".
121
+ if (keyMatch(content) && !/^["'[]/.test(content)) {
122
+ const childIndent = indent + 1 + offset;
123
+ lines[cursor] = { ...line, indent: childIndent, body: content, raw: " ".repeat(childIndent) + content };
124
+ out.push(parseMapping(childIndent));
125
+ continue;
126
+ }
127
+ cursor += 1;
128
+ out.push(scalar(content, line.no));
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function keyMatch(body) {
134
+ const m = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s:#"'[\]{},&*!|>-][^:]*?|-[^\s:][^:]*?)\s*:(?:\s+(.*))?$/.exec(body);
135
+ return m ? { key: m[1], value: m[2] ?? "" } : null;
136
+ }
137
+
138
+ function parseMapping(indent) {
139
+ const out = {};
140
+ for (;;) {
141
+ skipBlank();
142
+ if (cursor >= lines.length) break;
143
+ const line = lines[cursor];
144
+ if (line.indent < indent) break;
145
+ if (line.indent > indent) fail(line.no, "unexpected indentation inside a mapping");
146
+ if (/^-(\s|$)/.test(line.body)) break;
147
+ const hit = keyMatch(line.body);
148
+ if (!hit) fail(line.no, `cannot parse mapping entry: ${line.body.slice(0, 60)}`);
149
+ const key = hit.key.startsWith('"') || hit.key.startsWith("'") ? scalar(hit.key, line.no) : hit.key;
150
+ if (Object.hasOwn(out, key)) fail(line.no, `duplicate key ${JSON.stringify(key)}`);
151
+ cursor += 1;
152
+ const value = hit.value.trim();
153
+ if (value === "") {
154
+ skipBlank();
155
+ const next = lines[cursor];
156
+ if (next && next.indent > indent) out[key] = parseBlock(next.indent);
157
+ else if (next && next.indent === indent && /^-(\s|$)/.test(next.body)) out[key] = parseSequence(indent);
158
+ else out[key] = "";
159
+ } else if (value === "|" || value === "|-") {
160
+ out[key] = blockScalar(indent, value, line.no);
161
+ } else if (/^[|>]/.test(value)) {
162
+ fail(line.no, `block scalar header ${JSON.stringify(value.slice(0, 12))} is not supported (indentation indicators, "+" chomping and folded scalars are rejected)`);
163
+ } else {
164
+ const next = (skipBlank(), lines[cursor]);
165
+ if (next && next.indent > indent) fail(next.no, "multi-line plain scalars are not supported");
166
+ out[key] = scalar(value, line.no);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ skipBlank();
173
+ const result = cursor >= lines.length ? {} : parseBlock(lines[cursor].indent);
174
+ skipBlank();
175
+ if (cursor < lines.length) fail(lines[cursor].no, "unparsed trailing content");
176
+ return result;
177
+ }
@@ -1,6 +0,0 @@
1
- {
2
- "schema": 1,
3
- "sha256": "a41ce13fd3bac31db0230d70bed20db0ed9bf554bdae2255bdf2064589b48f29",
4
- "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
- "codesign": "strict-valid"
6
- }