forked from foxglove/extension-registry
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate.ts
More file actions
213 lines (192 loc) · 7.37 KB
/
validate.ts
File metadata and controls
213 lines (192 loc) · 7.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import JSZip from "jszip";
import registryJson from "./extensions.json";
import crypto from "node:crypto";
import path from "node:path";
import * as core from "@actions/core";
import semver from "semver";
function warning(message: string) {
core.warning(message, { file: "extensions.json" });
}
function error(message: string) {
core.error(message, { file: "extensions.json" });
process.exitCode = core.ExitCode.Failure;
}
/**
* We require newly submitted extensions to use at least this version of create-foxglove-extension.
*
* 1.0.3: fixed a bug where packaged extensions were not compressed
*/
const createFoxgloveExtensionMinVersion = "1.0.3";
/**
* SHA sums of extensions that are exempt from the version check. For these we will only log a warning.
* For other (newer) ones it will be an error if compression is not used.
*/
const exemptExtensionsSHAs = [
"fa2b11af8ed7c420ca6e541196bca608661c0c1a81cd1f768c565c72a55a63c8",
"ac07f5f84b96ad1139b4d66b685b864cf5713081e198e63fccef7a0546dd1ab2",
"1193589eb2779a1224328defca4e2ca378ef786474be1842ac43b674b9535d82",
"f03d7a517ba6f7d57564eec965358055c87285a8a504d5488af969767a76c4ab",
"71ae6c6ffa4b86aa427d14fcc86af437fdca4b5e10cd714e70f95e1af324ceaf",
"87e0a4fe397974fb2e3771f370009ccdbe195498d4792c04bc08b2e2482bda71",
"6afa4437c45cb5b60a99405e40b0c51b4edfcc0f9a640532cc218962da1ee2b8",
"b5c02cb834005affcf1873a6a481281a73428d04062bbf763863c8fb7e64fc95",
"12863df314ec682e23ebb74598e3e3e89e610fa960f3461b5e91a6896ed8228f",
"bfbb33b91f337a25de79449f9b529bc94bfaf908187f39cef24d70d4e1083434",
"5b2dbe3280a7833c8d827e4f3b8acfa4df9cf4cd16a5db037885a6cfcdd42041",
"249be37fe7c42a2bf647b344f639b755956b5f656e232770b13353dea75c7696",
"3c1ba8195e31809939c89ce73320cdc47147f012f41b050029f6aae4dabc7c93",
];
/**
* Check that a URL points at a plain-text file (Markdown). This helps avoid accidentally pointing
* at an HTML page (like a non-raw github.com URL)
*/
async function validateMarkdownUrl(url: string, id: string, name: string) {
if (url.startsWith("https://github.com/")) {
error(
`${id}: Invalid ${name} URL: use raw.githubusercontent.com instead of github.com`
);
return;
}
const response = await fetch(url);
const contentType = response.headers.get("content-type");
if (!response.ok) {
error(
`${id}: Invalid ${name} URL: expected status 200, got ${response.status} ${response.statusText}`
);
return;
}
if (contentType == undefined || !contentType.startsWith("text/plain")) {
error(
`${id}: Invalid ${name} URL: expected content-type text/plain, got: ${contentType}`
);
}
}
async function validateExtension(extension: (typeof registryJson)[number]) {
console.log(`\nValidating ${extension.id}...`);
const foxeResponse = await fetch(extension.foxe);
if (foxeResponse.status !== 200) {
error(
`Extension ${extension.id} has invalid foxe URL. Expected 200 response, got ${foxeResponse.status}`
);
return;
}
const foxeContent = await foxeResponse.arrayBuffer();
const sha256sum = crypto
.createHash("sha256")
.update(new DataView(foxeContent))
.digest("hex");
if (sha256sum !== extension.sha256sum) {
error(
`${extension.id}:Digest mismatch for ${extension.foxe}, expected: ${extension.sha256sum}, actual: ${sha256sum}`
);
return;
}
const zip = await JSZip.loadAsync(foxeContent, { checkCRC32: true });
const packageJsonContent = await zip.file("package.json")?.async("string");
if (!packageJsonContent) {
error(`${extension.id}: Missing package.json`);
return;
}
const packageJson = JSON.parse(packageJsonContent);
if (packageJson.name == undefined || packageJson.name.length === 0) {
error(`${extension.id}: Invalid package.json: missing name`);
return;
}
const createFoxgloveExtensionRange =
packageJson.devDependencies?.["create-foxglove-extension"] ??
packageJson.devDependencies?.["@foxglove/fox"];
if (!createFoxgloveExtensionRange) {
error(
`${extension.id}: Invalid package.json: expected create-foxglove-extension in devDependencies`
);
return;
}
const actualMinVersion = semver.minVersion(createFoxgloveExtensionRange);
if (!actualMinVersion) {
error(
`${extension.id}: Invalid package.json: unable to determine min version of create-foxglove-extension`
);
return;
}
if (!semver.gte(actualMinVersion, createFoxgloveExtensionMinVersion)) {
const message = `${extension.id}: must use create-foxglove-extension ${createFoxgloveExtensionMinVersion}, found ${createFoxgloveExtensionRange}`;
if (exemptExtensionsSHAs.includes(extension.sha256sum)) {
warning(message);
} else {
error(message);
return;
}
} else if (exemptExtensionsSHAs.includes(extension.sha256sum)) {
error(
`The following SHA should be removed from exemptExtensionsSHAs: ${extension.sha256sum}`
);
}
const mainPath = packageJson.main;
if (typeof mainPath !== "string") {
error(`${extension.id}: Invalid package.json: missing "main" field`);
return;
}
// Normalize the package.json:main field so we can lookup the file in the zip archive.
// This turns ./dist/extension.js into dist/extension.js because having a leading `./` is not
// supported by jszip.
const normalized = path.normalize(mainPath);
const srcText = await zip.file(normalized)?.async("string");
if (srcText == undefined) {
error(
`${extension.id}: Extension ${extension.foxe} is corrupted: unable to extract main JS file`
);
return;
}
await validateMarkdownUrl(extension.readme, extension.id, "readme");
await validateMarkdownUrl(extension.changelog, extension.id, "changelog");
}
function parseArgs(): { ids: string[] | undefined } {
const args = process.argv.slice(2);
const idsIndex = args.indexOf("--ids");
const idsArg = idsIndex !== -1 ? args[idsIndex + 1] : undefined;
if (idsArg != undefined) {
// Handle both comma-separated and newline-separated IDs
return { ids: idsArg.split(/[,\n]/).filter((id) => id.length > 0) };
}
return { ids: undefined };
}
async function main() {
const { ids: targetIds } = parseArgs();
// Determine which extensions to validate
let extensionsToValidate: typeof registryJson;
if (targetIds != undefined) {
// Targeted validation: only validate specified extensions
extensionsToValidate = registryJson.filter((ext) =>
targetIds.includes(ext.id)
);
// Warn if any requested IDs were not found
const foundIds = new Set(extensionsToValidate.map((ext) => ext.id));
for (const id of targetIds) {
if (!foundIds.has(id)) {
error(`Extension ID not found in registry: ${id}`);
}
}
console.log(
`Validating ${extensionsToValidate.length} changed extension(s): ${extensionsToValidate.map((e) => e.id).join(", ")}`
);
} else {
// Full validation: check all extensions and unused SHAs
extensionsToValidate = registryJson;
const unusedSHAs = exemptExtensionsSHAs.filter(
(sha) => !registryJson.find((ext) => ext.sha256sum === sha)
);
for (const sha of unusedSHAs) {
error(
`The following SHA should be removed from exemptExtensionSHAs: ${sha}`
);
}
console.log(`Validating all ${extensionsToValidate.length} extensions...`);
}
for (const extension of extensionsToValidate) {
await validateExtension(extension);
}
}
void main().catch((err) => {
console.error(err);
process.exit(1);
});