diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 23307d8a2..f096b50ec 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -9,6 +9,7 @@ import { fetchManifest } from '@apify/actor-templates'; import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES, ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts'; import { createHmacSignature } from '@apify/utilities'; +import { checkActorPaths, formatActorPathProblems } from '../../lib/actor-paths.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; @@ -299,6 +300,20 @@ export class ActorsPushCommand extends ApifyCommand { return; } + // The platform resolves these paths after the upload; a wrong one builds the wrong image or fails the build. + const pathProblems = checkActorPaths({ + actorDir: cwd, + uploadRoot: cwd, + uploadedFiles: filePathsToPush, + config: actorConfig, + dockerContextRoot: dockerContext.kind === 'within' ? dockerContext.contextRoot : undefined, + }); + if (pathProblems.length > 0) { + error({ message: formatActorPathProblems(pathProblems) }); + process.exitCode = CommandExitCodes.InvalidActorJson; + return; + } + const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; diff --git a/src/lib/actor-paths.ts b/src/lib/actor-paths.ts new file mode 100644 index 000000000..594295769 --- /dev/null +++ b/src/lib/actor-paths.ts @@ -0,0 +1,183 @@ +import { readdirSync, realpathSync, type Stats, statSync } from 'node:fs'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +import { ACTOR_SPECIFICATION_FOLDER } from './consts.js'; +import { isOutside, toPosix } from './docker-context.js'; + +export interface ActorPathProblem { + field: string; + value: string; + resolvedPath: string; + reason: string; +} + +export interface ActorPathsOptions { + actorDir: string; + /** What push sends: the Actor folder, or the source root of a monorepo Actor. */ + uploadRoot: string; + /** Relative to `uploadRoot`, as the file collectors return them. */ + uploadedFiles: string[]; + config: Record; + /** + * When given, `dockerfile` must lie inside it. The worker runs BuildKit with `--local dockerfile=` + * and `filename=`, so a Dockerfile outside the context cannot be read by the + * build. The same holds for a context within the Actor folder, which today's push already sends. Pass it as + * `resolveDockerContext` returns it: the path as written, from the real Actor folder. + */ + dockerContextRoot?: string; +} + +/** The platform finds these by a directory listing, so their names match case-insensitively. */ +const LISTED_FILE_FIELDS = ['dockerfile', 'readme', 'changelog']; +/** The worker copies a set alias over its base key before resolving, so the alias wins and the base key is ignored. */ +const SCHEMA_FIELDS: { field: string; alias?: string }[] = [ + { field: 'input', alias: 'inputSchema' }, + { field: 'output', alias: 'outputSchema' }, + { field: 'webServerSchema' }, +]; +const STORAGE_SCHEMA_FIELDS = ['keyValueStore', 'dataset']; + +interface PathField { + field: string; + value: string; + caseInsensitive: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Every file-path field the platform resolves relative to `.actor/`; an inline schema object is not a path. */ +function collectPathFields(config: Record): PathField[] { + const fields: PathField[] = []; + const add = (field: string, value: unknown, caseInsensitive = false) => { + // The worker treats an empty string as an unset field. + if (typeof value === 'string' && value !== '') fields.push({ field, value, caseInsensitive }); + }; + + for (const field of LISTED_FILE_FIELDS) add(field, config[field], true); + for (const { field, alias } of SCHEMA_FIELDS) { + if (alias && config[alias]) add(alias, config[alias]); + else add(field, config[field]); + } + if (isRecord(config.storages)) { + for (const field of STORAGE_SCHEMA_FIELDS) add(`storages.${field}`, config.storages[field]); + if (isRecord(config.storages.datasets)) { + for (const [name, value] of Object.entries(config.storages.datasets)) { + add(`storages.datasets.${name}`, value); + } + } + } + return fields; +} + +function listDirectory(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** The options as every field sees them: real roots and the uploaded files as posix paths. */ +interface PathCheckScope { + actorDir: string; + uploadRoot: string; + uploadedFiles: Set; + dockerContextRoot?: string; +} + +/** + * Applies the platform's rules to one path field of `actor.json`, in order; the first failing rule wins. + * 1. No backslashes and not absolute: the platform resolves the value relative to `.actor/` on Linux. + * 2. Resolved from `.actor/`, it lies inside the upload root; the platform never sees anything else. + * 3. The file exists in its directory: by any case for `dockerfile`, `readme` and `changelog`, which the + * platform finds by listing the directory, and by the exact name for schema references. A dangling symlink + * does not exist. + * 4. It is a file, not a directory. + * 5. `dockerfile` lies inside the Docker context, as BuildKit reads it from there. + * 6. It is among the files push uploads, in the same case: a file `.gitignore` or `.actorignore` drops never + * reaches the platform. + */ +function checkPathField( + { field, value, caseInsensitive }: PathField, + { actorDir, uploadRoot, uploadedFiles, dockerContextRoot }: PathCheckScope, +): ActorPathProblem | undefined { + const problem = (resolvedPath: string, reason: string) => ({ field, value, resolvedPath, reason }); + if (value.includes('\\')) { + return problem(value, 'uses a backslash; the platform resolves paths with forward slashes'); + } + if (isAbsolute(value)) return problem(value, `is absolute; the path is relative to ${ACTOR_SPECIFICATION_FOLDER}/`); + + const resolvedPath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, value); + if (isOutside(relative(uploadRoot, resolvedPath))) { + return problem(resolvedPath, `lies outside the uploaded folder ${uploadRoot}`); + } + + const dir = dirname(resolvedPath); + const wanted = basename(resolvedPath); + const entries = listDirectory(dir); + const sameCase = entries.find((entry) => entry === wanted); + const anyCase = entries.find((entry) => entry.toLowerCase() === wanted.toLowerCase()); + const name = sameCase ?? (caseInsensitive ? anyCase : undefined); + if (!name) { + return problem( + resolvedPath, + anyCase ? `does not exist (the name must match ${anyCase} exactly)` : 'does not exist', + ); + } + + // push reads through a symlink and uploads the content at this path, so the path is judged as it is, not by its + // target; a dangling symlink has no content to upload. + const filePath = join(dir, name); + let stats: Stats; + try { + stats = statSync(filePath); + } catch { + return problem(resolvedPath, 'does not exist'); + } + if (stats.isDirectory()) return problem(resolvedPath, 'is a directory, not a file'); + if (dockerContextRoot && field === 'dockerfile' && isOutside(relative(dockerContextRoot, filePath))) { + return problem(resolvedPath, `lies outside the Docker context ${dockerContextRoot}`); + } + + const uploadedPath = toPosix(relative(uploadRoot, filePath)); + if (!uploadedFiles.has(uploadedPath)) { + const differentCase = [...uploadedFiles].find((file) => file.toLowerCase() === uploadedPath.toLowerCase()); + return problem( + resolvedPath, + differentCase + ? `does not match the case of the uploaded file ${differentCase}` + : 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + ); + } + return undefined; +} + +/** + * Applies the platform's rules to every file path in `actor.json` before anything is uploaded: relative to + * `.actor/`, inside the upload root, an existing file, and one that push sends. A symlink counts by its own path, + * as push uploads its content there. + */ +export function checkActorPaths(options: ActorPathsOptions): ActorPathProblem[] { + // The Actor folder and the upload root are real paths, as the Docker context resolver works from them, so a + // symlinked Actor folder does not count as an escape. A referenced path, like the Docker context the resolver + // returns, is judged as written below the real Actor folder, the way push lays out the upload. + const scope: PathCheckScope = { + actorDir: realpathSync(options.actorDir), + uploadRoot: realpathSync(options.uploadRoot), + uploadedFiles: new Set(options.uploadedFiles.map(toPosix)), + dockerContextRoot: options.dockerContextRoot, + }; + return collectPathFields(options.config).flatMap((pathField) => checkPathField(pathField, scope) ?? []); +} + +export function formatActorPathProblems(problems: ActorPathProblem[]): string { + return [ + '.actor/actor.json refers to files the Apify platform could not use:', + ...problems.map( + ({ field, value, resolvedPath, reason }) => ` "${field}": "${value}" (${resolvedPath}) ${reason}.`, + ), + `Paths are relative to ${ACTOR_SPECIFICATION_FOLDER}/ and must point to files inside the uploaded folder.`, + ].join('\n'); +} diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index 1555cc48d..47ee7abe4 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -109,11 +109,11 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown return { kind: 'outside', contextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; } -function isOutside(relativePath: string): boolean { +export function isOutside(relativePath: string): boolean { return isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${sep}`); } -function toPosix(filePath: string): string { +export function toPosix(filePath: string): string { return filePath.split(sep).join('/'); } diff --git a/test/local/commands/push-actor-paths.test.ts b/test/local/commands/push-actor-paths.test.ts new file mode 100644 index 000000000..0a243c141 --- /dev/null +++ b/test/local/commands/push-actor-paths.test.ts @@ -0,0 +1,168 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ACTOR_SOURCE_TYPES } from '@apify/consts'; + +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes } from '../../../src/lib/consts.js'; +import { cwdCache } from '../../../src/lib/hooks/useActorConfig.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const actName = 'push-actor-paths-actor'; +const ACTOR_ID = 'pAtH1234efGh'; + +const actor = { + id: ACTOR_ID, + name: actName, + // Older than any local file, so the "modified on the platform" check never trips. + modifiedAt: new Date(0), + taggedBuilds: { latest: { buildId: 'build1' } }, +}; +const build = { id: 'build1', actId: ACTOR_ID, buildNumber: '0.0.1', status: 'SUCCEEDED' }; +const currentVersion = { versionNumber: '0.0', sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES }; + +const actorGet = vitest.fn(async () => actor); +const versionUpdate = vitest.fn(async (_modifier: unknown) => ({})); +const versionCreate = vitest.fn(async (created: unknown) => created); + +// Stands in for the platform: the Actor and its version exist, and every read and write is recorded. +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentUserInfo: vitest.fn(async () => ({ id: 'userId', username: 'user' })), + outputJobLog: vitest.fn(async () => {}), + getLoggedClientOrThrow: vitest.fn(async () => ({ + baseUrl: 'https://api.apify.com/v2', + actor: () => ({ + get: actorGet, + version: () => ({ get: async () => currentVersion, update: versionUpdate }), + versions: () => ({ create: versionCreate }), + build: async () => build, + }), + build: () => ({ get: async () => build }), + })), +})); + +// The mocked `node:process` copy has no signal handlers; nothing here needs them. +vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', () => ({ + useAbortJobOnSignal: () => ({ [Symbol.dispose]() {} }), +})); + +const { beforeAllCalls, afterAllCalls, joinPath, tmpPath } = useTempPath(actName, { + create: true, + remove: true, + cwd: true, + cwdParent: false, +}); + +const { logMessages } = useConsoleSpy(); + +const { ActorsPushCommand } = await import('../../../src/commands/actors/push.js'); +// The command sets the exit code on this mocked copy of `node:process`, not on the global one. +const { default: commandProcess } = await import('node:process'); + +async function write(relativePath: string, content: string) { + await mkdir(dirname(joinPath(relativePath)), { recursive: true }); + await writeFile(joinPath(relativePath), content); +} + +async function writeActorConfig(fields: Record) { + await write( + '.actor/actor.json', + JSON.stringify({ actorSpecification: 1, name: actName, version: '0.0', buildTag: 'latest', ...fields }), + ); +} + +// The mocked copy may share the exit code with the real process, so both are restored. +let originalExitCode: typeof process.exitCode; +let originalGlobalExitCode: typeof process.exitCode; + +beforeEach(async () => { + originalExitCode = commandProcess.exitCode; + originalGlobalExitCode = process.exitCode; + actorGet.mockClear(); + versionUpdate.mockClear(); + versionCreate.mockClear(); + cwdCache.clear(); + await beforeAllCalls(); + + await write('.actor/INPUT_SCHEMA.json', '{}'); + await write('.actorignore', 'Dockerfile.ci\n'); + await write('Dockerfile', 'FROM apify/actor-node:20\n'); + await write('Dockerfile.ci', 'FROM apify/actor-node:20\n'); + await write('src/main.js', 'console.log(1);\n'); +}); + +afterEach(async () => { + commandProcess.exitCode = originalExitCode; + process.exitCode = originalGlobalExitCode; + await afterAllCalls(); +}); + +describe('apify push with file paths in actor.json', () => { + it('stops before reading or writing the Actor, listing every wrong path', async () => { + await writeActorConfig({ + dockerfile: '../Dockerfile.prod', + readme: '../Dockerfile.ci', + input: '../../INPUT_SCHEMA.json', + }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('.actor/actor.json refers to files the Apify platform could not use:'); + expect(stderr).toContain(`"dockerfile": "../Dockerfile.prod" (${joinPath('Dockerfile.prod')}) does not exist.`); + expect(stderr).toContain( + `"readme": "../Dockerfile.ci" (${joinPath('Dockerfile.ci')}) is not among the files apify push uploads (excluded by .gitignore or .actorignore?).`, + ); + expect(stderr).toContain( + `"input": "../../INPUT_SCHEMA.json" (${joinPath('..', 'INPUT_SCHEMA.json')}) lies outside the uploaded folder ${tmpPath}.`, + ); + expect(actorGet).not.toHaveBeenCalled(); + expect(versionCreate).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('keeps stdout clean with --json', async () => { + await writeActorConfig({ dockerfile: '../Dockerfile.prod' }); + + await testRunCommand(ActorsPushCommand, { flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.log).toEqual([]); + expect(logMessages.error.join('\n')).toContain('"dockerfile": "../Dockerfile.prod"'); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('requires the Dockerfile inside a Docker context within the Actor folder', async () => { + await write('docker/Dockerfile', 'FROM apify/actor-node:20\n'); + await writeActorConfig({ dockerContextDir: '../docker', dockerfile: '../Dockerfile' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain( + `"dockerfile": "../Dockerfile" (${joinPath('Dockerfile')}) lies outside the Docker context ${joinPath('docker')}.`, + ); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('pushes as usual when every path points to an uploaded file', async () => { + await writeActorConfig({ dockerfile: '../dockerfile', input: './INPUT_SCHEMA.json' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + const modifier = versionUpdate.mock.calls[0][0] as { sourceFiles: { name: string }[] }; + expect(modifier.sourceFiles.map((file) => file.name).sort()).toEqual([ + '.actor/INPUT_SCHEMA.json', + '.actor/actor.json', + '.actorignore', + 'Dockerfile', + 'src/main.js', + ]); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); + }); +}); diff --git a/test/local/lib/actor-paths.test.ts b/test/local/lib/actor-paths.test.ts new file mode 100644 index 000000000..b03bb878f --- /dev/null +++ b/test/local/lib/actor-paths.test.ts @@ -0,0 +1,295 @@ +import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { checkActorPaths, formatActorPathProblems } from '../../../src/lib/actor-paths.js'; +import { resolveDockerContext } from '../../../src/lib/docker-context.js'; +import { getActorLocalFilePaths } from '../../../src/lib/utils.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const ACTOR_PATH = 'actors/x'; + +describe('checkActorPaths', () => { + const { tmpPath, beforeAllCalls, afterAllCalls } = useTempPath('actor-paths-test-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + // The checker works from real paths, so the expectations do too. + let root: string; + let actorDir: string; + let uploadedFiles: string[]; + + const write = (relativePath: string, content = 'content') => { + mkdirSync(dirname(join(root, relativePath)), { recursive: true }); + writeFileSync(join(root, relativePath), content); + }; + + // The Actor folder is what an ordinary push uploads. + const check = ( + config: Record, + options: { uploadRoot?: string; uploadedFiles?: string[]; dockerContextRoot?: string } = {}, + ) => checkActorPaths({ actorDir, uploadRoot: actorDir, uploadedFiles, config, ...options }); + + beforeAll(async () => { + await beforeAllCalls(); + root = realpathSync(tmpPath); + actorDir = join(root, ACTOR_PATH); + write(`${ACTOR_PATH}/.actor/actor.json`, '{}'); + write(`${ACTOR_PATH}/.actor/INPUT_SCHEMA.json`, '{}'); + write(`${ACTOR_PATH}/.actor/README.md`); + write(`${ACTOR_PATH}/.actor/CHANGELOG.md`); + write(`${ACTOR_PATH}/.actorignore`, 'Dockerfile.ci\n'); + write(`${ACTOR_PATH}/Dockerfile`, 'FROM apify/actor-node:20\n'); + write(`${ACTOR_PATH}/Dockerfile.ci`, 'FROM apify/actor-node:20\n'); + write(`${ACTOR_PATH}/src/index.js`); + write('shared/Dockerfile', 'FROM apify/actor-node:20\n'); + write('shared/input_schema.json', '{}'); + symlinkSync(join(root, 'shared', 'input_schema.json'), join(actorDir, '.actor', 'linked_schema.json')); + symlinkSync(join(root, 'shared', 'Dockerfile'), join(actorDir, 'Dockerfile.shared')); + uploadedFiles = await getActorLocalFilePaths(actorDir); + symlinkSync(join(root, 'shared', 'missing.json'), join(actorDir, '.actor', 'dangling.json')); + symlinkSync(actorDir, join(root, 'linked-actor')); + symlinkSync(join(root, 'shared'), join(actorDir, 'sym')); + }); + + afterAll(async () => { + await afterAllCalls(); + }); + + it('passes files inside the upload root, next to actor.json or above it', () => { + expect(check({ input: './INPUT_SCHEMA.json', readme: './README.md', dockerfile: '../Dockerfile' })).toEqual([]); + expect(check({ input: 'INPUT_SCHEMA.json' })).toEqual([]); + }); + + it('does nothing for a config without path fields', () => { + expect(check({ name: 'x', version: '0.0', environmentVariables: { A: '1' } })).toEqual([]); + expect(check({})).toEqual([]); + }); + + it('skips inline schema objects', () => { + expect( + check({ + input: { title: 'Input', type: 'object', properties: {} }, + storages: { dataset: { fields: {} }, datasets: { main: { fields: {} } } }, + }), + ).toEqual([]); + }); + + it('rejects a path above the upload root', () => { + expect(check({ dockerfile: '../../../shared/Dockerfile' })).toEqual([ + { + field: 'dockerfile', + value: '../../../shared/Dockerfile', + resolvedPath: join(root, 'shared', 'Dockerfile'), + reason: `lies outside the uploaded folder ${actorDir}`, + }, + ]); + }); + + it('skips an empty string, which the platform treats as an unset field', () => { + expect(check({ dockerfile: '', input: '', storages: { dataset: '', datasets: { main: '' } } })).toEqual([]); + }); + + it('rejects a backslash before resolving anything', () => { + expect(check({ dockerfile: '..\\Dockerfile' })).toEqual([ + { + field: 'dockerfile', + value: '..\\Dockerfile', + resolvedPath: '..\\Dockerfile', + reason: 'uses a backslash; the platform resolves paths with forward slashes', + }, + ]); + }); + + it('rejects an absolute path', () => { + const value = '/x'; + + expect(check({ input: value })).toEqual([ + { field: 'input', value, resolvedPath: value, reason: 'is absolute; the path is relative to .actor/' }, + ]); + }); + + it('rejects a missing file', () => { + expect(check({ input: './missing.json' })).toEqual([ + { + field: 'input', + value: './missing.json', + resolvedPath: join(actorDir, '.actor', 'missing.json'), + reason: 'does not exist', + }, + ]); + }); + + it('rejects a directory', () => { + expect(check({ dockerfile: '../src' })).toEqual([ + { + field: 'dockerfile', + value: '../src', + resolvedPath: join(actorDir, 'src'), + reason: 'is a directory, not a file', + }, + ]); + }); + + it('rejects a file push does not upload', () => { + expect(check({ dockerfile: '../Dockerfile.ci' })).toEqual([ + { + field: 'dockerfile', + value: '../Dockerfile.ci', + resolvedPath: join(actorDir, 'Dockerfile.ci'), + reason: 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + }, + ]); + }); + + it('rejects a file whose case differs from the uploaded one', () => { + // A case-insensitive file system finds README.md, but the Git index lists it as readme.md. + expect(check({ readme: './README.md' }, { uploadedFiles: ['.actor/readme.md'] })).toEqual([ + { + field: 'readme', + value: './README.md', + resolvedPath: join(actorDir, '.actor', 'README.md'), + reason: 'does not match the case of the uploaded file .actor/readme.md', + }, + ]); + }); + + it('judges a symlink by its own path, as push uploads its content there', () => { + // The link points outside the upload root; push reads through it and uploads the content at the link's path. + expect(uploadedFiles).toContain('.actor/linked_schema.json'); + expect(check({ input: './linked_schema.json' })).toEqual([]); + + const withoutLink = uploadedFiles.filter((file) => file !== '.actor/linked_schema.json'); + expect(check({ input: './linked_schema.json' }, { uploadedFiles: withoutLink })).toEqual([ + { + field: 'input', + value: './linked_schema.json', + resolvedPath: join(actorDir, '.actor', 'linked_schema.json'), + reason: 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + }, + ]); + }); + + it('rejects a dangling symlink as missing', () => { + expect(check({ input: './dangling.json' }, { uploadedFiles: [...uploadedFiles, '.actor/dangling.json'] })).toEqual([ + { + field: 'input', + value: './dangling.json', + resolvedPath: join(actorDir, '.actor', 'dangling.json'), + reason: 'does not exist', + }, + ]); + }); + + it('judges a symlinked Dockerfile against the Docker context by its own path', () => { + // The link's target lies outside the context, but push uploads its content at the link's path inside it. + expect(uploadedFiles).toContain('Dockerfile.shared'); + expect(check({ dockerfile: '../Dockerfile.shared' }, { dockerContextRoot: actorDir })).toEqual([]); + }); + + it('accepts a Dockerfile in a context within a symlinked Actor folder', () => { + const linkedActorDir = join(root, 'linked-actor'); + const dockerContext = resolveDockerContext(linkedActorDir, '..'); + expect(dockerContext).toEqual({ kind: 'within', contextRoot: actorDir }); + + expect( + checkActorPaths({ + actorDir: linkedActorDir, + uploadRoot: linkedActorDir, + uploadedFiles, + config: { dockerfile: '../Dockerfile' }, + dockerContextRoot: actorDir, + }), + ).toEqual([]); + }); + + it('accepts a Dockerfile in a Docker context reached through a symlink inside the Actor folder', async () => { + // `sym` links to the shared folder outside the Actor; push uploads it as a normal folder `sym` inside. + const dockerContext = resolveDockerContext(actorDir, '../sym'); + expect(dockerContext).toEqual({ kind: 'within', contextRoot: join(actorDir, 'sym') }); + + expect( + checkActorPaths({ + actorDir, + uploadRoot: actorDir, + uploadedFiles: await getActorLocalFilePaths(actorDir), + config: { dockerContextDir: '../sym', dockerfile: '../sym/Dockerfile' }, + dockerContextRoot: join(actorDir, 'sym'), + }), + ).toEqual([]); + }); + + it('matches the name of dockerfile, readme and changelog case-insensitively, and a schema exactly', () => { + expect(check({ dockerfile: '../dockerfile', readme: './readme.MD', changelog: './changelog.md' })).toEqual([]); + expect(check({ input: './input_schema.json' })).toEqual([ + { + field: 'input', + value: './input_schema.json', + resolvedPath: join(actorDir, '.actor', 'input_schema.json'), + reason: 'does not exist (the name must match INPUT_SCHEMA.json exactly)', + }, + ]); + }); + + it('checks every string-valued schema reference', () => { + expect( + check({ + output: './output.json', + webServerSchema: './openapi.json', + storages: { keyValueStore: './kvs.json', dataset: './dataset.json', datasets: { main: './main.json' } }, + }).map((problem) => problem.field), + ).toEqual(['output', 'webServerSchema', 'storages.keyValueStore', 'storages.dataset', 'storages.datasets.main']); + }); + + it('checks inputSchema and outputSchema, which win over input and output', () => { + expect(check({ inputSchema: './in.json', outputSchema: './out.json' }).map((problem) => problem.field)).toEqual([ + 'inputSchema', + 'outputSchema', + ]); + // The worker copies a set alias over its base key, so the base key is never resolved. + expect(check({ input: './missing.json', inputSchema: './INPUT_SCHEMA.json' })).toEqual([]); + expect(check({ output: './missing.json', outputSchema: { type: 'object' } })).toEqual([]); + expect(check({ input: './missing.json', inputSchema: '' }).map((problem) => problem.field)).toEqual(['input']); + }); + + it('requires dockerfile inside the Docker context when one is given', () => { + const monorepoFiles = [`${ACTOR_PATH}/Dockerfile`, `${ACTOR_PATH}/.actor/actor.json`, 'shared/Dockerfile']; + const monorepo = { uploadRoot: root, uploadedFiles: monorepoFiles, dockerContextRoot: join(root, 'shared') }; + + expect(check({ dockerfile: '../../../shared/Dockerfile' }, monorepo)).toEqual([]); + expect(check({ dockerfile: '../Dockerfile' }, monorepo)).toEqual([ + { + field: 'dockerfile', + value: '../Dockerfile', + resolvedPath: join(actorDir, 'Dockerfile'), + reason: `lies outside the Docker context ${join(root, 'shared')}`, + }, + ]); + expect(check({ dockerfile: '../Dockerfile' }, { ...monorepo, dockerContextRoot: root })).toEqual([]); + }); +}); + +describe('formatActorPathProblems', () => { + it('lists every problem with its field, value, resolved path and reason', () => { + const message = formatActorPathProblems([ + { + field: 'dockerfile', + value: '../Dockerfile.prod', + resolvedPath: '/actor/Dockerfile.prod', + reason: 'does not exist', + }, + { field: 'input', value: '/schema.json', resolvedPath: '/schema.json', reason: 'is absolute' }, + ]); + + expect(message).toBe( + [ + '.actor/actor.json refers to files the Apify platform could not use:', + ' "dockerfile": "../Dockerfile.prod" (/actor/Dockerfile.prod) does not exist.', + ' "input": "/schema.json" (/schema.json) is absolute.', + 'Paths are relative to .actor/ and must point to files inside the uploaded folder.', + ].join('\n'), + ); + }); +});