iterate-skill-installer 3.5.2 → 3.5.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/bin/cli.js +27 -7
- package/lib/installer.js +231 -82
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -42,28 +42,48 @@ Examples:
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async function run() {
|
|
45
|
-
|
|
45
|
+
let options;
|
|
46
|
+
try {
|
|
47
|
+
options = parseArgs(process.argv.slice(2));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Hard argument errors are THROWN by parseArgs (library code never calls
|
|
50
|
+
// process.exit); report them cleanly at this boundary.
|
|
51
|
+
console.error(`Error: ${err.message}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
if (options.mode === 'help') {
|
|
48
56
|
printHelp();
|
|
49
|
-
|
|
57
|
+
return 0;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
if (options.mode === 'version') {
|
|
53
61
|
console.log(`iterate-skill-installer ${VERSION}`);
|
|
54
|
-
|
|
62
|
+
return 0;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
try {
|
|
58
66
|
const code = await main(options);
|
|
59
67
|
// Normalize: a null/non-numeric code (main should never produce one, but a
|
|
60
68
|
// signal-killed child upstream does) must surface as a failure, never as
|
|
61
|
-
// the success exit 0 that
|
|
62
|
-
|
|
69
|
+
// the success exit 0 that a null exit code would yield.
|
|
70
|
+
return typeof code === 'number' ? code : 1;
|
|
63
71
|
} catch (err) {
|
|
64
72
|
console.error(`\nUnexpected error: ${err.message}`);
|
|
65
|
-
|
|
73
|
+
return 1;
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
// CLI boundary: set process.exitCode rather than calling process.exit(), so
|
|
78
|
+
// buffered stdout/stderr still flush and no caller cleanup is skipped. The
|
|
79
|
+
// trailing catch is the top-level net: an unexpected rejection (e.g. a throw
|
|
80
|
+
// before run()'s inner try) becomes a clean message + non-zero code, never an
|
|
81
|
+
// unhandled-rejection stack trace.
|
|
82
|
+
run()
|
|
83
|
+
.then((code) => {
|
|
84
|
+
process.exitCode = typeof code === 'number' ? code : 1;
|
|
85
|
+
})
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
console.error(`\nUnexpected error: ${err && err.message ? err.message : err}`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
});
|
package/lib/installer.js
CHANGED
|
@@ -339,9 +339,14 @@ function validateTokenShape(token) {
|
|
|
339
339
|
* loudly instead of leaking the PAT to a foreign host.
|
|
340
340
|
*/
|
|
341
341
|
function buildAuthFlags(url, token) {
|
|
342
|
-
|
|
342
|
+
// Re-normalize here as well as at the CLI boundary: this is the exact point
|
|
343
|
+
// the token is interpolated into a header, so a raw CR/LF (e.g. a token read
|
|
344
|
+
// from a Windows-created file) must never reach curl — an embedded newline
|
|
345
|
+
// would split the header (CRLF/header injection) or silently break auth.
|
|
346
|
+
const clean = normalizeToken(token);
|
|
347
|
+
if (!clean) return [];
|
|
343
348
|
if (!isGithubApiUrl(url)) return [];
|
|
344
|
-
return ['-H', `Authorization: Bearer ${
|
|
349
|
+
return ['-H', `Authorization: Bearer ${clean}`, '--max-redirs', '0'];
|
|
345
350
|
}
|
|
346
351
|
|
|
347
352
|
async function fetchJson(url, token) {
|
|
@@ -349,7 +354,10 @@ async function fetchJson(url, token) {
|
|
|
349
354
|
// and avoids Node-specific certificate issues in some environments.
|
|
350
355
|
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
351
356
|
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(JSON_MAX_BYTES), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
|
|
352
|
-
|
|
357
|
+
// `--` ends curl's option parsing so a URL that begins with `-` (e.g. a
|
|
358
|
+
// hostile/odd `browser_download_url` served by the API) is treated as a URL,
|
|
359
|
+
// never smuggled in as a curl flag.
|
|
360
|
+
args.push('--', url);
|
|
353
361
|
// stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
|
|
354
362
|
// would truncate a large /releases payload and JSON.parse then failed with a
|
|
355
363
|
// baffling "Unexpected end of JSON input". The cap now matches the
|
|
@@ -381,7 +389,9 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
|
|
|
381
389
|
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
382
390
|
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(maxBytes), '-o', destPath, '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
|
|
383
391
|
if (progress) args.push('--progress-bar');
|
|
384
|
-
|
|
392
|
+
// `--` ends curl's option parsing so a `-`-leading URL can never be read as
|
|
393
|
+
// a curl flag (option injection); it is always the final positional URL.
|
|
394
|
+
args.push('--', url);
|
|
385
395
|
if (progress) {
|
|
386
396
|
// Let curl write its progress bar directly to the terminal.
|
|
387
397
|
await runCommand('curl', args, { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
@@ -429,8 +439,16 @@ function enforceDownloadedSize(destPath, maxBytes) {
|
|
|
429
439
|
// project config and shows up in the invoking environment for audit.
|
|
430
440
|
const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
|
|
431
441
|
|
|
442
|
+
// Only these (case-insensitive) values enable the override. A bare "any
|
|
443
|
+
// non-empty string" check meant `ITERATE_ALLOW_UNSIGNED=0` (or "false", or a
|
|
444
|
+
// stray "no") ENABLED unsigned installs — the exact opposite of what the
|
|
445
|
+
// operator intended, and a silent downgrade of the authenticity gate.
|
|
446
|
+
const ALLOW_UNSIGNED_TRUTHY = new Set(['1', 'true', 'yes']);
|
|
447
|
+
|
|
432
448
|
function allowUnsigned() {
|
|
433
|
-
|
|
449
|
+
const raw = process.env[ALLOW_UNSIGNED_ENV];
|
|
450
|
+
if (raw == null) return false;
|
|
451
|
+
return ALLOW_UNSIGNED_TRUTHY.has(String(raw).trim().toLowerCase());
|
|
434
452
|
}
|
|
435
453
|
|
|
436
454
|
/**
|
|
@@ -499,6 +517,31 @@ function sha256File(filePath) {
|
|
|
499
517
|
});
|
|
500
518
|
}
|
|
501
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Constant-time, case-insensitive comparison of two SHA256 hex digests.
|
|
522
|
+
*
|
|
523
|
+
* Casing must never decide the outcome: `sha256sum`/other tools may emit
|
|
524
|
+
* upper-case hex, and a case-sensitive `===` would reject a perfectly good
|
|
525
|
+
* release. Both sides are lower-cased and compared with `timingSafeEqual` —
|
|
526
|
+
* the expected hash travels over the network beside the tarball, so an
|
|
527
|
+
* early-exit string inequality would leak prefix information usable to forge a
|
|
528
|
+
* checksum. The length guard keeps `timingSafeEqual` from throwing on
|
|
529
|
+
* mismatched lengths (an empty/short value is refused outright).
|
|
530
|
+
*
|
|
531
|
+
* Exported so the comparison itself is unit-testable (the inline version it
|
|
532
|
+
* replaces could only be exercised by a full network install).
|
|
533
|
+
*/
|
|
534
|
+
function constantTimeHashEqual(actualHash, expectedHash) {
|
|
535
|
+
if (typeof actualHash !== 'string' || typeof expectedHash !== 'string') return false;
|
|
536
|
+
const actualBuf = Buffer.from(actualHash.trim().toLowerCase(), 'utf8');
|
|
537
|
+
const expectedBuf = Buffer.from(expectedHash.trim().toLowerCase(), 'utf8');
|
|
538
|
+
return (
|
|
539
|
+
actualBuf.length === expectedBuf.length &&
|
|
540
|
+
actualBuf.length > 0 &&
|
|
541
|
+
crypto.timingSafeEqual(actualBuf, expectedBuf)
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
502
545
|
// Best-effort GPG authenticity verification of a downloaded artifact against
|
|
503
546
|
// the embedded signing key. Returns one of:
|
|
504
547
|
// 'verified' — `gpg` present and the detached signature is valid.
|
|
@@ -747,77 +790,118 @@ function runCommand(bin, args, options = {}) {
|
|
|
747
790
|
|
|
748
791
|
async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
|
|
749
792
|
// Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
|
|
750
|
-
// link entry
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
// a
|
|
793
|
+
// link entry — and any device ("b" block / "c" character), FIFO ("p") or
|
|
794
|
+
// socket special node. We deliberately reject *all* links rather than
|
|
795
|
+
// validating their targets: the release tarball contains none, so any link is
|
|
796
|
+
// a sign of a tampered archive and refusing it outright is the safest
|
|
797
|
+
// response to a symlink-escape attempt (see extractTarball above). Special
|
|
798
|
+
// nodes are rejected for the same reason: neither the release nor its
|
|
799
|
+
// installer ever ships one, and a FIFO/device could wedge or bypass a later
|
|
800
|
+
// read of the extracted tree.
|
|
754
801
|
//
|
|
755
802
|
// stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
|
|
756
803
|
// tail buffer silently dropped the OLDEST entries, so an archive with enough
|
|
757
804
|
// filler members to push its listing past the cap hid a symlink/hardlink
|
|
758
805
|
// entry from this scan entirely (while `tar -xzf` still extracted it).
|
|
759
806
|
// A listing over the cap now fails closed instead.
|
|
807
|
+
//
|
|
808
|
+
// Returns the COMPLETE verbose listing so callers (extractTarball) can reuse
|
|
809
|
+
// it for the pre-extraction declared-size check without a second tar run.
|
|
760
810
|
const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
|
|
761
811
|
stdoutCap: listingCap,
|
|
762
812
|
stdoutMustBeComplete: true,
|
|
763
813
|
});
|
|
814
|
+
const TYPE_LABELS = {
|
|
815
|
+
l: 'symbolic link',
|
|
816
|
+
h: 'hard link',
|
|
817
|
+
b: 'block device node',
|
|
818
|
+
c: 'character device node',
|
|
819
|
+
p: 'FIFO',
|
|
820
|
+
s: 'socket',
|
|
821
|
+
};
|
|
764
822
|
for (const line of verbose.split(/\r?\n/)) {
|
|
765
823
|
if (!line) {
|
|
766
824
|
continue;
|
|
767
825
|
}
|
|
768
826
|
const type = line[0];
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
'Refusing to extract tarball: archive contains a symbolic link, which the release never ships.',
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
if (type === 'h') {
|
|
827
|
+
const label = TYPE_LABELS[type];
|
|
828
|
+
if (label) {
|
|
775
829
|
throw new InstallerError(
|
|
776
|
-
|
|
830
|
+
`Refusing to extract tarball: archive contains a ${label}, which the release never ships.`,
|
|
777
831
|
);
|
|
778
832
|
}
|
|
779
833
|
}
|
|
834
|
+
return verbose;
|
|
780
835
|
}
|
|
781
836
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
// Extraction uses --strip-components=1, which relies on the archive having
|
|
790
|
-
// exactly one top-level directory (see README "Release tarball structure").
|
|
791
|
-
// Archives that violate that contract — multiple top-level directories, or
|
|
792
|
-
// entries with no path left after stripping — are rejected outright.
|
|
793
|
-
//
|
|
794
|
-
// A name-based `../` check alone is not enough: a tarball can smuggle a
|
|
795
|
-
// symlink whose in-archive name looks safe but whose target points outside
|
|
796
|
-
// destDir, then place files "through" that symlink (e.g. `link/passwd` where
|
|
797
|
-
// `link -> /etc`). Because extraction happens with a system `tar`, the write
|
|
798
|
-
// would follow the link before we could validate — so any symbolic or hard
|
|
799
|
-
// link is rejected *before* extraction. The release tarball never uses links.
|
|
800
|
-
const absoluteDest = path.resolve(destDir);
|
|
801
|
-
fs.mkdirSync(absoluteDest, { recursive: true });
|
|
837
|
+
// A month abbreviation ("Jan".."Dec") appears in the timestamp column of the
|
|
838
|
+
// bsdtar verbose listing; GNU tar prints an ISO date ("2023-01-01"). Both mark
|
|
839
|
+
// the end of the size/user fields, so the numeric token immediately before the
|
|
840
|
+
// first such token is the entry's declared uncompressed size in EITHER format.
|
|
841
|
+
const MONTH_ABBR_RE = /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/;
|
|
842
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
802
843
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
844
|
+
/**
|
|
845
|
+
* Parse one `tar -tvzf` line's declared uncompressed size, or null when the
|
|
846
|
+
* line cannot be parsed. `size` is the numeric field immediately preceding the
|
|
847
|
+
* timestamp column (see MONTH_ABBR_RE / ISO_DATE_RE).
|
|
848
|
+
*/
|
|
849
|
+
function declaredEntrySize(line) {
|
|
850
|
+
const tokens = line.trim().split(/\s+/);
|
|
851
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
852
|
+
if (ISO_DATE_RE.test(tokens[i]) || MONTH_ABBR_RE.test(tokens[i])) {
|
|
853
|
+
const candidate = tokens[i - 1];
|
|
854
|
+
return /^\d+$/.test(candidate) ? Number(candidate) : null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Sum the declared uncompressed sizes in a `tar -tvzf` listing.
|
|
862
|
+
*
|
|
863
|
+
* This is the PRE-extraction half of the decompression-bomb guard: `tar -xzf`
|
|
864
|
+
* has no size bound and the download cap only limits the COMPRESSED size, so a
|
|
865
|
+
* small highly-compressible archive can declare (and would expand to) many GiB.
|
|
866
|
+
* Reading the declared sizes out of the listing lets us refuse BEFORE writing
|
|
867
|
+
* anything to disk. Returns the total plus how many entries were parsed so a
|
|
868
|
+
* caller can distinguish "empty" from "unparseable format".
|
|
869
|
+
*/
|
|
870
|
+
function sumDeclaredSizes(verboseListing) {
|
|
871
|
+
let total = 0;
|
|
872
|
+
let parsed = 0;
|
|
873
|
+
let entries = 0;
|
|
874
|
+
for (const line of verboseListing.split(/\r?\n/)) {
|
|
875
|
+
if (!line.trim()) continue;
|
|
876
|
+
entries += 1;
|
|
877
|
+
const size = declaredEntrySize(line);
|
|
878
|
+
if (size !== null) {
|
|
879
|
+
total += size;
|
|
880
|
+
parsed += 1;
|
|
881
|
+
}
|
|
813
882
|
}
|
|
883
|
+
return { total, parsed, entries };
|
|
884
|
+
}
|
|
814
885
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
886
|
+
/**
|
|
887
|
+
* Validate every archive member NAME before extraction.
|
|
888
|
+
*
|
|
889
|
+
* Rejects, in order:
|
|
890
|
+
* - absolute paths (`/etc/...`, `C:\...`), which invite ambiguity between
|
|
891
|
+
* tar implementations about what the "first component" is;
|
|
892
|
+
* - any `..` path component (path traversal), which — even when the
|
|
893
|
+
* top-level-strip logic happens to keep it inside destDir — violates the
|
|
894
|
+
* release's "single top-level directory" contract and must never appear;
|
|
895
|
+
* - archives without exactly one top-level directory (required by
|
|
896
|
+
* --strip-components=1);
|
|
897
|
+
* - entries that still escape destDir after the top-level component is
|
|
898
|
+
* stripped.
|
|
899
|
+
*
|
|
900
|
+
* Pure and exported so the guards are unit-testable without crafting hostile
|
|
901
|
+
* tarballs. Returns the resolved top-level directory name.
|
|
902
|
+
*/
|
|
903
|
+
function validateEntryNames(entries, absoluteDest) {
|
|
904
|
+
// Reject absolute-path entries outright before any other accounting.
|
|
821
905
|
for (const entry of entries) {
|
|
822
906
|
if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
|
|
823
907
|
throw new InstallerError(
|
|
@@ -826,15 +910,20 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
|
|
|
826
910
|
}
|
|
827
911
|
}
|
|
828
912
|
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
|
|
913
|
+
// Reject any ``..`` component. A name-based resolve() check below already
|
|
914
|
+
// catches escapes, but an explicit rejection is unambiguous and also refuses
|
|
915
|
+
// the "stripped away" cases (e.g. a lone `..` top-level) that resolve back
|
|
916
|
+
// inside destDir while still violating the archive contract.
|
|
917
|
+
for (const entry of entries) {
|
|
918
|
+
if (entry.split('/').includes('..')) {
|
|
919
|
+
throw new InstallerError(
|
|
920
|
+
`Refusing to extract tarball: entry "${entry}" contains a ".." path component.`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
835
924
|
|
|
836
|
-
//
|
|
837
|
-
//
|
|
925
|
+
// Enforce the single-top-level-directory contract required by
|
|
926
|
+
// --strip-components=1.
|
|
838
927
|
const topLevelComponents = new Set(entries.map((entry) => entry.split('/')[0]));
|
|
839
928
|
if (topLevelComponents.size !== 1) {
|
|
840
929
|
const tops = [...topLevelComponents].join(', ');
|
|
@@ -844,8 +933,8 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
|
|
|
844
933
|
}
|
|
845
934
|
const topLevel = entries[0].split('/')[0];
|
|
846
935
|
|
|
847
|
-
//
|
|
848
|
-
//
|
|
936
|
+
// Validate every entry stays inside destDir after stripping the single
|
|
937
|
+
// top-level directory component (--strip-components=1).
|
|
849
938
|
for (const entry of entries) {
|
|
850
939
|
const stripped = entry.split('/').slice(1).join('/');
|
|
851
940
|
if (stripped === '') {
|
|
@@ -867,7 +956,73 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
|
|
|
867
956
|
}
|
|
868
957
|
}
|
|
869
958
|
|
|
959
|
+
return topLevel;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function extractTarball(
|
|
963
|
+
tarballPath,
|
|
964
|
+
destDir,
|
|
965
|
+
{ listingCap = MAX_LISTING_CHARS, declaredSizeCap = MAX_EXTRACTED_BYTES } = {},
|
|
966
|
+
) {
|
|
967
|
+
// Use the system tar command (available on macOS, Linux, and Windows 10+),
|
|
968
|
+
// but defend against path-traversal tarballs: before extracting we list the
|
|
969
|
+
// archive contents and refuse to extract any entry whose resolved path
|
|
970
|
+
// escapes destDir (e.g. a "../" prefix). This keeps the installer
|
|
971
|
+
// dependency-free while closing the theoretical escape vector.
|
|
972
|
+
//
|
|
973
|
+
// Extraction uses --strip-components=1, which relies on the archive having
|
|
974
|
+
// exactly one top-level directory (see README "Release tarball structure").
|
|
975
|
+
// Archives that violate that contract — multiple top-level directories, or
|
|
976
|
+
// entries with no path left after stripping — are rejected outright.
|
|
977
|
+
//
|
|
978
|
+
// A name-based `../` check alone is not enough: a tarball can smuggle a
|
|
979
|
+
// symlink whose in-archive name looks safe but whose target points outside
|
|
980
|
+
// destDir, then place files "through" that symlink (e.g. `link/passwd` where
|
|
981
|
+
// `link -> /etc`). Because extraction happens with a system `tar`, the write
|
|
982
|
+
// would follow the link before we could validate — so any symbolic or hard
|
|
983
|
+
// link is rejected *before* extraction. The release tarball never uses links.
|
|
984
|
+
// Note: dest is created AFTER every guard below (not before), so a refused
|
|
985
|
+
// archive never leaves a directory behind. `tar -xzf` requires the target to
|
|
986
|
+
// exist, so mkdir stays inside this function rather than with the caller.
|
|
987
|
+
const absoluteDest = path.resolve(destDir);
|
|
988
|
+
|
|
989
|
+
// 1. List archive entries (no extraction yet). Must be complete — see
|
|
990
|
+
// rejectLinkEntries: every guard below reads THIS listing, and a tail
|
|
991
|
+
// buffer would let a big archive hide entries from all of them.
|
|
992
|
+
const listing = await runCommand('tar', ['-tzf', tarballPath], {
|
|
993
|
+
stdoutCap: listingCap,
|
|
994
|
+
stdoutMustBeComplete: true,
|
|
995
|
+
});
|
|
996
|
+
const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
997
|
+
if (entries.length === 0) {
|
|
998
|
+
throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// 1a/2/3. Reject absolute paths and `..` components, enforce the single
|
|
1002
|
+
// top-level directory, and prove every entry stays inside destDir.
|
|
1003
|
+
validateEntryNames(entries, absoluteDest);
|
|
1004
|
+
|
|
1005
|
+
// 1b. Reject any symbolic/hard link — and any device/FIFO/socket node —
|
|
1006
|
+
// before touching the tree. The verbose listing's first character is the
|
|
1007
|
+
// entry type for both GNU tar and bsdtar (macOS): '-' regular, 'd' directory,
|
|
1008
|
+
// 'l' symbolic link, 'h' hard link, 'b'/'c' device, 'p' FIFO. The COMPLETE
|
|
1009
|
+
// listing is returned so the declared-size check below can reuse it.
|
|
1010
|
+
const verbose = await rejectLinkEntries(tarballPath, { listingCap });
|
|
1011
|
+
|
|
1012
|
+
// 1c. PRE-extraction decompression-bomb check: refuse an archive whose
|
|
1013
|
+
// declared uncompressed total exceeds the budget, using the sizes from
|
|
1014
|
+
// the listing we already parsed. This fails before a single byte is
|
|
1015
|
+
// written, complementing the post-extraction re-measure in step 5.
|
|
1016
|
+
const declared = sumDeclaredSizes(verbose);
|
|
1017
|
+
if (declared.total > declaredSizeCap) {
|
|
1018
|
+
throw new InstallerError(
|
|
1019
|
+
`Refusing to extract tarball: declared uncompressed size ${declared.total} bytes ` +
|
|
1020
|
+
`exceeds the safety budget (${declaredSizeCap} bytes).`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
870
1024
|
// 4. Now extract safely — the content has been validated above.
|
|
1025
|
+
fs.mkdirSync(absoluteDest, { recursive: true });
|
|
871
1026
|
await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
|
|
872
1027
|
|
|
873
1028
|
// 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
|
|
@@ -1164,8 +1319,10 @@ function buildPythonInstallArgs(options) {
|
|
|
1164
1319
|
*
|
|
1165
1320
|
* Kept as a pure function (exported) so the flag surface — including the
|
|
1166
1321
|
* skill-only ``--no-cli`` switch — can be unit-tested without spawning the
|
|
1167
|
-
* full download-and-install pipeline.
|
|
1168
|
-
*
|
|
1322
|
+
* full download-and-install pipeline. Hard argument errors (a value-consuming
|
|
1323
|
+
* flag missing its value, an unknown option) THROW an ``InstallerError`` for
|
|
1324
|
+
* the CLI boundary to report; library code must never call ``process.exit``
|
|
1325
|
+
* (which would skip the caller's cleanup/flush and is untestable in-process).
|
|
1169
1326
|
*/
|
|
1170
1327
|
function parseArgs(argv) {
|
|
1171
1328
|
const options = {
|
|
@@ -1203,16 +1360,14 @@ function parseArgs(argv) {
|
|
|
1203
1360
|
switch (arg) {
|
|
1204
1361
|
case '--ai':
|
|
1205
1362
|
if (!next || next.startsWith('-')) {
|
|
1206
|
-
|
|
1207
|
-
process.exit(1);
|
|
1363
|
+
throw new InstallerError('--ai requires a value (a flag was found instead)');
|
|
1208
1364
|
}
|
|
1209
1365
|
options.ai = next;
|
|
1210
1366
|
i++;
|
|
1211
1367
|
break;
|
|
1212
1368
|
case '--target':
|
|
1213
1369
|
if (!next || next.startsWith('-')) {
|
|
1214
|
-
|
|
1215
|
-
process.exit(1);
|
|
1370
|
+
throw new InstallerError('--target requires a value (a flag was found instead)');
|
|
1216
1371
|
}
|
|
1217
1372
|
if (options.globalExplicit) {
|
|
1218
1373
|
warning('--target overrides --global: installing into the project directory instead.');
|
|
@@ -1240,16 +1395,14 @@ function parseArgs(argv) {
|
|
|
1240
1395
|
break;
|
|
1241
1396
|
case '--token':
|
|
1242
1397
|
if (!next || next.startsWith('-')) {
|
|
1243
|
-
|
|
1244
|
-
process.exit(1);
|
|
1398
|
+
throw new InstallerError('--token requires a value (a flag was found instead)');
|
|
1245
1399
|
}
|
|
1246
1400
|
options.token = normalizeToken(next);
|
|
1247
1401
|
i++;
|
|
1248
1402
|
break;
|
|
1249
1403
|
default:
|
|
1250
1404
|
if (arg.startsWith('-')) {
|
|
1251
|
-
|
|
1252
|
-
process.exit(1);
|
|
1405
|
+
throw new InstallerError(`unknown option ${arg}`);
|
|
1253
1406
|
}
|
|
1254
1407
|
// A bare positional argument is not a valid installer flag; warn so a
|
|
1255
1408
|
// typo like "npx iterate-skill-installer trae" is not silently ignored.
|
|
@@ -1416,16 +1569,8 @@ async function main(options = {}) {
|
|
|
1416
1569
|
return 1;
|
|
1417
1570
|
}
|
|
1418
1571
|
const actualHash = await sha256File(tarballPath);
|
|
1419
|
-
// Constant-time compare (
|
|
1420
|
-
|
|
1421
|
-
// would leak prefix information usable to forge a checksum. The length
|
|
1422
|
-
// guard keeps timingSafeEqual from throwing on mismatched lengths.
|
|
1423
|
-
const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
|
|
1424
|
-
const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
|
|
1425
|
-
const hashMatches =
|
|
1426
|
-
actualBuf.length === expectedBuf.length &&
|
|
1427
|
-
actualBuf.length > 0 &&
|
|
1428
|
-
crypto.timingSafeEqual(actualBuf, expectedBuf);
|
|
1572
|
+
// Constant-time, case-insensitive compare (see constantTimeHashEqual).
|
|
1573
|
+
const hashMatches = constantTimeHashEqual(actualHash, expectedHash);
|
|
1429
1574
|
if (!hashMatches) {
|
|
1430
1575
|
error('Checksum mismatch — refusing to install.');
|
|
1431
1576
|
return 1;
|
|
@@ -1558,9 +1703,13 @@ module.exports = {
|
|
|
1558
1703
|
resolveInstallMode,
|
|
1559
1704
|
askYesNo,
|
|
1560
1705
|
parseChecksums,
|
|
1706
|
+
constantTimeHashEqual,
|
|
1561
1707
|
enforceDownloadedSize,
|
|
1562
1708
|
extractTarball,
|
|
1563
1709
|
rejectLinkEntries,
|
|
1710
|
+
sumDeclaredSizes,
|
|
1711
|
+
declaredEntrySize,
|
|
1712
|
+
validateEntryNames,
|
|
1564
1713
|
isGithubApiUrl,
|
|
1565
1714
|
normalizeToken,
|
|
1566
1715
|
validateTokenShape,
|