-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathinstall.js
More file actions
382 lines (348 loc) · 10.5 KB
/
install.js
File metadata and controls
382 lines (348 loc) · 10.5 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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
#!/usr/bin/env node
"use strict";
// notion-cli npm postinstall script.
//
// Security model:
// 1. Fetch SHA256SUMS for the release before any binary.
// 2. Download the binary, hashing as it streams.
// 3. Compare the computed hash to the entry in SHA256SUMS.
// 4. Only persist + chmod +x the binary if the hash matches; otherwise
// delete any partial file and exit non-zero.
//
// Two failure classes, treated differently:
// - Transient (network unreachable, 5xx, 404 because release hasn't
// propagated, etc.): warn and exit 0 so `npm install` is not broken on
// CI. The user can `npm run rebuild` later or build from source.
// - Active-attack signal (downloaded bytes do not match the signed
// checksum): hard-fail with non-zero exit. Never write the bad binary.
//
// All redirects are restricted to github.com and *.githubusercontent.com,
// and only https:// is followed. http:// redirects abort. Response bodies
// are capped at MAX_DOWNLOAD_BYTES.
const https = require("https");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { URL } = require("url");
const PLATFORM_MAP = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const ARCH_MAP = {
arm64: "arm64",
x64: "amd64",
};
const MAX_REDIRECTS = 5;
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB — far above any legit binary
const MAX_CHECKSUM_BYTES = 16 * 1024; // SHA256SUMS for 5 binaries is < 1 KB
function isAllowedHost(host) {
if (!host) return false;
const lower = host.toLowerCase();
if (lower === "github.com") return true;
if (lower === "objects.githubusercontent.com") return true;
if (lower.endsWith(".githubusercontent.com")) return true;
return false;
}
function getVersion() {
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8")
);
return pkg.version;
} catch {
return "6.0.0";
}
}
function checkPlatformPackage() {
const platformKey = `${process.platform}-${process.arch}`;
const pkgName = `@coastal-programs/notion-cli-${platformKey}`;
try {
require.resolve(`${pkgName}/package.json`);
// Platform package exists, no need to download
return true;
} catch {
return false;
}
}
// fetchURL streams an https URL, enforcing redirect-host pinning, https-only,
// and a maximum-body size. The body is delivered to onData(chunk) and the
// returned promise resolves with { ok, status } once the stream ends.
function fetchURL(targetURL, onData) {
return new Promise((resolve, reject) => {
const visit = (urlString, redirects) => {
let parsed;
try {
parsed = new URL(urlString);
} catch (err) {
reject(new Error(`invalid URL: ${err.message}`));
return;
}
if (parsed.protocol !== "https:") {
reject(
new Error(
`refusing to follow non-https URL (got ${parsed.protocol}//${parsed.host})`
)
);
return;
}
if (!isAllowedHost(parsed.hostname)) {
reject(new Error(`refusing redirect to disallowed host: ${parsed.hostname}`));
return;
}
const req = https.get(
urlString,
{ headers: { "User-Agent": "notion-cli-npm" } },
(res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirects >= MAX_REDIRECTS) {
res.resume();
reject(new Error("too many redirects"));
return;
}
// Resolve relative redirects against the current URL.
let next;
try {
next = new URL(res.headers.location, urlString).toString();
} catch (err) {
res.resume();
reject(new Error(`invalid redirect target: ${err.message}`));
return;
}
res.resume();
visit(next, redirects + 1);
return;
}
if (status !== 200) {
res.resume();
resolve({ ok: false, status });
return;
}
let received = 0;
let aborted = false;
res.on("data", (chunk) => {
if (aborted) return;
received += chunk.length;
if (received > MAX_DOWNLOAD_BYTES) {
aborted = true;
res.destroy();
reject(
new Error(
`response exceeded max size of ${MAX_DOWNLOAD_BYTES} bytes`
)
);
return;
}
try {
onData(chunk);
} catch (err) {
aborted = true;
res.destroy();
reject(err);
}
});
res.on("end", () => {
if (!aborted) {
resolve({ ok: true, status });
}
});
res.on("error", (err) => {
if (!aborted) reject(err);
});
}
);
req.on("error", reject);
};
visit(targetURL, 0);
});
}
async function fetchText(url, cap) {
const limit = cap || MAX_CHECKSUM_BYTES;
const chunks = [];
let total = 0;
const result = await fetchURL(url, (chunk) => {
total += chunk.length;
if (total > limit) {
throw new Error(`text response exceeded ${limit} bytes`);
}
chunks.push(chunk);
});
if (!result.ok) {
return { ok: false, status: result.status, body: "" };
}
return { ok: true, status: result.status, body: Buffer.concat(chunks).toString("utf8") };
}
// parseChecksums parses lines of "<hex> <filename>" (sha256sum -b or text mode)
// and returns a map of filename -> hex.
function parseChecksums(text) {
const out = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
// Format: "<64-hex-chars> <filename>" (two spaces; '*' instead of ' ' for binary mode)
const m = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (!m) continue;
out[m[2]] = m[1].toLowerCase();
}
return out;
}
async function downloadBinary() {
if (checkPlatformPackage()) {
return 0;
}
const os = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!os || !arch) {
console.warn(
`[notion-cli] Unsupported platform: ${process.platform}-${process.arch}`
);
console.warn("[notion-cli] You may need to build from source.");
return 0;
}
const version = getVersion();
const ext = process.platform === "win32" ? ".exe" : "";
const binaryName = `notion-cli-${os}-${arch}${ext}`;
const base = `https://github.com/Coastal-Programs/notion-cli/releases/download/v${version}`;
const checksumURL = `${base}/SHA256SUMS`;
const binaryURL = `${base}/${binaryName}`;
const destDir = path.join(
__dirname,
"node_modules",
".cache",
"notion-cli",
"bin"
);
const destPath = path.join(
destDir,
process.platform === "win32" ? "notion-cli.exe" : "notion-cli"
);
try {
fs.mkdirSync(destDir, { recursive: true });
} catch {
// Directory may already exist
}
console.log(`[notion-cli] Downloading binary for ${os}/${arch}...`);
// ---- Step 1: fetch the signed checksum manifest ----
let checksums;
try {
const sums = await fetchText(checksumURL);
if (!sums.ok) {
console.warn(
`[notion-cli] SHA256SUMS not available (HTTP ${sums.status}).`
);
console.warn(
"[notion-cli] Skipping postinstall download. Build from source: make build"
);
return 0;
}
checksums = parseChecksums(sums.body);
} catch (err) {
console.warn(`[notion-cli] Failed to fetch SHA256SUMS: ${err.message}`);
console.warn("[notion-cli] You can build from source: make build");
return 0;
}
const expected = checksums[binaryName];
if (!expected) {
console.warn(
`[notion-cli] No checksum entry for ${binaryName} in SHA256SUMS.`
);
console.warn(
"[notion-cli] Refusing to install an unverified binary. Build from source: make build"
);
return 0;
}
// ---- Step 2: stream the binary, hashing as we go, into a temp file ----
const tmpPath = `${destPath}.partial`;
// Clean any stale partial from a previous failed run.
try {
fs.unlinkSync(tmpPath);
} catch {
// not present
}
const file = fs.createWriteStream(tmpPath);
const hash = crypto.createHash("sha256");
let downloadResult;
try {
downloadResult = await fetchURL(binaryURL, (chunk) => {
hash.update(chunk);
file.write(chunk);
});
} catch (err) {
try {
file.destroy();
} catch {
// best-effort
}
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort
}
console.warn(`[notion-cli] Download failed: ${err.message}`);
console.warn("[notion-cli] You can build from source: make build");
return 0;
}
// Wait for the file stream to flush before checking the hash.
await new Promise((resolve, reject) => {
file.end(() => resolve());
file.on("error", reject);
});
if (!downloadResult.ok) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort
}
console.warn(
`[notion-cli] Binary not available (HTTP ${downloadResult.status}).`
);
console.warn(
"[notion-cli] You may need to build from source: make build"
);
return 0;
}
const actual = hash.digest("hex");
if (actual !== expected) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort
}
// This is an active-attack signal, not a transient network failure.
// Hard-fail: never persist a binary whose hash does not match.
console.error(
`[notion-cli] CHECKSUM MISMATCH for ${binaryName}: expected ${expected}, got ${actual}`
);
console.error(
"[notion-cli] Refusing to install. Report this at https://github.com/Coastal-Programs/notion-cli/issues"
);
return 1;
}
// ---- Step 3: atomic rename + chmod ----
try {
fs.renameSync(tmpPath, destPath);
if (process.platform !== "win32") {
fs.chmodSync(destPath, 0o755);
}
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort
}
console.warn(`[notion-cli] Failed to finalize binary: ${err.message}`);
return 0;
}
console.log("[notion-cli] Binary installed and checksum-verified.");
return 0;
}
downloadBinary()
.then((code) => {
if (code !== 0) {
process.exit(code);
}
})
.catch(() => {
// Unexpected error path: do not break npm install for transient issues.
});