Skip to content

Commit be546cb

Browse files
authored
Fix issues with registries (#447)
1 parent 8e1c095 commit be546cb

File tree

6 files changed

+126
-24
lines changed

6 files changed

+126
-24
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3374,7 +3374,7 @@
33743374
"@microsoft/vscode-azext-azureutils": "^4.0.0",
33753375
"@microsoft/vscode-azext-utils": "^4.0.4",
33763376
"@microsoft/vscode-container-client": "^0.5.3",
3377-
"@microsoft/vscode-docker-registries": "^0.4.1",
3377+
"@microsoft/vscode-docker-registries": "^0.4.2",
33783378
"@microsoft/vscode-processutils": "^0.2.1",
33793379
"dayjs": "^1.11.7",
33803380
"dockerfile-language-server-nodejs": "^0.15.0",

src/test/utils/httpRequest.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE.md in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import assert from 'assert';
7+
import { httpRequest, RequestOptionsLike } from '../../utils/httpRequest';
8+
9+
suite('(unit) utils/httpRequest', () => {
10+
let originalFetch: typeof globalThis.fetch;
11+
let lastFetchUrl: string | URL | Request;
12+
let lastFetchInit: RequestInit | undefined;
13+
14+
function stubFetch(status = 200, body = '{}', headers?: Record<string, string>): void {
15+
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
16+
lastFetchUrl = input;
17+
lastFetchInit = init;
18+
return new Response(body, {
19+
status,
20+
statusText: status === 200 ? 'OK' : 'Error',
21+
headers: headers ?? {},
22+
});
23+
};
24+
}
25+
26+
setup(() => {
27+
originalFetch = globalThis.fetch;
28+
lastFetchUrl = undefined!;
29+
lastFetchInit = undefined;
30+
});
31+
32+
teardown(() => {
33+
globalThis.fetch = originalFetch;
34+
});
35+
36+
test('sets duplex to half when body is provided', async () => {
37+
stubFetch();
38+
const options: RequestOptionsLike = { method: 'POST', body: 'test' };
39+
await httpRequest('https://example.com', options);
40+
41+
assert.strictEqual(lastFetchInit?.duplex, 'half');
42+
});
43+
44+
test('sets duplex to half when body is empty string', async () => {
45+
stubFetch();
46+
const options: RequestOptionsLike = { method: 'POST', body: '' };
47+
await httpRequest('https://example.com', options);
48+
49+
assert.strictEqual(lastFetchInit?.duplex, 'half');
50+
});
51+
52+
test('does not set duplex when no body is provided', async () => {
53+
stubFetch();
54+
const options: RequestOptionsLike = { method: 'GET' };
55+
await httpRequest('https://example.com', options);
56+
57+
assert.strictEqual(lastFetchInit?.duplex, undefined);
58+
});
59+
60+
test('does not mutate original options headers via signRequest', async () => {
61+
stubFetch();
62+
const sharedOptions: RequestOptionsLike = {
63+
method: 'HEAD',
64+
headers: { 'Accept': 'application/json' },
65+
};
66+
67+
const signRequest = async (request: RequestOptionsLike): Promise<void> => {
68+
request.headers!['Authorization'] = 'Bearer token123';
69+
};
70+
71+
await httpRequest('https://example.com', sharedOptions, signRequest);
72+
73+
// The signed header should have been sent
74+
const sentHeaders = lastFetchInit?.headers as Record<string, string>;
75+
assert.strictEqual(sentHeaders['Authorization'], 'Bearer token123');
76+
77+
// But the original options should be unmodified
78+
assert.strictEqual(sharedOptions.headers!['Authorization'], undefined, 'Original options.headers should not be mutated');
79+
assert.strictEqual(Object.keys(sharedOptions.headers!).length, 1);
80+
});
81+
82+
test('passes signed headers through to fetch', async () => {
83+
stubFetch();
84+
const options: RequestOptionsLike = {
85+
method: 'GET',
86+
headers: { 'X-Custom': 'value' },
87+
};
88+
89+
const signRequest = async (request: RequestOptionsLike): Promise<void> => {
90+
request.headers!['Authorization'] = 'Bearer mytoken';
91+
};
92+
93+
await httpRequest('https://example.com', options, signRequest);
94+
95+
const sentHeaders = lastFetchInit?.headers as Record<string, string>;
96+
assert.strictEqual(sentHeaders['Authorization'], 'Bearer mytoken');
97+
assert.strictEqual(sentHeaders['X-Custom'], 'value');
98+
});
99+
100+
test('passes url directly to fetch', async () => {
101+
stubFetch();
102+
await httpRequest('https://example.com/v2/test', { method: 'GET' });
103+
104+
assert.strictEqual(lastFetchUrl, 'https://example.com/v2/test');
105+
});
106+
});

