graphify-sf 0.4.2 → 0.4.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/README.md CHANGED
@@ -3,7 +3,12 @@
3
3
  Install and run [graphify-sf](https://github.com/raykuonz/graphify-sf) — the Salesforce SFDX
4
4
  knowledge-graph tool — from npm, without needing Python.
5
5
 
6
- On install, a pre-built binary matching your OS/arch is downloaded from the GitHub Release.
6
+ The pre-built binary for your OS/arch arrives with the install, inside a platform package
7
+ (`@graphify-sf/cli-<os>-<arch>`, an optional dependency); no download is needed. If that package
8
+ is missing (`--omit=optional`, or a registry mirror without the `@graphify-sf` scope), the binary
9
+ is downloaded from the GitHub Release instead. That download is checked against the sha256 shipped
10
+ in this package (`checksums.json`) and is refused if it doesn't match. Packages are published
11
+ from CI with npm provenance.
7
12
 
8
13
  ## Install
9
14
 
@@ -14,8 +19,8 @@ pnpm add graphify-sf
14
19
  ```
15
20
 
16
21
  > **pnpm note:** pnpm sets `ignore-scripts=true` by default, so the `postinstall` step may not
17
- > run automatically. The binary is downloaded lazily on first use instead, so everything still
18
- > works — you may just see a one-time download message the first time you invoke the CLI.
22
+ > run automatically. That only matters on the fallback path above: the binary is then downloaded
23
+ > (and verified) lazily on first use, so you may see a one-time download message.
19
24
 
20
25
  ## CLI usage
21
26
 
@@ -37,8 +42,9 @@ console.log("Graph written to:", graphJsonPath);
37
42
  `runGraphify` returns a Promise that resolves with `{ code, graphJsonPath }` on success and
38
43
  rejects on non-zero exit.
39
44
 
40
- Additional exports: `ensureBinary()` (downloads if missing, returns path), `binaryPath()`
41
- (returns expected path without triggering a download).
45
+ Additional exports: `ensureBinary()` (resolves the binary, downloading and verifying it if
46
+ needed, and returns its path), `binaryPath()` (returns the fallback download path without
47
+ triggering a download).
42
48
 
43
49
  ## What's bundled
44
50
 
package/checksums.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "graphify-sf-darwin-arm64": "03096680fad912233f923d2eb25680e58cc483c0209e05dbb1878fc768912357",
3
+ "graphify-sf-darwin-x64": "f9f0a90b078b0450a1518596b90f2f37661b441d5db076637522d490e008ddb7",
4
+ "graphify-sf-linux-arm64": "8124d48ab7348086ab5c8e36a09fda7db5e0f82d6194fc2b27a58b9d509bcd32",
5
+ "graphify-sf-linux-x64": "9f708521f324f9018fae163d43704bf9f08412a649aa4f51e30fdbca492cc04e",
6
+ "graphify-sf-win32-x64.exe": "26645b75318621eaf5ef2620c0701c0ed250b1cd170d9e1ea13023909f40163a"
7
+ }
package/lib/download.js CHANGED
@@ -1,12 +1,20 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("crypto");
3
4
  const https = require("https");
4
5
  const fs = require("fs");
5
6
  const path = require("path");
7
+ const { Transform } = require("stream");
8
+ const { pipeline } = require("stream/promises");
6
9
 
7
10
  const PKG = require("../package.json");
8
11
  const VERSION = PKG.version;
9
12
 
13
+ // Release binaries are ~16-30 MB; anything far larger is not ours.
14
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024;
15
+ // Abort a connection that stalls for this long (no bytes in either direction).
16
+ const DOWNLOAD_IDLE_TIMEOUT_MS = 60 * 1000;
17
+
10
18
  const SUPPORTED = {
11
19
  linux: ["x64", "arm64"],
12
20
  darwin: ["x64", "arm64"],
@@ -50,12 +58,55 @@ function downloadUrl(asset) {
50
58
  return `https://github.com/raykuonz/graphify-sf/releases/download/v${VERSION}/${asset}`;
51
59
  }
52
60
 
