diff --git a/packages/provision-kit/src/install-source-download.test.ts b/packages/provision-kit/src/install-source-download.test.ts index dbd6e4c742..119a18684a 100644 --- a/packages/provision-kit/src/install-source-download.test.ts +++ b/packages/provision-kit/src/install-source-download.test.ts @@ -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 () => { @@ -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); +}); diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index ac6bc6b362..9fd53df554 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -208,7 +208,7 @@ 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) { @@ -216,3 +216,7 @@ function parseSourceUrl(raw: string): URL { } return parsed; } + +export function invalidSourceUrlError(): AppError { + return new AppError('INVALID_ARGS', 'Invalid source URL'); +} diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 1cbe44931c..53e2391727 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -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; @@ -158,7 +158,8 @@ export async function validateDownloadSourceUrl(parsedUrl: URL): Promise { * 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; diff --git a/src/__tests__/cli-install-from-source.test.ts b/src/__tests__/cli-install-from-source.test.ts new file mode 100644 index 0000000000..bc103e7649 --- /dev/null +++ b/src/__tests__/cli-install-from-source.test.ts @@ -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 must be an http(s) URL: /abs/path/app.zip' && + String(error.details?.hint).includes('install '), + ); +}); + +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 '), + ); + }); +} + +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, + ); +}); diff --git a/src/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index 082ed328d1..467ff74683 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -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'; @@ -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 () => { diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 7155d10e09..6258fa58e7 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -204,6 +204,11 @@ 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 must be an http(s) URL: ${url}`, { + hint: 'Install a local build with install .', + }); + } return { kind: 'url' as const, url: url!, @@ -211,6 +216,11 @@ function resolveInstallSource(positionals: string[], flags: CliFlags) { }; } +function isHttpUrl(value: string): boolean { + const protocol = URL.parse(value)?.protocol; + return protocol === 'http:' || protocol === 'https:'; +} + function parseInstallSourceHeaders( headerFlags: CliFlags['header'], ): Record | undefined {