Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/provision-kit/src/install-source-download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Readable } from 'node:stream';
import { test, vi } from 'vitest';
import { mkdtempForTest } from './tmp-dir.fixtures.ts';
import { downloadInstallSource } from './install-source-download.ts';
import { isTrustedInstallSourceUrl } from './install-source.ts';
import * as networkTransport from './install-source-network-transport.ts';

test('download redirects revalidate destinations and strip sensitive cross-origin headers', async () => {
Expand Down Expand Up @@ -242,3 +243,17 @@ function response(
close: async () => {},
};
}

test('download and the trusted-source check refuse an unparsable source alike', async () => {
const source = '/abs/path/app.zip';
const expected = { code: 'INVALID_ARGS', message: 'Invalid source URL' };
await assert.rejects(
downloadInstallSource({
tempDir: '/unused',
url: source,
signal: new AbortController().signal,
}),
expected,
);
assert.throws(() => isTrustedInstallSourceUrl(source), expected);
});
6 changes: 5 additions & 1 deletion packages/provision-kit/src/install-source-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,15 @@ function parseSourceUrl(raw: string): URL {
try {
parsed = new URL(raw);
} catch {
throw new AppError('INVALID_ARGS', 'Invalid source URL');
throw invalidSourceUrlError();
}

if (parsed.username || parsed.password) {
throw new AppError('INVALID_ARGS', 'Source URL credentials are not allowed');
}
return parsed;
}

export function invalidSourceUrlError(): AppError {
return new AppError('INVALID_ARGS', 'Invalid source URL');
}
5 changes: 3 additions & 2 deletions packages/provision-kit/src/install-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
noteInstallArtifactArchiveDepth,
} from './install-artifact-archive-context.ts';
import { approveDownloadSourceUrl } from './install-source-network.ts';
import { downloadInstallSource } from './install-source-download.ts';
import { downloadInstallSource, invalidSourceUrlError } from './install-source-download.ts';

type MaterializeLocalSourceResult = {
localPath: string;
Expand Down Expand Up @@ -158,7 +158,8 @@ export async function validateDownloadSourceUrl(parsedUrl: URL): Promise<void> {
* whether a URL names a GitHub Actions or EAS artifact, which says nothing about who built it.
*/
export function isTrustedInstallSourceUrl(sourceUrl: string | URL): boolean {
const parsed = sourceUrl instanceof URL ? sourceUrl : new URL(sourceUrl);
const parsed = sourceUrl instanceof URL ? sourceUrl : URL.parse(sourceUrl);
if (!parsed) throw invalidSourceUrlError();
const hostname = parsed.hostname.toLowerCase();
if (!hostname) return false;
const pathname = parsed.pathname;
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/cli-install-from-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import type { AgentDeviceClient } from '../agent-device-client.ts';
import { tryRunClientBackedCommand } from '../cli/commands/router.ts';

const REACHED_CLIENT = new Error('reached the client');

async function runInstallFromSource(source: string) {
const client = {
apps: {
installFromSource: async () => {
throw REACHED_CLIENT;
},
},
} as unknown as AgentDeviceClient;
return await tryRunClientBackedCommand({
command: 'install-from-source',
positionals: [source],
flags: { json: false, help: false, version: false },
client,
});
}

test('install-from-source refuses a local path with a typed error', async () => {
await assert.rejects(
() => runInstallFromSource('/abs/path/app.zip'),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'install-from-source <url> must be an http(s) URL: /abs/path/app.zip' &&
String(error.details?.hint).includes('install <app> <path>'),
);
});

for (const source of [
'http://',
'https://',
'http://exa mple.com/app.zip',
'ftp://example.com/app.zip',
'example.com/app.zip',
]) {
test(`install-from-source refuses ${JSON.stringify(source)} before any work`, async () => {
await assert.rejects(
() => runInstallFromSource(source),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
String(error.details?.hint).includes('install <app> <path>'),
);
});
}

test('install-from-source passes an http(s) URL to the client', async () => {
await assert.rejects(
() => runInstallFromSource('HTTPS://example.com/app.zip'),
(error) => error === REACHED_CLIENT,
);
});
8 changes: 8 additions & 0 deletions src/__tests__/install-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
withAppleToolProvider,
} from '@agent-device/platform-apple/tool-provider';
import { prepareIosInstallArtifact } from '@agent-device/platform-apple/install-artifact';
import { AppError } from '@agent-device/kernel/errors';
import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts';
import { mkdtempForTest } from './test-utils/tmp-dir.ts';
import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport';
Expand Down Expand Up @@ -104,6 +105,13 @@ test('isTrustedInstallSourceUrl recognizes supported artifact services', () => {
false,
);
assert.equal(isTrustedInstallSourceUrl('https://expo.dev/pricing'), false);
assert.throws(
() => isTrustedInstallSourceUrl('/abs/path/app.zip'),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Invalid source URL',
);
});

test.sequential('materializeInstallablePath extracts zip archives without ditto', async () => {
Expand Down
10 changes: 10 additions & 0 deletions src/commands/management/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,23 @@ function resolveInstallSource(positionals: string[], flags: CliFlags) {
}
if (githubArtifactSource) return githubArtifactSource;
if (configuredSource) return configuredSource;
if (!isHttpUrl(url!)) {
throw new AppError('INVALID_ARGS', `install-from-source <url> must be an http(s) URL: ${url}`, {
hint: 'Install a local build with install <app> <path>.',
});
}
return {
kind: 'url' as const,
url: url!,
headers: parseInstallSourceHeaders(flags.header),
};
}

function isHttpUrl(value: string): boolean {
const protocol = URL.parse(value)?.protocol;
return protocol === 'http:' || protocol === 'https:';
}

function parseInstallSourceHeaders(
headerFlags: CliFlags['header'],
): Record<string, string> | undefined {
Expand Down
Loading