no-mistakes 0.11.0 → 0.12.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-mistakes",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Static codebase analysis tools for TS/JS dependencies, dependents, and symbols",
5
5
  "license": "MIT",
6
6
  "repository": {
package/report-types.d.ts CHANGED
@@ -49,6 +49,73 @@ export interface ServerRoutesReport {
49
49
  diagnostics: unknown[];
50
50
  }
51
51
 
52
+ export type FetchSourceType =
53
+ | "page"
54
+ | "layout"
55
+ | "loading"
56
+ | "error"
57
+ | "template"
58
+ | "route"
59
+ | "module";
60
+
61
+ export type CacheKind =
62
+ | "none"
63
+ | "fetch-cache"
64
+ | "fetch-next-revalidate"
65
+ | "fetch-next-tags"
66
+ | "react-cache"
67
+ | "cache"
68
+ | "unstable-cache";
69
+
70
+ export interface FetchOccurrence {
71
+ path: string;
72
+ rawPath: string;
73
+ method: string;
74
+ file: string;
75
+ line: number;
76
+ side: "server" | "client";
77
+ rsc: boolean;
78
+ cached: boolean;
79
+ cacheKind: CacheKind;
80
+ cachedFunction?: string;
81
+ dynamic: boolean;
82
+ unsupported: boolean;
83
+ functionName?: string;
84
+ conditional: boolean;
85
+ inPromiseAll: boolean;
86
+ errorHandled: boolean;
87
+ sourceType: FetchSourceType;
88
+ }
89
+
90
+ export interface FetchRouteReport {
91
+ route: string;
92
+ file: string;
93
+ apiCalls: FetchOccurrence[];
94
+ }
95
+
96
+ export interface FetchSummary {
97
+ totalRoutes: number;
98
+ routesWithApiCalls: number;
99
+ totalApiCalls: number;
100
+ uniqueApiCalls: number;
101
+ duplicateApiCalls: number;
102
+ dynamicApiCalls: number;
103
+ cachedApiCalls: number;
104
+ clientApiCalls: number;
105
+ serverApiCalls: number;
106
+ rscApiCalls: number;
107
+ conditionalApiCalls: number;
108
+ parallelApiCalls: number;
109
+ errorHandledApiCalls: number;
110
+ }
111
+
112
+ export interface FetchReport {
113
+ summary: FetchSummary;
114
+ routes: FetchRouteReport[];
115
+ duplicates: unknown[];
116
+ unsupported: unknown[];
117
+ }
118
+
52
119
  export interface ReactComponentFacts {
53
120
  name: string;
54
121
  file: string;
@@ -2,7 +2,28 @@
2
2
 
3
3
  const { basename } = require("node:path");
4
4
 
5
- function assetName(binName, version, target, assetExtension) {
5
+ function assetName(binNameOrOptions, version, target, assetExtension) {
6
+ if (
7
+ typeof binNameOrOptions === "object" &&
8
+ binNameOrOptions !== null &&
9
+ !Array.isArray(binNameOrOptions)
10
+ ) {
11
+ return assetNameFromOptions(binNameOrOptions);
12
+ }
13
+ return assetNameFromLegacyArgs(binNameOrOptions, version, target, assetExtension);
14
+ }
15
+
16
+ function assetNameFromLegacyArgs(binName, version, target, assetExtension) {
17
+ return assetNameFromOptions({ binName, version, target, assetExtension });
18
+ }
19
+
20
+ function assetNameFromOptions({ binName, version, target, assetExtension }) {
21
+ if (typeof binName !== "string" || typeof version !== "string" || typeof target !== "string") {
22
+ throw new TypeError(
23
+ "assetName requires (binName, version, target) or an equivalent options object.",
24
+ );
25
+ }
26
+
6
27
  const ext = assetExtension ?? (target.endsWith("windows-msvc") ? ".exe" : "");
7
28
  return `${binName}-v${version}-${target}${ext}`;
8
29
  }
@@ -9,8 +9,9 @@ const { fileURLToPath } = require("node:url");
9
9
 
10
10
  const DOWNLOAD_TIMEOUT_MS = 30_000;
11
11
 
12
- function download(url, destination, redirects = 0) {
13
- if (url.startsWith("file://")) {
12
+ function download(url, destination, redirects = 0, validateUrl = () => {}) {
13
+ validateUrl(url);
14
+ if (isFileUrl(url)) {
14
15
  return copyFile(fileURLToPath(url), destination);
15
16
  }
16
17
 
@@ -20,6 +21,9 @@ function download(url, destination, redirects = 0) {
20
21
  await pipeline(response, createWriteStream(destination));
21
22
  },
22
23
  redirects,
24
+ { http, https },
25
+ DOWNLOAD_TIMEOUT_MS,
26
+ validateUrl,
23
27
  );
24
28
  }
25
29
 
@@ -29,8 +33,15 @@ function request(
29
33
  redirects = 0,
30
34
  clients = { http, https },
31
35
  timeoutMs = DOWNLOAD_TIMEOUT_MS,
36
+ validateUrl = () => {},
32
37
  ) {
33
38
  return new Promise((resolve, reject) => {
39
+ try {
40
+ validateUrl(url);
41
+ } catch (error) {
42
+ reject(error);
43
+ return;
44
+ }
34
45
  const client = url.startsWith("http://") ? clients.http : clients.https;
35
46
  const req = client.get(url, (response) => {
36
47
  if (isRedirectStatus(response.statusCode)) {
@@ -49,6 +60,7 @@ function request(
49
60
  redirects + 1,
50
61
  clients,
51
62
  timeoutMs,
63
+ validateUrl,
52
64
  ).then(resolve, reject);
53
65
  return;
54
66
  }
@@ -73,25 +85,42 @@ function isRedirectStatus(statusCode) {
73
85
  return [301, 302, 303, 307, 308].includes(statusCode);
74
86
  }
75
87
 
76
- async function fetchText(url) {
77
- if (url.startsWith("file://")) {
88
+ async function fetchText(url, validateUrl = () => {}) {
89
+ validateUrl(url);
90
+
91
+ if (isFileUrl(url)) {
78
92
  return readFile(fileURLToPath(url), "utf8");
79
93
  }
80
94
  const chunks = [];
81
95
  let totalLength = 0;
82
96
  const MAX_LENGTH = 1024 * 1024;
83
- await request(url, async (response) => {
84
- for await (const chunk of response) {
85
- totalLength += chunk.length;
86
- if (totalLength > MAX_LENGTH) {
87
- throw new Error(`Response exceeded maximum size of ${MAX_LENGTH} bytes`);
97
+ await request(
98
+ url,
99
+ async (response) => {
100
+ for await (const chunk of response) {
101
+ totalLength += chunk.length;
102
+ if (totalLength > MAX_LENGTH) {
103
+ throw new Error(`Response exceeded maximum size of ${MAX_LENGTH} bytes`);
104
+ }
105
+ chunks.push(chunk);
88
106
  }
89
- chunks.push(chunk);
90
- }
91
- });
107
+ },
108
+ 0,
109
+ { http, https },
110
+ DOWNLOAD_TIMEOUT_MS,
111
+ validateUrl,
112
+ );
92
113
  return Buffer.concat(chunks).toString("utf8");
93
114
  }
94
115
 
116
+ function isFileUrl(url) {
117
+ try {
118
+ return new URL(url).protocol === "file:";
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
95
124
  module.exports = {
96
125
  download,
97
126
  fetchText,
@@ -52,19 +52,25 @@ async function install(binName, repository, options = {}) {
52
52
  return destination;
53
53
  }
54
54
 
55
- const asset = assetName(binName, version, target, options.assetExtension);
56
- const baseUrl = options.baseUrl || releaseBaseUrl(repository, version, options.envVar);
55
+ const asset = assetName({ binName, version, target, assetExtension: options.assetExtension });
56
+ const baseUrl = normalizeBaseUrl(
57
+ options.baseUrl || releaseBaseUrl(repository, version, options.envVar),
58
+ );
59
+ validateReleaseBaseUrl(baseUrl, repository, { enforcePath: true });
60
+ const validateReleaseDownloadUrl = (url) =>
61
+ validateReleaseBaseUrl(url, repository, { enforcePath: false });
62
+
57
63
  const temp = `${destination}.tmp-${process.pid}`;
58
64
 
59
65
  await mkdir(vendorDir, { recursive: true });
60
66
 
61
67
  try {
62
68
  console.log(`Downloading ${binName} v${version} for ${target}...`);
63
- await download(`${baseUrl}/${asset}`, temp);
69
+ await download(`${baseUrl}/${asset}`, temp, 0, validateReleaseDownloadUrl);
64
70
 
65
71
  let checksumText;
66
72
  try {
67
- checksumText = await fetchText(`${baseUrl}/${asset}.sha256`);
73
+ checksumText = await fetchText(`${baseUrl}/${asset}.sha256`, validateReleaseDownloadUrl);
68
74
  } catch (e) {
69
75
  throw new Error(`Failed to fetch checksum for ${asset}: ${e.message}`);
70
76
  }
@@ -85,6 +91,84 @@ async function install(binName, repository, options = {}) {
85
91
  }
86
92
  }
87
93
 
94
+ function normalizeBaseUrl(baseUrl) {
95
+ const url = String(baseUrl);
96
+ if (url.endsWith("://")) {
97
+ return url;
98
+ }
99
+ return url.replace(/\/+$/, "");
100
+ }
101
+
102
+ function validateReleaseBaseUrl(baseUrl, repository, options = {}) {
103
+ const enforcePath = options.enforcePath ?? true;
104
+ const allowedPublicHost = "github.com";
105
+ const allowedRedirectHostSuffixes = ["githubusercontent.com"];
106
+ const allowedLocalHosts = ["127.0.0.1", "example.test"];
107
+ const publicPathPrefix = `/${repository.toLowerCase()}/releases/download`;
108
+
109
+ let parsedUrl;
110
+ try {
111
+ parsedUrl = new URL(baseUrl);
112
+ } catch {
113
+ throw new Error(`Invalid release base URL: ${baseUrl}. It must be a valid absolute URL.`);
114
+ }
115
+
116
+ if (parsedUrl.protocol === "file:") {
117
+ if (
118
+ parsedUrl.username ||
119
+ parsedUrl.password ||
120
+ parsedUrl.pathname.startsWith("//") ||
121
+ !String(baseUrl).toLowerCase().startsWith("file:///")
122
+ ) {
123
+ throw new Error(
124
+ `Untrusted base URL: ${baseUrl}. File URLs must use canonical 'file:///path/to/asset' form and must not include credentials.`,
125
+ );
126
+ }
127
+ return;
128
+ }
129
+ if (parsedUrl.username || parsedUrl.password) {
130
+ throw new Error(`Untrusted base URL: ${baseUrl}. Credentials are not allowed in URLs.`);
131
+ }
132
+
133
+ const hostname = parsedUrl.hostname.toLowerCase();
134
+ const isLocalHost = allowedLocalHosts.includes(hostname);
135
+ if (isLocalHost) {
136
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
137
+ throw new Error(`Untrusted base URL: ${baseUrl}. Expected http: or https: protocol.`);
138
+ }
139
+ } else if (parsedUrl.protocol !== "https:") {
140
+ throw new Error(`Untrusted base URL: ${baseUrl}. Expected https: protocol.`);
141
+ }
142
+
143
+ if (enforcePath && hostname !== allowedPublicHost && !isLocalHost) {
144
+ throw new Error(
145
+ `Untrusted base URL: ${baseUrl}. When enforcePath is enabled, expected base URL host ${allowedPublicHost} unless host is a local testing host.`,
146
+ );
147
+ }
148
+
149
+ const isAllowedHost =
150
+ hostname === allowedPublicHost ||
151
+ allowedLocalHosts.includes(hostname) ||
152
+ allowedRedirectHostSuffixes.some(
153
+ (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`),
154
+ );
155
+ if (!isAllowedHost) {
156
+ throw new Error(
157
+ `Untrusted base URL: ${baseUrl}. Allowed hosts are: ${[allowedPublicHost, ...allowedLocalHosts, ...allowedRedirectHostSuffixes.map((suffix) => `*.${suffix}`)].join(", ")}.`,
158
+ );
159
+ }
160
+
161
+ if (
162
+ enforcePath &&
163
+ !isLocalHost &&
164
+ !parsedUrl.pathname.toLowerCase().startsWith(`${publicPathPrefix}/`)
165
+ ) {
166
+ throw new Error(
167
+ `Untrusted GitHub repository in base URL: ${baseUrl}. For github.com, expected base URL prefix ${publicPathPrefix}.`,
168
+ );
169
+ }
170
+ }
171
+
88
172
  function isPlaceholder(path) {
89
173
  let fd;
90
174
  try {
@@ -121,4 +205,5 @@ module.exports = {
121
205
  isPlaceholder,
122
206
  sha256,
123
207
  unsupportedPlatformMessage,
208
+ validateReleaseBaseUrl,
124
209
  };