61
+ function checksumsPath() {
62
+ return path.join(__dirname, "..", "checksums.json");
63
+ }
64
+
65
+ // sha256 the release workflow recorded for `asset`. checksums.json is written
66
+ // at publish time and ships inside the npm tarball, which lockfiles pin by
67
+ // integrity; GitHub release assets can be replaced under an existing tag, so a
68
+ // downloaded binary is trusted only if it matches. Throws when there is no
69
+ // usable entry (e.g. a source checkout), so callers fail closed.
70
+ function expectedSha256(asset, file) {
71
+ let table = null;
72
+ try {
73
+ table = JSON.parse(fs.readFileSync(file || checksumsPath(), "utf8"));
74
+ } catch {
75
+ /* missing or unreadable: handled below */
76
+ }
77
+ const digest = table && Object.hasOwn(table, asset) ? table[asset] : undefined;
78
+ if (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
79
+ throw new Error(
80
+ `graphify-sf: no published sha256 for ${asset} (checksums.json missing or incomplete); ` +
81
+ `refusing to download an unverifiable binary. ` +
82
+ `Remediation: install via pip/pipx instead: pipx install graphify-sf, ` +
83
+ `or point GRAPHIFY_BIN at an existing binary`
84
+ );
85
+ }
86
+ return digest;
87
+ }
88
+
89
+ function sha256File(file) {
90
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
91
+ }
92
+
93
+ // True only if `dest` exists and matches the published sha256 for this platform.
94
+ function verifiedCachedBinary(dest, file) {
95
+ if (!fs.existsSync(dest)) return false;
96
+ try {
97
+ return sha256File(dest) === expectedSha256(assetName(), file);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
53
103
  function fetchFollow(url, redirects) {
54
104
  if (redirects === undefined) redirects = 5;
55
105
  return new Promise((resolve, reject) => {
56
- https
106
+ const req = https
57
107
  .get(url, { headers: { "User-Agent": "graphify-sf-npm-installer" } }, (res) => {
58
108
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
109
+ res.resume(); // discard the redirect body so the socket is released
59
110
  if (redirects === 0) {
60
111
  reject(new Error(`graphify-sf: too many redirects downloading ${url}`));
61
112
  return;
@@ -64,6 +115,7 @@ function fetchFollow(url, redirects) {
64
115
  return;
65
116
  }
66
117
  if (res.statusCode !== 200) {
118
+ res.resume();
67
119
  reject(
68
120
  new Error(
69
121
  `graphify-sf: download failed with HTTP ${res.statusCode} from ${url}. ` +
@@ -75,49 +127,72 @@ function fetchFollow(url, redirects) {
75
127
  resolve(res);
76
128
  })
77
129
  .on("error", reject);
130
+ req.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, () =>
131
+ req.destroy(new Error(`graphify-sf: download stalled for ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000}s at ${url}`))
132
+ );
133
+ });
134
+ }
135
+
136
+ // Pass-through that errors once more than `max` bytes have flowed.
137
+ function byteLimit(max, url) {
138
+ let seen = 0;
139
+ return new Transform({
140
+ transform(chunk, _encoding, callback) {
141
+ seen += chunk.length;
142
+ if (seen > max) {
143
+ callback(new Error(`graphify-sf: download from ${url} exceeded ${max} bytes; refusing it`));
144
+ return;
145
+ }
146
+ callback(null, chunk);
147
+ },
78
148
  });
79
149
  }
80
150
 
81
- async function download() {
151
+ // Download this platform's binary from GitHub Releases, verifying its sha256
152
+ // against checksums.json before it is renamed into place. The hash is taken
153
+ // from the bytes on disk, in a temp file private to this call, so concurrent
154
+ // first runs cannot interleave writes into a file another one verified.
155
+ // `opts` (dest, checksumsPath, fetch, maxBytes) exists for tests; production
156
+ // callers pass nothing. Progress goes to stderr so it never mixes into the
157
+ // CLI's stdout (e.g. `graphify-sf ... | jq`, or runGraphify with piped stdio).
158
+ async function download(opts) {
159
+ const o = opts || {};
82
160
  const asset = assetName();
161
+ const expected = expectedSha256(asset, o.checksumsPath); // fails before any network request
83
162
  const url = downloadUrl(asset);
84
- const dest = binaryPath();
85
-
86
- fs.mkdirSync(binDir(), { recursive: true });
163
+ const dest = o.dest || binaryPath();
164
+ const fetch = o.fetch || fetchFollow;
87
165
 
88
- process.stdout.write(`graphify-sf: downloading ${asset} from ${url}\n`);
166
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
89
167
 
90
- const res = await fetchFollow(url);
168
+ process.stderr.write(`graphify-sf: downloading ${asset} from ${url}\n`);
91
169
 
92
- await new Promise((resolve, reject) => {
93
- const tmp = dest + ".tmp";
94
- const out = fs.createWriteStream(tmp);
95
- res.pipe(out);
96
- out.on("finish", () => {
97
- out.close(() => {
98
- try {
99
- fs.renameSync(tmp, dest);
100
- resolve();
101
- } catch (e) {
102
- reject(e);
103
- }
104
- });
105
- });
106
- out.on("error", (e) => {
107
- fs.unlink(tmp, () => {});
108
- reject(e);
109
- });
110
- res.on("error", (e) => {
111
- fs.unlink(tmp, () => {});
112
- reject(e);
113
- });
114
- });
170
+ const res = await fetch(url);
115
171
 
116
- if (process.platform !== "win32") {
117
- fs.chmodSync(dest, 0o755);
172
+ const tmp = `${dest}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
173
+ try {
174
+ // pipeline destroys every stream on error or premature close; "wx" refuses
175
+ // to reuse an existing path.
176
+ await pipeline(res, byteLimit(o.maxBytes || MAX_DOWNLOAD_BYTES, url), fs.createWriteStream(tmp, { flags: "wx" }));
177
+ const actual = sha256File(tmp);
178
+ if (actual !== expected) {
179
+ throw new Error(
180
+ `graphify-sf: sha256 mismatch for ${asset} from ${url} ` +
181
+ `(expected ${expected}, got ${actual}); refusing to install it. ` +
182
+ `Remediation: install via pip/pipx instead: pipx install graphify-sf`
183
+ );
184
+ }
185
+ // Executable before it becomes visible at `dest`.
186
+ if (process.platform !== "win32") {
187
+ fs.chmodSync(tmp, 0o755);
188
+ }
189
+ fs.renameSync(tmp, dest);
190
+ } catch (e) {
191
+ fs.rmSync(tmp, { force: true });
192
+ throw e;
118
193
  }
119
194
 
120
- process.stdout.write(`graphify-sf: installed to ${dest}\n`);
195
+ process.stderr.write(`graphify-sf: installed to ${dest}\n`);
121
196
  }
122
197
 
123
198
  // Resolve a user-supplied binary path from the GRAPHIFY_BIN environment
@@ -189,7 +264,9 @@ function subpackageBinary() {
189
264
  return null;
190
265
  }
191
266
 
192
- async function ensureBinary() {
267
+ async function ensureBinary(opts) {
268
+ const o = opts || {};
269
+
193
270
  // 1. Explicit override always wins.
194
271
  const fromEnv = envBinary();
195
272
  if (fromEnv) {
@@ -203,14 +280,17 @@ async function ensureBinary() {
203
280
  return fromSubpkg;
204
281
  }
205
282
 
206
- // 3. Already-downloaded binary in the package's bin/ dir (legacy cache).
207
- const dest = binaryPath();
208
- if (fs.existsSync(dest)) {
283
+ // 3. A binary an earlier run downloaded into bin/, but only while it still
284
+ // matches the published sha256. A stale or tampered file is left for
285
+ // download() to replace atomically (rename); it is never returned, and
286
+ // deleting it here could race another process's freshly verified rename.
287
+ const dest = o.dest || binaryPath();
288
+ if (verifiedCachedBinary(dest, o.checksumsPath)) {
209
289
  return dest;
210
290
  }
211
291
 
212
- // 4. Last resort: lazily download from GitHub Releases on first use.
213
- await download();
292
+ // 4. Last resort: lazily download (and verify) from GitHub Releases.
293
+ await download(o);
214
294
  return dest;
215
295
  }
216
296
 
@@ -221,6 +301,8 @@ module.exports = {
221
301
  download,
222
302
  ensureBinary,
223
303
  envBinary,
304
+ expectedSha256,
224
305
  subpackageName,
225
306
  subpackageBinary,
307
+ verifiedCachedBinary,
226
308
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphify-sf",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Turn any Salesforce SFDX project into a queryable knowledge graph — no Python required.",
5
5
  "bin": {
6
6
  "graphify-sf": "bin/graphify-sf.js"
@@ -11,17 +11,18 @@
11
11
  "test": "node --test"
12
12
  },
13
13
  "optionalDependencies": {
14
- "@graphify-sf/cli-linux-x64": "0.4.2",
15
- "@graphify-sf/cli-linux-arm64": "0.4.2",
16
- "@graphify-sf/cli-darwin-x64": "0.4.2",
17
- "@graphify-sf/cli-darwin-arm64": "0.4.2",
18
- "@graphify-sf/cli-win32-x64": "0.4.2"
14
+ "@graphify-sf/cli-linux-x64": "0.4.3",
15
+ "@graphify-sf/cli-linux-arm64": "0.4.3",
16
+ "@graphify-sf/cli-darwin-x64": "0.4.3",
17
+ "@graphify-sf/cli-darwin-arm64": "0.4.3",
18
+ "@graphify-sf/cli-win32-x64": "0.4.3"
19
19
  },
20
20
  "files": [
21
- "bin/",
21
+ "bin/graphify-sf.js",
22
22
  "lib/",
23
23
  "index.js",
24
24
  "install.js",
25
+ "checksums.json",
25
26
  "README.md"
26
27
  ],
27
28
  "engines": {