src/tree/images/imageChecker/OutdatedImageChecker.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,8 @@ export class OutdatedImageChecker {
107107
private async getLatestImageDigest(registry: ImageRegistry, repo: string, tag: string): Promise<string> {
108108
const manifestResponse = await httpRequest(`${registry.baseUrl}/${repo}/manifests/${tag}`, this.defaultRequestOptions, async (request) => {
109109
if (registry.signRequest) {
110-
return registry.signRequest(request, `repository:library/${repo}:pull`);
110+
await registry.signRequest(request, `repository:library/${repo}:pull`);
111111
}
112-
113-
return request;
114112
});
115113

116114
return manifestResponse.headers.get('docker-content-digest') as string;

src/tree/images/imageChecker/registries.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
import { ImageNameInfo } from '@microsoft/vscode-container-client';
77
import { URL } from 'url';
88
import { ociClientId } from '../../../constants';
9-
import { HttpErrorResponse, IOAuthContext, RequestLike, RequestOptionsLike, bearerAuthHeader, getWwwAuthenticateContext, httpRequest } from '../../../utils/httpRequest';
9+
import { HttpErrorResponse, IOAuthContext, RequestOptionsLike, bearerAuthHeader, getWwwAuthenticateContext, httpRequest } from '../../../utils/httpRequest';
1010
import { NormalizedImageNameInfo } from '../NormalizedImageNameInfo';
1111

1212
export interface ImageRegistry {
1313
isMatch: (imageNameInfo: ImageNameInfo) => boolean;
1414
baseUrl: string;
15-
signRequest?(request: RequestLike, scope: string): Promise<RequestLike>;
15+
signRequest?(request: RequestOptionsLike, scope: string): Promise<void>;
1616
}
1717

1818
let dockerHubAuthContext: IOAuthContext | undefined;
@@ -24,7 +24,7 @@ export const registries: ImageRegistry[] = [
2424
return !!imageNameInfo.originalName && normalizedImageNameInfo.normalizedRegistry === 'docker.io' && normalizedImageNameInfo.normalizedNamespace === 'library';
2525
},
2626
baseUrl: 'https://registry-1.docker.io/v2/library',
27-
signRequest: async (request: RequestLike, scope: string): Promise<RequestLike> => {
27+
signRequest: async (request: RequestOptionsLike, scope: string): Promise<void> => {
2828
if (!dockerHubAuthContext) {
2929
try {
3030
const options: RequestOptionsLike = {
@@ -59,8 +59,8 @@ export const registries: ImageRegistry[] = [
5959
const tokenResponse = await httpRequest<{ token: string }>(url.toString(), authRequestOptions);
6060
const token = (await tokenResponse.json()).token;
6161

62-
request.headers.set('Authorization', bearerAuthHeader(token));
63-
return request;
62+
request.headers ??= {};
63+
request.headers['Authorization'] = bearerAuthHeader(token);
6464
}
6565
},
6666
{

src/utils/httpRequest.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,26 @@ export const enum ErrorHandling {
1616
export async function httpRequest<T>(
1717
url: string,
1818
options?: RequestOptionsLike,
19-
signRequest?: (request: RequestLike) => Promise<RequestLike>,
19+
signRequest?: (request: RequestOptionsLike) => Promise<void>,
2020
errorHandling: ErrorHandling = ErrorHandling.ThrowOnError
2121
): Promise<HttpResponse<T>> {
22-
const requestOptions: RequestInit = options;
22+
const requestOptions: RequestInit = { ...options, headers: { ...options?.headers } };
2323
if (options?.form) {
2424
// URLSearchParams is a silly way to say "it's form data"
2525
requestOptions.body = new URLSearchParams(options.form);
2626
}
2727

28-
let request = new Request(url, requestOptions ?? {});
28+
if (requestOptions.body !== undefined && requestOptions.duplex === undefined) {
29+
// Node's built-in fetch implementation (via undici) requires `duplex: 'half'`
30+
// when sending a request body in this code path, otherwise Node throws an error.
31+
requestOptions.duplex = 'half';
32+
}
2933

3034
if (signRequest) {
31-
request = await signRequest(request) as Request;
35+
await signRequest(requestOptions as RequestOptionsLike);
3236
}
3337

34-
const response = await fetch(request);
38+
const response = await fetch(url, requestOptions);
3539

3640
if (errorHandling === ErrorHandling.ReturnErrorResponse || response.ok) {
3741
return new HttpResponse(response, url);
@@ -111,12 +115,6 @@ export interface RequestOptionsLike {
111115
body?: string;
112116
}
113117

114-
export interface RequestLike {
115-
url: string;
116-
headers: HeadersLike;
117-
method: string; // This is a string because node-fetch's Request defines it as such
118-
}
119-
120118
export interface HeadersLike {
121119
get(header: string): string | string[];
122120
set(header: string, value: string): void;

0 commit comments

Comments
 (0)