Skip to content

Commit 4d2b6df

Browse files
committed
refactor(http): separate retry and redirect phases
1 parent 44342c6 commit 4d2b6df

4 files changed

Lines changed: 285 additions & 226 deletions

File tree

src/http-request/download.mts

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -105,23 +105,29 @@ export async function httpDownload(
105105
// passed) and the inner total===0 + interval-throttle branches fire
106106
// only on real network downloads, not the unit test mocks.
107107
/* c8 ignore start */
108-
let progressCallback:
108+
function createProgressCallback():
109109
| ((downloaded: number, total: number) => void)
110-
| undefined
111-
if (onProgress) {
112-
progressCallback = onProgress
113-
} else if (logger) {
114-
let lastPercent = 0
115-
progressCallback = (downloaded: number, total: number) => {
116-
const percent = total === 0 ? 0 : MathFloor((downloaded / total) * 100)
117-
if (percent >= lastPercent + progressInterval) {
118-
logger.log(
119-
` Progress: ${percent}% (${(downloaded / 1024 / 1024).toFixed(1)} MB / ${(total / 1024 / 1024).toFixed(1)} MB)`,
120-
)
121-
lastPercent = percent
110+
| undefined {
111+
let progressCallback:
112+
| ((downloaded: number, total: number) => void)
113+
| undefined
114+
if (onProgress) {
115+
progressCallback = onProgress
116+
} else if (logger) {
117+
let lastPercent = 0
118+
progressCallback = (downloaded: number, total: number) => {
119+
const percent = total === 0 ? 0 : MathFloor((downloaded / total) * 100)
120+
if (percent >= lastPercent + progressInterval) {
121+
logger.log(
122+
` Progress: ${percent}% (${(downloaded / 1024 / 1024).toFixed(1)} MB / ${(total / 1024 / 1024).toFixed(1)} MB)`,
123+
)
124+
lastPercent = percent
125+
}
122126
}
123127
}
128+
return progressCallback
124129
}
130+
const progressCallback = createProgressCallback()
125131
/* c8 ignore stop */
126132

127133
// Download to a temp file first, then atomically rename to destination.
@@ -137,6 +143,47 @@ export async function httpDownload(
137143
await safeDelete(tempPath)
138144
}
139145

146+
async function verifyDownloadedFile(
147+
result: Awaited<ReturnType<typeof httpDownloadAttempt>>,
148+
): Promise<void> {
149+
// Both digests were computed over the response chunks before they reached
150+
// the destination stream, so verification does not reread the temp file.
151+
if (sha256) {
152+
const expectedHash = sha256.toLowerCase()
153+
154+
// Use constant-time comparison to prevent timing attacks.
155+
if (
156+
result.sha256.length !== expectedHash.length ||
157+
!crypto.timingSafeEqual(
158+
BufferFrom!(result.sha256),
159+
Buffer.from(expectedHash),
160+
)
161+
) {
162+
await safeDelete(tempPath)
163+
throw new ErrorCtor(
164+
`Checksum verification failed for ${url}\n` +
165+
`Expected: ${expectedHash}\n` +
166+
`Computed: ${result.sha256}`,
167+
)
168+
}
169+
}
170+
if (
171+
integrity &&
172+
(result.integrity.length !== integrity.length ||
173+
!crypto.timingSafeEqual(
174+
BufferFrom!(result.integrity),
175+
Buffer.from(integrity),
176+
))
177+
) {
178+
await safeDelete(tempPath)
179+
throw new ErrorCtor(
180+
`Integrity verification failed for ${url}\n` +
181+
`Expected: ${integrity}\n` +
182+
`Computed: ${result.integrity}`,
183+
)
184+
}
185+
}
186+
140187
// Retry logic with exponential backoff
141188
let lastError: Error | undefined
142189
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -151,42 +198,7 @@ export async function httpDownload(
151198
timeout,
152199
})
153200

154-
// Both digests were computed over the response chunks before they reached
155-
// the destination stream, so verification does not reread the temp file.
156-
if (sha256) {
157-
const expectedHash = sha256.toLowerCase()
158-
159-
// Use constant-time comparison to prevent timing attacks.
160-
if (
161-
result.sha256.length !== expectedHash.length ||
162-
!crypto.timingSafeEqual(
163-
BufferFrom!(result.sha256),
164-
Buffer.from(expectedHash),
165-
)
166-
) {
167-
await safeDelete(tempPath)
168-
throw new ErrorCtor(
169-
`Checksum verification failed for ${url}\n` +
170-
`Expected: ${expectedHash}\n` +
171-
`Computed: ${result.sha256}`,
172-
)
173-
}
174-
}
175-
if (
176-
integrity &&
177-
(result.integrity.length !== integrity.length ||
178-
!crypto.timingSafeEqual(
179-
BufferFrom!(result.integrity),
180-
Buffer.from(integrity),
181-
))
182-
) {
183-
await safeDelete(tempPath)
184-
throw new ErrorCtor(
185-
`Integrity verification failed for ${url}\n` +
186-
`Expected: ${integrity}\n` +
187-
`Computed: ${result.integrity}`,
188-
)
189-
}
201+
await verifyDownloadedFile(result)
190202

191203
// Download succeeded - atomically rename temp file to destination.
192204
// This overwrites any existing file at destPath.

src/http-request/request-attempt.mts

Lines changed: 95 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,100 @@ export async function httpRequestAttempt(
159159
}
160160
}
161161

162+
function handleRedirect(res: IncomingResponse, location: string): void {
163+
// Drain the redirect response body to free the socket.
164+
res.resume()
165+
166+
emitResponse({
167+
headers: res.headers,
168+
status: res.statusCode,
169+
statusText: res.statusMessage,
170+
})
171+
172+
if (maxRedirects <= 0) {
173+
// Hook already emitted above — reject directly to avoid double-fire.
174+
settled = true
175+
reject(
176+
new ErrorCtor(
177+
`Too many redirects (exceeded maximum: ${maxRedirects})`,
178+
),
179+
)
180+
return
181+
}
182+
183+
// Resolve the Location header against the current url whether it
184+
// is absolute or relative — the URL constructor ignores the base
185+
// when the first argument already parses as an absolute URL. A
186+
// scheme check on `.protocol` (not `startsWith('http')`, which
187+
// also accepts `httpfoo:`) validates the result.
188+
const redirectParsed = new URLCtor(location, url)
189+
if (
190+
redirectParsed.protocol !== 'http:' &&
191+
redirectParsed.protocol !== 'https:'
192+
) {
193+
// Hook already emitted above — reject directly to avoid double-fire.
194+
settled = true
195+
reject(
196+
new ErrorCtor(
197+
`Redirect Location has an unsupported scheme: ${location}`,
198+
),
199+
)
200+
return
201+
}
202+
const redirectUrl = redirectParsed.toString()
203+
204+
if (isHttps && redirectParsed.protocol !== 'https:') {
205+
// Hook already emitted above — reject directly to avoid double-fire.
206+
settled = true
207+
reject(
208+
new ErrorCtor(
209+
`Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`,
210+
),
211+
)
212+
return
213+
}
214+
215+
// Strip auth/session headers on cross-origin redirects to prevent
216+
// leaking credentials to third-party hosts (e.g., GitHub -> S3).
217+
let redirectHeaders = headers
218+
if (new URLCtor(url).origin !== redirectParsed.origin) {
219+
redirectHeaders = {
220+
__proto__: null,
221+
} as unknown as typeof headers
222+
const stripped = new Set([
223+
'authorization',
224+
'cookie',
225+
'proxy-authenticate',
226+
'proxy-authorization',
227+
])
228+
for (const key of ObjectKeys(headers)) {
229+
if (!stripped.has(key.toLowerCase())) {
230+
;(redirectHeaders as Record<string, unknown>)[key] = (
231+
headers as Record<string, unknown>
232+
)[key]
233+
}
234+
}
235+
}
236+
237+
// Redirect chaining — Promise adoption handles the inner result.
238+
settled = true
239+
resolve(
240+
httpRequestAttempt(redirectUrl, {
241+
body,
242+
ca,
243+
followRedirects,
244+
headers: redirectHeaders,
245+
hooks,
246+
maxRedirects: maxRedirects - 1,
247+
maxResponseSize,
248+
method,
249+
stream,
250+
timeout,
251+
}),
252+
)
253+
return
254+
}
255+
162256
/* c8 ignore start - External HTTP/HTTPS request */
163257
const request = httpModule.request(
164258
requestOptions,
@@ -177,96 +271,7 @@ export async function httpRequestAttempt(
177271
res.statusCode < 400 &&
178272
res.headers.location
179273
) {
180-
// Drain the redirect response body to free the socket.
181-
res.resume()
182-
183-
emitResponse({
184-
headers: res.headers,
185-
status: res.statusCode,
186-
statusText: res.statusMessage,
187-
})
188-
189-
if (maxRedirects <= 0) {
190-
// Hook already emitted above — reject directly to avoid double-fire.
191-
settled = true
192-
reject(
193-
new ErrorCtor(
194-
`Too many redirects (exceeded maximum: ${maxRedirects})`,
195-
),
196-
)
197-
return
198-
}
199-
200-
// Resolve the Location header against the current url whether it
201-
// is absolute or relative — the URL constructor ignores the base
202-
// when the first argument already parses as an absolute URL. A
203-
// scheme check on `.protocol` (not `startsWith('http')`, which
204-
// also accepts `httpfoo:`) validates the result.
205-
const redirectParsed = new URLCtor(res.headers.location, url)
206-
if (
207-
redirectParsed.protocol !== 'http:' &&
208-
redirectParsed.protocol !== 'https:'
209-
) {
210-
// Hook already emitted above — reject directly to avoid double-fire.
211-
settled = true
212-
reject(
213-
new ErrorCtor(
214-
`Redirect Location has an unsupported scheme: ${res.headers.location}`,
215-
),
216-
)
217-
return
218-
}
219-
const redirectUrl = redirectParsed.toString()
220-
221-
if (isHttps && redirectParsed.protocol !== 'https:') {
222-
// Hook already emitted above — reject directly to avoid double-fire.
223-
settled = true
224-
reject(
225-
new ErrorCtor(
226-
`Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`,
227-
),
228-
)
229-
return
230-
}
231-
232-
// Strip auth/session headers on cross-origin redirects to prevent
233-
// leaking credentials to third-party hosts (e.g., GitHub -> S3).
234-
let redirectHeaders = headers
235-
if (new URLCtor(url).origin !== redirectParsed.origin) {
236-
redirectHeaders = {
237-
__proto__: null,
238-
} as unknown as typeof headers
239-
const stripped = new Set([
240-
'authorization',
241-
'cookie',
242-
'proxy-authenticate',
243-
'proxy-authorization',
244-
])
245-
for (const key of ObjectKeys(headers)) {
246-
if (!stripped.has(key.toLowerCase())) {
247-
;(redirectHeaders as Record<string, unknown>)[key] = (
248-
headers as Record<string, unknown>
249-
)[key]
250-
}
251-
}
252-
}
253-
254-
// Redirect chaining — Promise adoption handles the inner result.
255-
settled = true
256-
resolve(
257-
httpRequestAttempt(redirectUrl, {
258-
body,
259-
ca,
260-
followRedirects,
261-
headers: redirectHeaders,
262-
hooks,
263-
maxRedirects: maxRedirects - 1,
264-
maxResponseSize,
265-
method,
266-
stream,
267-
timeout,
268-
}),
269-
)
274+
handleRedirect(res, res.headers.location)
270275
return
271276
}
272277

0 commit comments

Comments
 (0)