@intentius/chant-lexicon-aws 0.41.0 → 0.41.3
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/components/capability-plugin.d.ts +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/generated/index.d.ts +31 -3
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +266 -29
- package/dist/spec/fetch.d.ts +27 -1
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/pinned-types.json +2 -0
- package/dist/types/index.d.ts +358 -25
- package/package.json +2 -2
- package/src/components/capability-plugin.test.ts +19 -0
- package/src/components/capability-plugin.ts +5 -2
- package/src/generated/index.d.ts +358 -25
- package/src/generated/index.ts +34 -6
- package/src/generated/lexicon-aws.json +266 -29
- package/src/spec/fetch.test.ts +101 -1
- package/src/spec/fetch.ts +144 -4
- package/src/spec/pin.ts +3 -3
- package/src/spec/pinned-types.json +2 -0
package/src/spec/fetch.test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { fetchSchemaZip, loadPinnedSchemas, pinAssetName, pinAssetUrl } from "./fetch";
|
|
6
|
+
import { specContentDigest } from "./pin";
|
|
3
7
|
|
|
4
8
|
describe("fetchSchemaZip", () => {
|
|
5
9
|
test("exports fetchSchemaZip function", () => {
|
|
@@ -25,3 +29,99 @@ describe("fetchSchemaZip", () => {
|
|
|
25
29
|
}
|
|
26
30
|
});
|
|
27
31
|
});
|
|
32
|
+
|
|
33
|
+
// #1511 — the pinned release asset is the deterministic source: verified
|
|
34
|
+
// content loads (from local cache or the download), a digest mismatch refuses
|
|
35
|
+
// loudly wherever the bytes came from, and an unreachable asset falls back to
|
|
36
|
+
// the live path by returning undefined.
|
|
37
|
+
describe("loadPinnedSchemas (#1511)", () => {
|
|
38
|
+
const schemaA = Buffer.from(JSON.stringify({ typeName: "AWS::Test::A", properties: {} }));
|
|
39
|
+
const schemaB = Buffer.from(JSON.stringify({ typeName: "AWS::Test::B", properties: {} }));
|
|
40
|
+
|
|
41
|
+
async function makeZip(): Promise<{ zip: Buffer; digest: string }> {
|
|
42
|
+
const { zipSync } = await import("fflate");
|
|
43
|
+
const zip = Buffer.from(zipSync({ "aws-test-a.json": schemaA, "aws-test-b.json": schemaB }));
|
|
44
|
+
const digest = specContentDigest(
|
|
45
|
+
new Map([
|
|
46
|
+
["AWS::Test::A", schemaA],
|
|
47
|
+
["AWS::Test::B", schemaB],
|
|
48
|
+
]),
|
|
49
|
+
);
|
|
50
|
+
return { zip, digest };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("a downloaded asset that digests to the pin loads, and is cached only after verifying", async () => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-spec-pin-"));
|
|
55
|
+
try {
|
|
56
|
+
const { zip, digest } = await makeZip();
|
|
57
|
+
const pin = { digest, resources: 2, accepted: "2026-08-05" };
|
|
58
|
+
let downloads = 0;
|
|
59
|
+
const download = async () => (downloads++, zip);
|
|
60
|
+
|
|
61
|
+
const first = await loadPinnedSchemas({ pin, cacheDir: dir, download });
|
|
62
|
+
expect([...first!.keys()].sort()).toEqual(["AWS::Test::A", "AWS::Test::B"]);
|
|
63
|
+
expect(downloads).toBe(1);
|
|
64
|
+
|
|
65
|
+
// Second load: served from the verified local cache, no download.
|
|
66
|
+
const second = await loadPinnedSchemas({ pin, cacheDir: dir, download });
|
|
67
|
+
expect(second!.size).toBe(2);
|
|
68
|
+
expect(downloads).toBe(1);
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("content that does not digest to the pin throws, naming both digests — and is never cached", async () => {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-spec-pin-"));
|
|
76
|
+
try {
|
|
77
|
+
const { zip } = await makeZip();
|
|
78
|
+
const pin = { digest: "sha256:" + "0".repeat(64), resources: 2, accepted: "2026-08-05" };
|
|
79
|
+
let downloads = 0;
|
|
80
|
+
const download = async () => (downloads++, zip);
|
|
81
|
+
|
|
82
|
+
await expect(loadPinnedSchemas({ pin, cacheDir: dir, download })).rejects.toThrow(
|
|
83
|
+
/extracts to sha256:.*declares sha256:0{8}/s,
|
|
84
|
+
);
|
|
85
|
+
// A second call downloads again: the bad copy must not have been cached.
|
|
86
|
+
await expect(loadPinnedSchemas({ pin, cacheDir: dir, download })).rejects.toThrow();
|
|
87
|
+
expect(downloads).toBe(2);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a corrupted local cache copy refuses the same way — cache is not more trusted than the network", async () => {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-spec-pin-"));
|
|
95
|
+
try {
|
|
96
|
+
const { zip, digest } = await makeZip();
|
|
97
|
+
const pin = { digest, resources: 2, accepted: "2026-08-05" };
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
// A DIFFERENT zip planted at the pin's cache path.
|
|
100
|
+
const { zipSync } = await import("fflate");
|
|
101
|
+
const wrong = Buffer.from(zipSync({ "other.json": Buffer.from(JSON.stringify({ typeName: "AWS::Test::Other" })) }));
|
|
102
|
+
writeFileSync(join(dir, pinAssetName(pin)), wrong);
|
|
103
|
+
|
|
104
|
+
await expect(loadPinnedSchemas({ pin, cacheDir: dir, download: async () => zip })).rejects.toThrow(/extracts to/);
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("unreachable asset: undefined, so the caller falls back to the live fetch", async () => {
|
|
111
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-spec-pin-"));
|
|
112
|
+
try {
|
|
113
|
+
const pin = { digest: "sha256:" + "1".repeat(64), resources: 2, accepted: "2026-08-05" };
|
|
114
|
+
const schemas = await loadPinnedSchemas({ pin, cacheDir: dir, download: async () => undefined });
|
|
115
|
+
expect(schemas).toBeUndefined();
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the asset URL is public, content-addressed, and on the dedicated pin release", () => {
|
|
122
|
+
const pin = { digest: "sha256:" + "ab".repeat(32), resources: 1, accepted: "2026-08-05" };
|
|
123
|
+
expect(pinAssetUrl(pin)).toBe(
|
|
124
|
+
"https://github.com/INTENTIUS/chant/releases/download/aws-spec-pin/abababababab.zip",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/spec/fetch.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { execFile } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
3
6
|
import { fetchWithCache, extractFromZip, clearCacheFile } from "@intentius/chant/codegen/fetch";
|
|
7
|
+
import { ACCEPT_ENV, AWS_SPEC_PIN, specContentDigest, type SpecPin } from "./pin";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Top-level CloudFormation Registry JSON Schema for a single resource type.
|
|
@@ -67,18 +71,154 @@ const SCHEMA_ZIP_URL = "https://schema.cloudformation.us-east-1.amazonaws.com/Cl
|
|
|
67
71
|
const CACHE_DIR = join(homedir(), ".chant");
|
|
68
72
|
const CACHE_FILE = join(CACHE_DIR, "CloudformationSchema.zip");
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The pinned-spec store (chant #1511): assets on one dedicated GitHub release,
|
|
76
|
+
* named by content digest, in a public repo — so nothing binary ever enters
|
|
77
|
+
* git history, and the download needs no auth anywhere (CI included).
|
|
78
|
+
*
|
|
79
|
+
* Why a store at all: `prepack` regenerates from upstream, and the registry
|
|
80
|
+
* serves a single mutable "latest" artifact — on 2026-08-05 four distinct
|
|
81
|
+
* contents were observed in one day, two *contradicting* each other about the
|
|
82
|
+
* same resources, so the surface gate compared artifacts built from whichever
|
|
83
|
+
* variant that fetch happened to hit. Publishing aws was a retry lottery
|
|
84
|
+
* (v0.41.1 and v0.41.2 both stranded it). The accepted content itself is the
|
|
85
|
+
* only deterministic input; the accept uploads it, every build downloads it
|
|
86
|
+
* by digest and verifies before trusting it.
|
|
87
|
+
*/
|
|
88
|
+
const SPEC_PIN_RELEASE_TAG = "aws-spec-pin";
|
|
89
|
+
const SPEC_PIN_REPO = "INTENTIUS/chant";
|
|
90
|
+
/** Verified-by-digest local copies of pin assets, so repeat builds skip the download. Content-addressed: no TTL, never invalidated. */
|
|
91
|
+
const PIN_CACHE_DIR = join(CACHE_DIR, "spec-pin");
|
|
92
|
+
|
|
93
|
+
/** `<first 12 digest hex>.zip` — the asset (and local cache) name for a pin. */
|
|
94
|
+
export function pinAssetName(pin: SpecPin = AWS_SPEC_PIN): string {
|
|
95
|
+
return `${pin.digest.replace(/^sha256:/, "").slice(0, 12)}.zip`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Public, unauthenticated download URL for a pin's asset. */
|
|
99
|
+
export function pinAssetUrl(pin: SpecPin = AWS_SPEC_PIN): string {
|
|
100
|
+
return `https://github.com/${SPEC_PIN_REPO}/releases/download/${SPEC_PIN_RELEASE_TAG}/${pinAssetName(pin)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Injectable downloader, so tests never reach the network. Returns undefined on any failure — the caller falls back to the live fetch. */
|
|
104
|
+
export type PinAssetDownloader = (url: string) => Promise<Buffer | undefined>;
|
|
105
|
+
|
|
106
|
+
const downloadPinAsset: PinAssetDownloader = async (url) => {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
109
|
+
if (!res.ok) return undefined;
|
|
110
|
+
return Buffer.from(await res.arrayBuffer());
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load and verify the pinned spec: the local verified cache first, then the
|
|
118
|
+
* release asset. Returns undefined when neither is available (offline and
|
|
119
|
+
* cold, or the accept never uploaded) — the caller falls back to the live
|
|
120
|
+
* fetch and the pin's existing advisory drift report. Content that does not
|
|
121
|
+
* digest to the pin throws: a tampered or half-uploaded asset must never
|
|
122
|
+
* silently pass as the accepted spec, wherever it came from.
|
|
123
|
+
*/
|
|
124
|
+
export async function loadPinnedSchemas(
|
|
125
|
+
options: { pin?: SpecPin; cacheDir?: string; download?: PinAssetDownloader } = {},
|
|
126
|
+
): Promise<Map<string, Buffer> | undefined> {
|
|
127
|
+
const pin = options.pin ?? AWS_SPEC_PIN;
|
|
128
|
+
const cacheDir = options.cacheDir ?? PIN_CACHE_DIR;
|
|
129
|
+
const cachePath = join(cacheDir, pinAssetName(pin));
|
|
130
|
+
|
|
131
|
+
const verify = async (zipData: Buffer, source: string): Promise<Map<string, Buffer>> => {
|
|
132
|
+
const schemas = await extractRawSchemas(zipData);
|
|
133
|
+
const digest = specContentDigest(schemas);
|
|
134
|
+
if (digest !== pin.digest) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`pinned spec from ${source} extracts to ${digest}, but the pin declares ${pin.digest} — ` +
|
|
137
|
+
`refusing to build from it. Re-run the accept (${ACCEPT_ENV}=1 npm run generate) to ` +
|
|
138
|
+
`upload the content the pin actually names.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return schemas;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (existsSync(cachePath)) {
|
|
145
|
+
return verify(readFileSync(cachePath), cachePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const url = pinAssetUrl(pin);
|
|
149
|
+
const zipData = await (options.download ?? downloadPinAsset)(url);
|
|
150
|
+
if (zipData === undefined) return undefined;
|
|
151
|
+
const schemas = await verify(zipData, url);
|
|
152
|
+
// Cache only after verification, so the cache can never hold a bad copy.
|
|
153
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
154
|
+
writeFileSync(cachePath, zipData);
|
|
155
|
+
return schemas;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The accept flow's upload half: push the just-accepted zip to the pin
|
|
160
|
+
* release via `gh` (dev-machine path — publishes never upload). Creates the
|
|
161
|
+
* rolling `aws-spec-pin` release on first use. Assets are content-addressed
|
|
162
|
+
* and never overwritten. A missing/unauthenticated `gh` degrades to printing
|
|
163
|
+
* the exact command to run by hand — the accept still completes, and the pin
|
|
164
|
+
* block still prints.
|
|
165
|
+
*/
|
|
166
|
+
async function uploadPinAsset(zipData: Buffer, digest: string): Promise<void> {
|
|
167
|
+
const pin: SpecPin = { ...AWS_SPEC_PIN, digest };
|
|
168
|
+
mkdirSync(PIN_CACHE_DIR, { recursive: true });
|
|
169
|
+
const localPath = join(PIN_CACHE_DIR, pinAssetName(pin));
|
|
170
|
+
writeFileSync(localPath, zipData);
|
|
171
|
+
|
|
172
|
+
const run = promisify(execFile);
|
|
173
|
+
try {
|
|
174
|
+
try {
|
|
175
|
+
await run("gh", ["release", "view", SPEC_PIN_RELEASE_TAG, "--repo", SPEC_PIN_REPO]);
|
|
176
|
+
} catch {
|
|
177
|
+
await run("gh", [
|
|
178
|
+
"release", "create", SPEC_PIN_RELEASE_TAG,
|
|
179
|
+
"--repo", SPEC_PIN_REPO,
|
|
180
|
+
"--title", "aws spec pin assets",
|
|
181
|
+
"--notes", "Content-addressed CloudFormation registry archives, one per accepted spec pin (chant #1511). Uploaded by the accept flow; downloaded and digest-verified by every build. Not a chant release.",
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
await run("gh", ["release", "upload", SPEC_PIN_RELEASE_TAG, localPath, "--repo", SPEC_PIN_REPO]);
|
|
185
|
+
console.error(`Accepted spec uploaded: ${pinAssetUrl(pin)}`);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(
|
|
188
|
+
`Could not upload the accepted spec via gh (${err instanceof Error ? err.message.split("\n")[0] : String(err)}).\n` +
|
|
189
|
+
`Upload it yourself before merging the pin:\n` +
|
|
190
|
+
` gh release upload ${SPEC_PIN_RELEASE_TAG} ${localPath} --repo ${SPEC_PIN_REPO}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
70
195
|
/**
|
|
71
196
|
* Fetch the CloudFormation Registry schema zip and extract per-resource JSON schemas.
|
|
72
197
|
* Returns a Map keyed by typeName (e.g. "AWS::S3::Bucket") to raw JSON bytes.
|
|
73
198
|
*
|
|
74
|
-
*
|
|
199
|
+
* chant #1511 — the pinned release asset is the primary source: when it
|
|
200
|
+
* resolves and digest-verifies, generation uses the content a human accepted
|
|
201
|
+
* rather than whatever upstream variant this fetch happens to hit, so every
|
|
202
|
+
* build (CI, prepack, publish) is deterministic. The live fetch remains for:
|
|
203
|
+
* `force`, the accept flow (`CHANT_ACCEPT_AWS_SPEC=1`, which must sample
|
|
204
|
+
* upstream — and bypasses the 24h cache for the same reason), and when the
|
|
205
|
+
* asset is unreachable. The live path keeps its 24h local cache.
|
|
75
206
|
*/
|
|
76
207
|
export async function fetchSchemaZip(force = false): Promise<Map<string, Buffer>> {
|
|
208
|
+
const accepting = !!process.env[ACCEPT_ENV];
|
|
209
|
+
if (!force && !accepting) {
|
|
210
|
+
const pinned = await loadPinnedSchemas();
|
|
211
|
+
if (pinned) return pinned;
|
|
212
|
+
}
|
|
77
213
|
const zipData = await fetchWithCache(
|
|
78
214
|
{ url: SCHEMA_ZIP_URL, cacheFile: CACHE_FILE },
|
|
79
|
-
force,
|
|
215
|
+
force || accepting,
|
|
80
216
|
);
|
|
81
|
-
|
|
217
|
+
const schemas = await extractRawSchemas(zipData);
|
|
218
|
+
if (accepting) {
|
|
219
|
+
await uploadPinAsset(zipData, specContentDigest(schemas));
|
|
220
|
+
}
|
|
221
|
+
return schemas;
|
|
82
222
|
}
|
|
83
223
|
|
|
84
224
|
/**
|
package/src/spec/pin.ts
CHANGED
|
@@ -56,9 +56,9 @@ export interface SpecPin {
|
|
|
56
56
|
* want, and paste the printed pin here in its own commit.
|
|
57
57
|
*/
|
|
58
58
|
export const AWS_SPEC_PIN: SpecPin = {
|
|
59
|
-
digest: "sha256:
|
|
60
|
-
resources:
|
|
61
|
-
accepted: "2026-08-
|
|
59
|
+
digest: "sha256:f05366bcba3160e5992f4ae312286e6f06a87dcbef425b29b39259beadd42ce0",
|
|
60
|
+
resources: 1653,
|
|
61
|
+
accepted: "2026-08-05",
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
/** Env var that accepts whatever upstream currently serves, printing the new pin. */
|
|
@@ -968,6 +968,7 @@
|
|
|
968
968
|
"AWS::Lex::ResourcePolicy",
|
|
969
969
|
"AWS::LicenseManager::Grant",
|
|
970
970
|
"AWS::LicenseManager::License",
|
|
971
|
+
"AWS::LicenseManager::LicenseAssetRuleSet",
|
|
971
972
|
"AWS::Lightsail::Alarm",
|
|
972
973
|
"AWS::Lightsail::Bucket",
|
|
973
974
|
"AWS::Lightsail::Certificate",
|
|
@@ -1236,6 +1237,7 @@
|
|
|
1236
1237
|
"AWS::QuickSight::Template",
|
|
1237
1238
|
"AWS::QuickSight::Theme",
|
|
1238
1239
|
"AWS::QuickSight::Topic",
|
|
1240
|
+
"AWS::QuickSight::TopicV2",
|
|
1239
1241
|
"AWS::QuickSight::VPCConnection",
|
|
1240
1242
|
"AWS::RAM::Permission",
|
|
1241
1243
|
"AWS::RAM::ResourceShare",
|