@telorun/analyzer 0.31.0 → 0.32.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.
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/inline-imports.d.ts.map +1 -1
- package/dist/inline-imports.js +6 -1
- package/dist/sources/http-source.d.ts.map +1 -1
- package/dist/sources/http-source.js +5 -6
- package/dist/sources/integrity.d.ts +35 -0
- package/dist/sources/integrity.d.ts.map +1 -0
- package/dist/sources/integrity.js +82 -0
- package/dist/sources/module-ref.d.ts +17 -0
- package/dist/sources/module-ref.d.ts.map +1 -0
- package/dist/sources/module-ref.js +35 -0
- package/dist/sources/registry-source.d.ts +0 -1
- package/dist/sources/registry-source.d.ts.map +1 -1
- package/dist/sources/registry-source.js +8 -30
- package/package.json +2 -2
- package/src/builtins.ts +13 -0
- package/src/index.ts +9 -0
- package/src/inline-imports.ts +7 -1
- package/src/sources/http-source.ts +5 -8
- package/src/sources/integrity.ts +101 -0
- package/src/sources/module-ref.ts +48 -0
- package/src/sources/registry-source.ts +8 -38
package/dist/builtins.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,
|
|
1
|
+
{"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAwiB/C,CAAC"}
|
package/dist/builtins.js
CHANGED
|
@@ -225,6 +225,7 @@ export const KERNEL_BUILTINS = [
|
|
|
225
225
|
additionalProperties: true,
|
|
226
226
|
},
|
|
227
227
|
source: { type: "string" },
|
|
228
|
+
integrity: { type: "string" },
|
|
228
229
|
variables: { type: "object" },
|
|
229
230
|
secrets: { type: "object" },
|
|
230
231
|
runtime: {
|
|
@@ -359,6 +360,11 @@ export const KERNEL_BUILTINS = [
|
|
|
359
360
|
type: "array",
|
|
360
361
|
items: { type: "string" },
|
|
361
362
|
},
|
|
363
|
+
// Integrity hash of the decompressed payload tar (`module.tar.gz`,
|
|
364
|
+
// telo.yaml excluded), written by `telo publish`. Pinned transitively
|
|
365
|
+
// by the importer's `#sha256-...` hash over this telo.yaml; verified at
|
|
366
|
+
// extract time. See plans/federated-registries.md.
|
|
367
|
+
filesIntegrity: { type: "string" },
|
|
362
368
|
// Inline imports — name-keyed map sugar for separate `Telo.Import`
|
|
363
369
|
// documents. The key is the PascalCase alias (the import's
|
|
364
370
|
// `metadata.name`). Each value is either a bare source string
|
|
@@ -376,6 +382,7 @@ export const KERNEL_BUILTINS = [
|
|
|
376
382
|
required: ["source"],
|
|
377
383
|
properties: {
|
|
378
384
|
source: { type: "string" },
|
|
385
|
+
integrity: { type: "string" },
|
|
379
386
|
variables: { type: "object" },
|
|
380
387
|
secrets: { type: "object" },
|
|
381
388
|
runtime: {
|
|
@@ -490,6 +497,11 @@ export const KERNEL_BUILTINS = [
|
|
|
490
497
|
type: "array",
|
|
491
498
|
items: { type: "string" },
|
|
492
499
|
},
|
|
500
|
+
// Integrity hash of the decompressed payload tar (`module.tar.gz`,
|
|
501
|
+
// telo.yaml excluded), written by `telo publish`. Pinned transitively
|
|
502
|
+
// by the importer's `#sha256-...` hash over this telo.yaml; verified at
|
|
503
|
+
// extract time. See plans/federated-registries.md.
|
|
504
|
+
filesIntegrity: { type: "string" },
|
|
493
505
|
// Inline imports — same name-keyed map sugar as Telo.Application; the
|
|
494
506
|
// loader desugars each entry into a synthetic Telo.Import. See the
|
|
495
507
|
// Application schema above and analyzer/nodejs/src/inline-imports.ts.
|
|
@@ -503,6 +515,7 @@ export const KERNEL_BUILTINS = [
|
|
|
503
515
|
required: ["source"],
|
|
504
516
|
properties: {
|
|
505
517
|
source: { type: "string" },
|
|
518
|
+
integrity: { type: "string" },
|
|
506
519
|
variables: { type: "object" },
|
|
507
520
|
secrets: { type: "object" },
|
|
508
521
|
runtime: {
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type { DocumentPosition } from "./position-metadata.js";
|
|
|
21
21
|
export { HttpSource } from "./sources/http-source.js";
|
|
22
22
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
23
23
|
export { defaultSources } from "./sources/default-sources.js";
|
|
24
|
+
export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, } from "./sources/integrity.js";
|
|
25
|
+
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
26
|
+
export type { ParsedModuleRef } from "./sources/module-ref.js";
|
|
24
27
|
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
25
28
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
26
29
|
export type { AnalysisDiagnostic, AnalysisOptions, LoaderInitOptions, LoadOptions, ManifestSource, Position, PositionIndex, Range } from "./types.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACH,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,gCAAgC,EAChC,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GACpB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACR,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACH,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,gCAAgC,EAChC,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GACpB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACR,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EACL,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,eAAe,GAChB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACxE,YAAY,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -13,5 +13,7 @@ export { buildDocumentPositions, buildLineOffsets, buildPositionIndex, documentL
|
|
|
13
13
|
export { HttpSource } from "./sources/http-source.js";
|
|
14
14
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
15
15
|
export { defaultSources } from "./sources/default-sources.js";
|
|
16
|
+
export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, } from "./sources/integrity.js";
|
|
17
|
+
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
16
18
|
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
17
19
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inline-imports.d.ts","sourceRoot":"","sources":["../src/inline-imports.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"inline-imports.d.ts","sourceRoot":"","sources":["../src/inline-imports.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAG/D;;6CAE6C;AAC7C,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,QAAQ,EAAE,gBAAgB,CAAC;CAC5B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,gBAAgB,EAChC,cAAc,EAAE,gBAAgB,GAAG,SAAS,GAC3C,eAAe,EAAE,CA+BnB;AAED;;;;;;;iFAOiF;AACjF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,UAAU,GAAG,UAAU,CAmB9D"}
|
package/dist/inline-imports.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { foldIntegrity } from "./sources/integrity.js";
|
|
1
2
|
import { isModuleKind } from "./module-kinds.js";
|
|
2
3
|
/**
|
|
3
4
|
* Desugar a module document's inline `imports:` map into synthetic
|
|
@@ -26,10 +27,14 @@ export function inlineImportManifests(moduleManifest, modulePosition) {
|
|
|
26
27
|
: undefined;
|
|
27
28
|
if (!entry || typeof entry.source !== "string")
|
|
28
29
|
continue;
|
|
30
|
+
// The object form carries integrity as a sibling `integrity:` field; fold it
|
|
31
|
+
// into the source string as a `#sha256-...` fragment so every downstream
|
|
32
|
+
// consumer sees a single representation (the scalar form already inlines it).
|
|
33
|
+
const source = foldIntegrity(entry.source, entry.integrity);
|
|
29
34
|
const manifest = {
|
|
30
35
|
kind: "Telo.Import",
|
|
31
36
|
metadata: { name: alias },
|
|
32
|
-
source
|
|
37
|
+
source,
|
|
33
38
|
...(entry.variables !== undefined ? { variables: entry.variables } : {}),
|
|
34
39
|
...(entry.secrets !== undefined ? { secrets: entry.secrets } : {}),
|
|
35
40
|
...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-source.d.ts","sourceRoot":"","sources":["../../src/sources/http-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"http-source.d.ts","sourceRoot":"","sources":["../../src/sources/http-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7E,qBAAa,UAAW,YAAW,cAAc;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAOlE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;CAIxD"}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { DEFAULT_MANIFEST_FILENAME } from "../types.js";
|
|
2
|
+
import { splitIntegrity, verifiedFetch } from "./integrity.js";
|
|
2
3
|
export class HttpSource {
|
|
3
4
|
supports(url) {
|
|
4
5
|
return url.startsWith("http://") || url.startsWith("https://");
|
|
5
6
|
}
|
|
6
7
|
async read(url) {
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
return { text: await response.text(), source: fetchUrl };
|
|
8
|
+
const { base, integrity } = splitIntegrity(url);
|
|
9
|
+
const fetchUrl = base.includes(".yaml") ? base : `${base}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
10
|
+
const { text } = await verifiedFetch(fetchUrl, integrity, fetchUrl);
|
|
11
|
+
return { text, source: fetchUrl };
|
|
13
12
|
}
|
|
14
13
|
resolveRelative(base, relative) {
|
|
15
14
|
const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Inline module integrity — the `#sha256-<base64url>` fragment carried on a
|
|
2
|
+
* remote import ref. Browser-safe: uses Web Crypto (`crypto.subtle`) and
|
|
3
|
+
* `btoa`, both globals in Node and the browser. No Node built-ins.
|
|
4
|
+
*
|
|
5
|
+
* The fragment is authoritative across every transport: a source's `read()`
|
|
6
|
+
* hashes the fetched bytes and compares against it before the manifest is
|
|
7
|
+
* parsed or cached. A mismatch is a terminal error — never a cache miss. */
|
|
8
|
+
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
9
|
+
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
10
|
+
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
11
|
+
export declare function splitIntegrity(ref: string): {
|
|
12
|
+
base: string;
|
|
13
|
+
integrity?: string;
|
|
14
|
+
};
|
|
15
|
+
/** Attach an integrity hash to a ref as a `#<alg>-...` fragment. No-op when the
|
|
16
|
+
* hash is absent/non-string or the ref already carries a fragment (an
|
|
17
|
+
* author-authored pin is never overwritten). Inverse of `splitIntegrity`;
|
|
18
|
+
* used to fold the object form's `integrity:` sibling into the source string. */
|
|
19
|
+
export declare function foldIntegrity(source: string, integrity: unknown): string;
|
|
20
|
+
/** SHA-256 of `bytes` as unpadded base64url — the canonical inline-hash form. */
|
|
21
|
+
export declare function sha256Base64Url(bytes: Uint8Array): Promise<string>;
|
|
22
|
+
/** Hash `bytes` and compare against `integrity` (`<alg>-<digest>`). Throws a
|
|
23
|
+
* terminal error on mismatch or an unsupported algorithm. `describe` names the
|
|
24
|
+
* artifact in the error (e.g. the module ref) so the failure is actionable. */
|
|
25
|
+
export declare function verifyIntegrity(bytes: Uint8Array, integrity: string, describe: string): Promise<void>;
|
|
26
|
+
/** The single verified network read for remote manifests: fetch `fetchUrl`,
|
|
27
|
+
* verify the raw bytes against `integrity` (when pinned), and return both the
|
|
28
|
+
* bytes and the decoded text. The one choke point every network `ManifestSource`
|
|
29
|
+
* routes through, so verification cannot drift between them. `describe` names
|
|
30
|
+
* the artifact in error messages. */
|
|
31
|
+
export declare function verifiedFetch(fetchUrl: string, integrity: string | undefined, describe: string): Promise<{
|
|
32
|
+
bytes: Uint8Array;
|
|
33
|
+
text: string;
|
|
34
|
+
}>;
|
|
35
|
+
//# sourceMappingURL=integrity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrity.d.ts","sourceRoot":"","sources":["../../src/sources/integrity.ts"],"names":[],"mappings":"AAAA;;;;;;6EAM6E;AAO7E;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAIhF;AAED;;;kFAGkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAIxE;AAcD,iFAAiF;AACjF,wBAAsB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAOxE;AAED;;gFAEgF;AAChF,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;sCAIsC;AACtC,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAU9C"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Inline module integrity — the `#sha256-<base64url>` fragment carried on a
|
|
2
|
+
* remote import ref. Browser-safe: uses Web Crypto (`crypto.subtle`) and
|
|
3
|
+
* `btoa`, both globals in Node and the browser. No Node built-ins.
|
|
4
|
+
*
|
|
5
|
+
* The fragment is authoritative across every transport: a source's `read()`
|
|
6
|
+
* hashes the fetched bytes and compares against it before the manifest is
|
|
7
|
+
* parsed or cached. A mismatch is a terminal error — never a cache miss. */
|
|
8
|
+
/** Only a `#<alg>-<base64url>` suffix is treated as integrity; other `#`
|
|
9
|
+
* fragments (rare in module refs) pass through untouched. `sha256` is the
|
|
10
|
+
* only algorithm accepted today; the prefix leaves room to migrate. */
|
|
11
|
+
const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
|
|
12
|
+
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
13
|
+
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
14
|
+
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
15
|
+
export function splitIntegrity(ref) {
|
|
16
|
+
const match = ref.match(INTEGRITY_FRAGMENT);
|
|
17
|
+
if (!match)
|
|
18
|
+
return { base: ref };
|
|
19
|
+
return { base: ref.slice(0, match.index), integrity: match[1] };
|
|
20
|
+
}
|
|
21
|
+
/** Attach an integrity hash to a ref as a `#<alg>-...` fragment. No-op when the
|
|
22
|
+
* hash is absent/non-string or the ref already carries a fragment (an
|
|
23
|
+
* author-authored pin is never overwritten). Inverse of `splitIntegrity`;
|
|
24
|
+
* used to fold the object form's `integrity:` sibling into the source string. */
|
|
25
|
+
export function foldIntegrity(source, integrity) {
|
|
26
|
+
return typeof integrity === "string" && !source.includes("#")
|
|
27
|
+
? `${source}#${integrity}`
|
|
28
|
+
: source;
|
|
29
|
+
}
|
|
30
|
+
function toBase64Url(bytes) {
|
|
31
|
+
let binary = "";
|
|
32
|
+
for (let i = 0; i < bytes.length; i++)
|
|
33
|
+
binary += String.fromCharCode(bytes[i]);
|
|
34
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
35
|
+
}
|
|
36
|
+
/** Normalize an encoded digest to unpadded base64url so a standard-base64 or
|
|
37
|
+
* padded input still compares equal to our canonical form. */
|
|
38
|
+
function normalizeDigest(value) {
|
|
39
|
+
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
40
|
+
}
|
|
41
|
+
/** SHA-256 of `bytes` as unpadded base64url — the canonical inline-hash form. */
|
|
42
|
+
export async function sha256Base64Url(bytes) {
|
|
43
|
+
// Copy into a plain ArrayBuffer: a Uint8Array may be backed by a
|
|
44
|
+
// SharedArrayBuffer, which `crypto.subtle.digest` does not accept.
|
|
45
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
46
|
+
new Uint8Array(buffer).set(bytes);
|
|
47
|
+
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
|
48
|
+
return toBase64Url(new Uint8Array(digest));
|
|
49
|
+
}
|
|
50
|
+
/** Hash `bytes` and compare against `integrity` (`<alg>-<digest>`). Throws a
|
|
51
|
+
* terminal error on mismatch or an unsupported algorithm. `describe` names the
|
|
52
|
+
* artifact in the error (e.g. the module ref) so the failure is actionable. */
|
|
53
|
+
export async function verifyIntegrity(bytes, integrity, describe) {
|
|
54
|
+
const dash = integrity.indexOf("-");
|
|
55
|
+
const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
|
|
56
|
+
const expected = dash > 0 ? integrity.slice(dash + 1) : "";
|
|
57
|
+
if (algorithm !== "sha256") {
|
|
58
|
+
throw new Error(`Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
|
|
59
|
+
`Only sha256 is supported (sha256-<base64url>).`);
|
|
60
|
+
}
|
|
61
|
+
const actual = await sha256Base64Url(bytes);
|
|
62
|
+
if (actual !== normalizeDigest(expected)) {
|
|
63
|
+
throw new Error(`Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
|
|
64
|
+
`got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
|
|
65
|
+
`the module may have been tampered with or republished.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The single verified network read for remote manifests: fetch `fetchUrl`,
|
|
69
|
+
* verify the raw bytes against `integrity` (when pinned), and return both the
|
|
70
|
+
* bytes and the decoded text. The one choke point every network `ManifestSource`
|
|
71
|
+
* routes through, so verification cannot drift between them. `describe` names
|
|
72
|
+
* the artifact in error messages. */
|
|
73
|
+
export async function verifiedFetch(fetchUrl, integrity, describe) {
|
|
74
|
+
const response = await fetch(fetchUrl);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Failed to fetch manifest ${describe}: ${response.status} ${response.statusText} (${fetchUrl})`);
|
|
77
|
+
}
|
|
78
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
79
|
+
if (integrity)
|
|
80
|
+
await verifyIntegrity(bytes, integrity, describe);
|
|
81
|
+
return { bytes, text: new TextDecoder().decode(bytes) };
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A parsed registry module reference. `modulePath` is `namespace/name`,
|
|
2
|
+
* `version` has any leading `v` stripped, and `integrity` carries the inline
|
|
3
|
+
* `sha256-<base64url>` hash when the ref was pinned. */
|
|
4
|
+
export interface ParsedModuleRef {
|
|
5
|
+
modulePath: string;
|
|
6
|
+
version: string;
|
|
7
|
+
integrity?: string;
|
|
8
|
+
}
|
|
9
|
+
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
10
|
+
* (no scheme, no leading `/` or `.`, contains both `@` and `/`). The integrity
|
|
11
|
+
* fragment, if any, does not affect the classification. */
|
|
12
|
+
export declare function isRegistryRef(url: string): boolean;
|
|
13
|
+
/** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
|
|
14
|
+
* source of truth shared by the registry source, the kernel manifest cache,
|
|
15
|
+
* and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
|
|
16
|
+
export declare function parseModuleRef(ref: string): ParsedModuleRef;
|
|
17
|
+
//# sourceMappingURL=module-ref.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module-ref.d.ts","sourceRoot":"","sources":["../../src/sources/module-ref.ts"],"names":[],"mappings":"AAEA;;yDAEyD;AACzD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;4DAE4D;AAC5D,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAYlD;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAgB3D"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { splitIntegrity } from "./integrity.js";
|
|
2
|
+
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
3
|
+
* (no scheme, no leading `/` or `.`, contains both `@` and `/`). The integrity
|
|
4
|
+
* fragment, if any, does not affect the classification. */
|
|
5
|
+
export function isRegistryRef(url) {
|
|
6
|
+
const { base } = splitIntegrity(url);
|
|
7
|
+
return (!base.startsWith("http://") &&
|
|
8
|
+
!base.startsWith("https://") &&
|
|
9
|
+
!base.startsWith("/") &&
|
|
10
|
+
!base.startsWith(".") &&
|
|
11
|
+
!base.startsWith("file://") &&
|
|
12
|
+
!base.startsWith("memory://") &&
|
|
13
|
+
base.includes("@") &&
|
|
14
|
+
base.includes("/"));
|
|
15
|
+
}
|
|
16
|
+
/** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
|
|
17
|
+
* source of truth shared by the registry source, the kernel manifest cache,
|
|
18
|
+
* and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
|
|
19
|
+
export function parseModuleRef(ref) {
|
|
20
|
+
const { base, integrity } = splitIntegrity(ref);
|
|
21
|
+
const atIdx = base.lastIndexOf("@");
|
|
22
|
+
if (atIdx <= 0 || atIdx === base.length - 1) {
|
|
23
|
+
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
24
|
+
}
|
|
25
|
+
const modulePath = base.slice(0, atIdx);
|
|
26
|
+
if (!modulePath.includes("/")) {
|
|
27
|
+
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
28
|
+
}
|
|
29
|
+
const rawVersion = base.slice(atIdx + 1);
|
|
30
|
+
const version = rawVersion.startsWith("v") ? rawVersion.slice(1) : rawVersion;
|
|
31
|
+
if (!version) {
|
|
32
|
+
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
33
|
+
}
|
|
34
|
+
return { modulePath, version, integrity };
|
|
35
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry-source.d.ts","sourceRoot":"","sources":["../../src/sources/registry-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"registry-source.d.ts","sourceRoot":"","sources":["../../src/sources/registry-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAM7E,qBAAa,cAAe,YAAW,cAAc;IACvC,OAAO,CAAC,WAAW;gBAAX,WAAW,SAAuB;IAEtD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAuBxE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAMvD,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,aAAa;CAGtB"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_MANIFEST_FILENAME } from "../types.js";
|
|
2
|
+
import { splitIntegrity, verifiedFetch } from "./integrity.js";
|
|
3
|
+
import { isRegistryRef, parseModuleRef } from "./module-ref.js";
|
|
2
4
|
const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
|
|
3
5
|
export class RegistrySource {
|
|
4
6
|
registryUrl;
|
|
@@ -6,20 +8,12 @@ export class RegistrySource {
|
|
|
6
8
|
this.registryUrl = registryUrl;
|
|
7
9
|
}
|
|
8
10
|
supports(url) {
|
|
9
|
-
return (
|
|
10
|
-
!url.startsWith("https://") &&
|
|
11
|
-
!url.startsWith("/") &&
|
|
12
|
-
!url.startsWith(".") &&
|
|
13
|
-
url.includes("@") &&
|
|
14
|
-
url.includes("/"));
|
|
11
|
+
return isRegistryRef(url);
|
|
15
12
|
}
|
|
16
13
|
async read(moduleRef) {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
throw new Error(`Failed to fetch manifest ${moduleRef}: ${response.status} ${response.statusText}`);
|
|
21
|
-
}
|
|
22
|
-
const text = await response.text();
|
|
14
|
+
const { base, integrity } = splitIntegrity(moduleRef);
|
|
15
|
+
const fetchUrl = this.toRegistryUrl(base);
|
|
16
|
+
const { text } = await verifiedFetch(fetchUrl, integrity, base);
|
|
23
17
|
// Some object-storage backends (e.g. Cloudflare R2 / S3) surface auth or
|
|
24
18
|
// permission failures by returning a 200 status with an XML error body.
|
|
25
19
|
// Catch this here so the loader produces a precise error rather than
|
|
@@ -30,7 +24,7 @@ export class RegistrySource {
|
|
|
30
24
|
const detail = codeMatch && messageMatch
|
|
31
25
|
? `${codeMatch[1]}: ${messageMatch[1]}`
|
|
32
26
|
: text.slice(0, 200);
|
|
33
|
-
throw new Error(`Registry returned a non-manifest response for ${
|
|
27
|
+
throw new Error(`Registry returned a non-manifest response for ${base} ` +
|
|
34
28
|
`(URL: ${fetchUrl}): ${detail}`);
|
|
35
29
|
}
|
|
36
30
|
return { text, source: fetchUrl };
|
|
@@ -41,27 +35,11 @@ export class RegistrySource {
|
|
|
41
35
|
return new URL(relative, baseWithSlash).href;
|
|
42
36
|
}
|
|
43
37
|
toRegistryModuleBase(moduleRef) {
|
|
44
|
-
const parsed =
|
|
38
|
+
const parsed = parseModuleRef(moduleRef);
|
|
45
39
|
const normalizedBase = this.registryUrl.replace(/\/+$/, "");
|
|
46
40
|
return `${normalizedBase}/${parsed.modulePath}/${parsed.version}`;
|
|
47
41
|
}
|
|
48
42
|
toRegistryUrl(moduleRef) {
|
|
49
43
|
return `${this.toRegistryModuleBase(moduleRef)}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
50
44
|
}
|
|
51
|
-
parseModuleRef(moduleRef) {
|
|
52
|
-
const atIdx = moduleRef.lastIndexOf("@");
|
|
53
|
-
if (atIdx <= 0 || atIdx === moduleRef.length - 1) {
|
|
54
|
-
throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
|
|
55
|
-
}
|
|
56
|
-
const modulePath = moduleRef.slice(0, atIdx);
|
|
57
|
-
if (!modulePath.includes("/")) {
|
|
58
|
-
throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
|
|
59
|
-
}
|
|
60
|
-
const rawVersion = moduleRef.slice(atIdx + 1);
|
|
61
|
-
const version = rawVersion.startsWith("v") ? rawVersion.substring(1) : rawVersion;
|
|
62
|
-
if (!version) {
|
|
63
|
-
throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
|
|
64
|
-
}
|
|
65
|
-
return { modulePath, version };
|
|
66
|
-
}
|
|
67
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
49
49
|
"typescript": "^5.0.0",
|
|
50
50
|
"vitest": "^2.1.8",
|
|
51
|
-
"@telorun/sdk": "0.
|
|
51
|
+
"@telorun/sdk": "0.41.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@telorun/sdk": "*"
|
package/src/builtins.ts
CHANGED
|
@@ -227,6 +227,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
227
227
|
additionalProperties: true,
|
|
228
228
|
},
|
|
229
229
|
source: { type: "string" },
|
|
230
|
+
integrity: { type: "string" },
|
|
230
231
|
variables: { type: "object" },
|
|
231
232
|
secrets: { type: "object" },
|
|
232
233
|
runtime: {
|
|
@@ -361,6 +362,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
361
362
|
type: "array",
|
|
362
363
|
items: { type: "string" },
|
|
363
364
|
},
|
|
365
|
+
// Integrity hash of the decompressed payload tar (`module.tar.gz`,
|
|
366
|
+
// telo.yaml excluded), written by `telo publish`. Pinned transitively
|
|
367
|
+
// by the importer's `#sha256-...` hash over this telo.yaml; verified at
|
|
368
|
+
// extract time. See plans/federated-registries.md.
|
|
369
|
+
filesIntegrity: { type: "string" },
|
|
364
370
|
// Inline imports — name-keyed map sugar for separate `Telo.Import`
|
|
365
371
|
// documents. The key is the PascalCase alias (the import's
|
|
366
372
|
// `metadata.name`). Each value is either a bare source string
|
|
@@ -378,6 +384,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
378
384
|
required: ["source"],
|
|
379
385
|
properties: {
|
|
380
386
|
source: { type: "string" },
|
|
387
|
+
integrity: { type: "string" },
|
|
381
388
|
variables: { type: "object" },
|
|
382
389
|
secrets: { type: "object" },
|
|
383
390
|
runtime: {
|
|
@@ -492,6 +499,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
492
499
|
type: "array",
|
|
493
500
|
items: { type: "string" },
|
|
494
501
|
},
|
|
502
|
+
// Integrity hash of the decompressed payload tar (`module.tar.gz`,
|
|
503
|
+
// telo.yaml excluded), written by `telo publish`. Pinned transitively
|
|
504
|
+
// by the importer's `#sha256-...` hash over this telo.yaml; verified at
|
|
505
|
+
// extract time. See plans/federated-registries.md.
|
|
506
|
+
filesIntegrity: { type: "string" },
|
|
495
507
|
// Inline imports — same name-keyed map sugar as Telo.Application; the
|
|
496
508
|
// loader desugars each entry into a synthetic Telo.Import. See the
|
|
497
509
|
// Application schema above and analyzer/nodejs/src/inline-imports.ts.
|
|
@@ -505,6 +517,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
505
517
|
required: ["source"],
|
|
506
518
|
properties: {
|
|
507
519
|
source: { type: "string" },
|
|
520
|
+
integrity: { type: "string" },
|
|
508
521
|
variables: { type: "object" },
|
|
509
522
|
secrets: { type: "object" },
|
|
510
523
|
runtime: {
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,15 @@ export type { DocumentPosition } from "./position-metadata.js";
|
|
|
53
53
|
export { HttpSource } from "./sources/http-source.js";
|
|
54
54
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
55
55
|
export { defaultSources } from "./sources/default-sources.js";
|
|
56
|
+
export {
|
|
57
|
+
splitIntegrity,
|
|
58
|
+
foldIntegrity,
|
|
59
|
+
verifyIntegrity,
|
|
60
|
+
verifiedFetch,
|
|
61
|
+
sha256Base64Url,
|
|
62
|
+
} from "./sources/integrity.js";
|
|
63
|
+
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
64
|
+
export type { ParsedModuleRef } from "./sources/module-ref.js";
|
|
56
65
|
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
57
66
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
58
67
|
export type {
|
package/src/inline-imports.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { LoadedFile } from "./loaded-types.js";
|
|
3
|
+
import { foldIntegrity } from "./sources/integrity.js";
|
|
3
4
|
import { isModuleKind } from "./module-kinds.js";
|
|
4
5
|
import type { DocumentPosition } from "./position-metadata.js";
|
|
5
6
|
import type { PositionIndex } from "./types.js";
|
|
@@ -42,10 +43,15 @@ export function inlineImportManifests(
|
|
|
42
43
|
: undefined;
|
|
43
44
|
if (!entry || typeof entry.source !== "string") continue;
|
|
44
45
|
|
|
46
|
+
// The object form carries integrity as a sibling `integrity:` field; fold it
|
|
47
|
+
// into the source string as a `#sha256-...` fragment so every downstream
|
|
48
|
+
// consumer sees a single representation (the scalar form already inlines it).
|
|
49
|
+
const source = foldIntegrity(entry.source, entry.integrity);
|
|
50
|
+
|
|
45
51
|
const manifest = {
|
|
46
52
|
kind: "Telo.Import",
|
|
47
53
|
metadata: { name: alias },
|
|
48
|
-
source
|
|
54
|
+
source,
|
|
49
55
|
...(entry.variables !== undefined ? { variables: entry.variables } : {}),
|
|
50
56
|
...(entry.secrets !== undefined ? { secrets: entry.secrets } : {}),
|
|
51
57
|
...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_MANIFEST_FILENAME, type ManifestSource } from "../types.js";
|
|
2
|
+
import { splitIntegrity, verifiedFetch } from "./integrity.js";
|
|
2
3
|
|
|
3
4
|
export class HttpSource implements ManifestSource {
|
|
4
5
|
supports(url: string): boolean {
|
|
@@ -6,14 +7,10 @@ export class HttpSource implements ManifestSource {
|
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
async read(url: string): Promise<{ text: string; source: string }> {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`Failed to fetch manifest from ${fetchUrl}: ${response.status} ${response.statusText}`,
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
return { text: await response.text(), source: fetchUrl };
|
|
10
|
+
const { base, integrity } = splitIntegrity(url);
|
|
11
|
+
const fetchUrl = base.includes(".yaml") ? base : `${base}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
12
|
+
const { text } = await verifiedFetch(fetchUrl, integrity, fetchUrl);
|
|
13
|
+
return { text, source: fetchUrl };
|
|
17
14
|
}
|
|
18
15
|
|
|
19
16
|
resolveRelative(base: string, relative: string): string {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Inline module integrity — the `#sha256-<base64url>` fragment carried on a
|
|
2
|
+
* remote import ref. Browser-safe: uses Web Crypto (`crypto.subtle`) and
|
|
3
|
+
* `btoa`, both globals in Node and the browser. No Node built-ins.
|
|
4
|
+
*
|
|
5
|
+
* The fragment is authoritative across every transport: a source's `read()`
|
|
6
|
+
* hashes the fetched bytes and compares against it before the manifest is
|
|
7
|
+
* parsed or cached. A mismatch is a terminal error — never a cache miss. */
|
|
8
|
+
|
|
9
|
+
/** Only a `#<alg>-<base64url>` suffix is treated as integrity; other `#`
|
|
10
|
+
* fragments (rare in module refs) pass through untouched. `sha256` is the
|
|
11
|
+
* only algorithm accepted today; the prefix leaves room to migrate. */
|
|
12
|
+
const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
|
|
13
|
+
|
|
14
|
+
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
15
|
+
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
16
|
+
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
17
|
+
export function splitIntegrity(ref: string): { base: string; integrity?: string } {
|
|
18
|
+
const match = ref.match(INTEGRITY_FRAGMENT);
|
|
19
|
+
if (!match) return { base: ref };
|
|
20
|
+
return { base: ref.slice(0, match.index), integrity: match[1] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Attach an integrity hash to a ref as a `#<alg>-...` fragment. No-op when the
|
|
24
|
+
* hash is absent/non-string or the ref already carries a fragment (an
|
|
25
|
+
* author-authored pin is never overwritten). Inverse of `splitIntegrity`;
|
|
26
|
+
* used to fold the object form's `integrity:` sibling into the source string. */
|
|
27
|
+
export function foldIntegrity(source: string, integrity: unknown): string {
|
|
28
|
+
return typeof integrity === "string" && !source.includes("#")
|
|
29
|
+
? `${source}#${integrity}`
|
|
30
|
+
: source;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
36
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Normalize an encoded digest to unpadded base64url so a standard-base64 or
|
|
40
|
+
* padded input still compares equal to our canonical form. */
|
|
41
|
+
function normalizeDigest(value: string): string {
|
|
42
|
+
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SHA-256 of `bytes` as unpadded base64url — the canonical inline-hash form. */
|
|
46
|
+
export async function sha256Base64Url(bytes: Uint8Array): Promise<string> {
|
|
47
|
+
// Copy into a plain ArrayBuffer: a Uint8Array may be backed by a
|
|
48
|
+
// SharedArrayBuffer, which `crypto.subtle.digest` does not accept.
|
|
49
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
50
|
+
new Uint8Array(buffer).set(bytes);
|
|
51
|
+
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
|
52
|
+
return toBase64Url(new Uint8Array(digest));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Hash `bytes` and compare against `integrity` (`<alg>-<digest>`). Throws a
|
|
56
|
+
* terminal error on mismatch or an unsupported algorithm. `describe` names the
|
|
57
|
+
* artifact in the error (e.g. the module ref) so the failure is actionable. */
|
|
58
|
+
export async function verifyIntegrity(
|
|
59
|
+
bytes: Uint8Array,
|
|
60
|
+
integrity: string,
|
|
61
|
+
describe: string,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const dash = integrity.indexOf("-");
|
|
64
|
+
const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
|
|
65
|
+
const expected = dash > 0 ? integrity.slice(dash + 1) : "";
|
|
66
|
+
if (algorithm !== "sha256") {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
|
|
69
|
+
`Only sha256 is supported (sha256-<base64url>).`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const actual = await sha256Base64Url(bytes);
|
|
73
|
+
if (actual !== normalizeDigest(expected)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
|
|
76
|
+
`got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
|
|
77
|
+
`the module may have been tampered with or republished.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The single verified network read for remote manifests: fetch `fetchUrl`,
|
|
83
|
+
* verify the raw bytes against `integrity` (when pinned), and return both the
|
|
84
|
+
* bytes and the decoded text. The one choke point every network `ManifestSource`
|
|
85
|
+
* routes through, so verification cannot drift between them. `describe` names
|
|
86
|
+
* the artifact in error messages. */
|
|
87
|
+
export async function verifiedFetch(
|
|
88
|
+
fetchUrl: string,
|
|
89
|
+
integrity: string | undefined,
|
|
90
|
+
describe: string,
|
|
91
|
+
): Promise<{ bytes: Uint8Array; text: string }> {
|
|
92
|
+
const response = await fetch(fetchUrl);
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Failed to fetch manifest ${describe}: ${response.status} ${response.statusText} (${fetchUrl})`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
99
|
+
if (integrity) await verifyIntegrity(bytes, integrity, describe);
|
|
100
|
+
return { bytes, text: new TextDecoder().decode(bytes) };
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { splitIntegrity } from "./integrity.js";
|
|
2
|
+
|
|
3
|
+
/** A parsed registry module reference. `modulePath` is `namespace/name`,
|
|
4
|
+
* `version` has any leading `v` stripped, and `integrity` carries the inline
|
|
5
|
+
* `sha256-<base64url>` hash when the ref was pinned. */
|
|
6
|
+
export interface ParsedModuleRef {
|
|
7
|
+
modulePath: string;
|
|
8
|
+
version: string;
|
|
9
|
+
integrity?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
13
|
+
* (no scheme, no leading `/` or `.`, contains both `@` and `/`). The integrity
|
|
14
|
+
* fragment, if any, does not affect the classification. */
|
|
15
|
+
export function isRegistryRef(url: string): boolean {
|
|
16
|
+
const { base } = splitIntegrity(url);
|
|
17
|
+
return (
|
|
18
|
+
!base.startsWith("http://") &&
|
|
19
|
+
!base.startsWith("https://") &&
|
|
20
|
+
!base.startsWith("/") &&
|
|
21
|
+
!base.startsWith(".") &&
|
|
22
|
+
!base.startsWith("file://") &&
|
|
23
|
+
!base.startsWith("memory://") &&
|
|
24
|
+
base.includes("@") &&
|
|
25
|
+
base.includes("/")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
|
|
30
|
+
* source of truth shared by the registry source, the kernel manifest cache,
|
|
31
|
+
* and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
|
|
32
|
+
export function parseModuleRef(ref: string): ParsedModuleRef {
|
|
33
|
+
const { base, integrity } = splitIntegrity(ref);
|
|
34
|
+
const atIdx = base.lastIndexOf("@");
|
|
35
|
+
if (atIdx <= 0 || atIdx === base.length - 1) {
|
|
36
|
+
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
37
|
+
}
|
|
38
|
+
const modulePath = base.slice(0, atIdx);
|
|
39
|
+
if (!modulePath.includes("/")) {
|
|
40
|
+
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
41
|
+
}
|
|
42
|
+
const rawVersion = base.slice(atIdx + 1);
|
|
43
|
+
const version = rawVersion.startsWith("v") ? rawVersion.slice(1) : rawVersion;
|
|
44
|
+
if (!version) {
|
|
45
|
+
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
46
|
+
}
|
|
47
|
+
return { modulePath, version, integrity };
|
|
48
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_MANIFEST_FILENAME, type ManifestSource } from "../types.js";
|
|
2
|
+
import { splitIntegrity, verifiedFetch } from "./integrity.js";
|
|
3
|
+
import { isRegistryRef, parseModuleRef } from "./module-ref.js";
|
|
2
4
|
|
|
3
5
|
const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
|
|
4
6
|
|
|
@@ -6,25 +8,13 @@ export class RegistrySource implements ManifestSource {
|
|
|
6
8
|
constructor(private registryUrl = DEFAULT_REGISTRY_URL) {}
|
|
7
9
|
|
|
8
10
|
supports(url: string): boolean {
|
|
9
|
-
return (
|
|
10
|
-
!url.startsWith("http://") &&
|
|
11
|
-
!url.startsWith("https://") &&
|
|
12
|
-
!url.startsWith("/") &&
|
|
13
|
-
!url.startsWith(".") &&
|
|
14
|
-
url.includes("@") &&
|
|
15
|
-
url.includes("/")
|
|
16
|
-
);
|
|
11
|
+
return isRegistryRef(url);
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
async read(moduleRef: string): Promise<{ text: string; source: string }> {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
throw new Error(
|
|
24
|
-
`Failed to fetch manifest ${moduleRef}: ${response.status} ${response.statusText}`,
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
const text = await response.text();
|
|
15
|
+
const { base, integrity } = splitIntegrity(moduleRef);
|
|
16
|
+
const fetchUrl = this.toRegistryUrl(base);
|
|
17
|
+
const { text } = await verifiedFetch(fetchUrl, integrity, base);
|
|
28
18
|
// Some object-storage backends (e.g. Cloudflare R2 / S3) surface auth or
|
|
29
19
|
// permission failures by returning a 200 status with an XML error body.
|
|
30
20
|
// Catch this here so the loader produces a precise error rather than
|
|
@@ -37,7 +27,7 @@ export class RegistrySource implements ManifestSource {
|
|
|
37
27
|
? `${codeMatch[1]}: ${messageMatch[1]}`
|
|
38
28
|
: text.slice(0, 200);
|
|
39
29
|
throw new Error(
|
|
40
|
-
`Registry returned a non-manifest response for ${
|
|
30
|
+
`Registry returned a non-manifest response for ${base} ` +
|
|
41
31
|
`(URL: ${fetchUrl}): ${detail}`,
|
|
42
32
|
);
|
|
43
33
|
}
|
|
@@ -51,7 +41,7 @@ export class RegistrySource implements ManifestSource {
|
|
|
51
41
|
}
|
|
52
42
|
|
|
53
43
|
private toRegistryModuleBase(moduleRef: string): string {
|
|
54
|
-
const parsed =
|
|
44
|
+
const parsed = parseModuleRef(moduleRef);
|
|
55
45
|
const normalizedBase = this.registryUrl.replace(/\/+$/, "");
|
|
56
46
|
return `${normalizedBase}/${parsed.modulePath}/${parsed.version}`;
|
|
57
47
|
}
|
|
@@ -59,24 +49,4 @@ export class RegistrySource implements ManifestSource {
|
|
|
59
49
|
private toRegistryUrl(moduleRef: string): string {
|
|
60
50
|
return `${this.toRegistryModuleBase(moduleRef)}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
61
51
|
}
|
|
62
|
-
|
|
63
|
-
private parseModuleRef(moduleRef: string): { modulePath: string; version: string } {
|
|
64
|
-
const atIdx = moduleRef.lastIndexOf("@");
|
|
65
|
-
if (atIdx <= 0 || atIdx === moduleRef.length - 1) {
|
|
66
|
-
throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const modulePath = moduleRef.slice(0, atIdx);
|
|
70
|
-
if (!modulePath.includes("/")) {
|
|
71
|
-
throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const rawVersion = moduleRef.slice(atIdx + 1);
|
|
75
|
-
const version = rawVersion.startsWith("v") ? rawVersion.substring(1) : rawVersion;
|
|
76
|
-
if (!version) {
|
|
77
|
-
throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return { modulePath, version };
|
|
81
|
-
}
|
|
82
52
|
}
|