Repository navigation
feat: validate actor.json paths before apify push uploads anything
#1503
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,138
−8
Merged
Changes from all commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
d71b158
feat: add the dockerContextDir resolver and Docker context file colle…
DaveHanns d49e15e
feat: refuse monorepo Actors in apify push before any platform write
DaveHanns 3fa653b
feat: validate actor.json paths before apify push uploads anything
DaveHanns afa1cb7
test: cover the actor.json path checks and the push refusal
DaveHanns 2ac0be9
test: pass the monorepo file list to the Docker context case instead …
DaveHanns 8312f66
fix: resolve the Actor directory's real path and require a directory …
DaveHanns feb2cec
fix: return forward-slash paths from the Docker context file collector
DaveHanns b2ee951
fix: name the Actor folder in the monorepo refusal when actor.json ha…
DaveHanns d1fec8d
test: cover readGitProvenance and tidy the monorepo push test
DaveHanns 86d8822
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns db2cb8f
refactor: split the Docker context resolution into none, within, outs…
DaveHanns f728886
test: cover the none and within Docker context kinds
DaveHanns 92b2679
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns 9300a01
fix: require the Dockerfile inside a Docker context within the Actor …
DaveHanns a9f2d19
refactor: rename readGitProvenance to readGitState
DaveHanns d9c0cb2
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns a31364e
fix: judge actor.json paths lexically, as push uploads them
DaveHanns 0b495cf
fix: check the inputSchema and outputSchema aliases
DaveHanns 3e14b8b
fix: skip empty path fields and reject backslashes
DaveHanns 775db15
test: cover the case mismatch reason and use the temp path hook
DaveHanns 703159b
refactor: compute the path check invariants once
DaveHanns cc9e1a8
docs: state the BuildKit basis of the Docker context rule
DaveHanns 39be1cb
docs: list the rules checkPathField applies, in order
DaveHanns f42dd5b
docs: name the dangling symlink and case rules in the checkPathField …
DaveHanns 40a603c
test: cover dangling and symlinked Dockerfile paths and a symlinked A…
DaveHanns e3edc79
test: use a forward-slash absolute path so the check reads the same o…
DaveHanns 50fb1b8
docs: explain why the Docker context is judged by its path as written
DaveHanns 1af9abf
test: pin the Docker context classification through symlinks
DaveHanns c1cce18
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns 944b6ae
fix: return the Docker context path as the upload lays it out
DaveHanns 5b807db
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns ee0eda8
fix: compare the Dockerfile with the Docker context as the upload lay…
DaveHanns d56fdf7
test: accept a Dockerfile in a Docker context reached through a symlink
DaveHanns a91bf73
Merge remote-tracking branch 'origin/master' into feat/push-validate-…
DaveHanns cd7d2b5
fix: check the default Dockerfile against the Docker context
DaveHanns 9b8ef37
fix: warn instead of failing when the platform falls back to its defa…
DaveHanns 7f0a49c
test: cover the default Dockerfile and the README and CHANGELOG warni…
DaveHanns 89b7811
fix: read actor.json paths and the default Dockerfile as the platform…
DaveHanns baadeb1
fix: check the paths in actor.json before asking for a login
DaveHanns 539f218
Merge branch 'master' into feat/push-validate-actor-json-paths
l2ysho File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,289 @@ | ||
| import { readdirSync, realpathSync, type Stats, statSync } from 'node:fs'; | ||
| import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'; | ||
|
|
||
| import { ACTOR_SPECIFICATION_FOLDER } from './consts.js'; | ||
| import { isOutside, toPosix } from './docker-context.js'; | ||
|
|
||
| /** | ||
| * An `error` would fail the build or build the wrong thing, so push stops. A `warning` is a path the platform cannot | ||
| * find but recovers from with its default lookup, so the build succeeds and push continues. | ||
| */ | ||
| export type ActorPathSeverity = 'error' | 'warning'; | ||
|
|
||
| export interface ActorPathProblem { | ||
| field: string; | ||
| /** Empty when the field is not set and the problem lies with what the platform uses by default. */ | ||
| value: string; | ||
| resolvedPath: string; | ||
| reason: string; | ||
| severity: ActorPathSeverity; | ||
| } | ||
|
|
||
| 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<string, unknown>; | ||
| /** | ||
| * When given, `dockerfile` must lie inside it. The worker runs BuildKit with `--local dockerfile=<context dir>` | ||
| * and `filename=<Dockerfile relative to the context>`, 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. Without | ||
| * `dockerfile`, the Dockerfile the platform would find by default is held to the same rules. 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']; | ||
| /** | ||
| * When the worker cannot find the file these name, it logs a warning and falls back to its default lookup | ||
| * (`.actor/ACTOR.md`, `.actor/README.md`, then `README.md` or `README` in the Actor folder; likewise for the | ||
| * changelog), so the build succeeds. A missing `dockerfile` falls back too, but to a Dockerfile the user did not choose. | ||
| */ | ||
| const FALLBACK_FIELDS = new Set(['readme', 'changelog']); | ||
| /** | ||
| * Without `dockerfile`, the worker takes the first of these that exists, relative to `.actor/`; with none, it copies | ||
| * its built-in Node.js Dockerfile into the Docker context. | ||
| */ | ||
| const DEFAULT_DOCKERFILE_PATHS = ['./Dockerfile', '../Dockerfile']; | ||
|
|
||
| interface PathField { | ||
| field: string; | ||
| value: string; | ||
| caseInsensitive: boolean; | ||
| } | ||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 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<string, unknown>): 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 entry for `filePath` in a listing of its directory, by the exact name and else by any case. */ | ||
| function findEntry(filePath: string): { sameCase?: string; anyCase?: string } { | ||
| const wanted = basename(filePath); | ||
| const entries = listDirectory(dirname(filePath)); | ||
| return { | ||
| sameCase: entries.find((entry) => entry === wanted), | ||
| anyCase: entries.find((entry) => entry.toLowerCase() === wanted.toLowerCase()), | ||
| }; | ||
| } | ||
|
|
||
| /** The options as every field sees them: real roots and the uploaded files as posix paths. */ | ||
| interface PathCheckScope { | ||
| actorDir: string; | ||
| uploadRoot: string; | ||
| uploadedFiles: Set<string>; | ||
| dockerContextRoot?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Applies the platform's rules to one path field of `actor.json`, in order; the first failing rule wins. | ||
| * 1. Not absolute, and resolved from `.actor/`, inside the upload root; the platform never sees anything else. The | ||
| * platform resolves the value on Linux, where a backslash is part of a name, so this rule reads it that way first. | ||
| * 2. No backslashes: on Linux, such a name matches no file a push from another system uploads. | ||
| * 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`, set or found by default, 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. | ||
| * | ||
| * Every problem is an error, except for `readme` and `changelog` failing rules 2 (a backslash), 3, or 6: the worker | ||
| * does not find such a file and falls back to its default, so these are warnings. An absolute path, a path outside | ||
| * the upload root, or a directory fails the build for those fields too. | ||
| */ | ||
| function checkPathField( | ||
| { field, value, caseInsensitive }: PathField, | ||
| { actorDir, uploadRoot, uploadedFiles, dockerContextRoot }: PathCheckScope, | ||
| ): ActorPathProblem | undefined { | ||
| const problem = (resolvedPath: string, reason: string, platformFallsBack = false): ActorPathProblem => ({ | ||
| field, | ||
| value, | ||
| resolvedPath, | ||
| reason, | ||
| severity: platformFallsBack && FALLBACK_FIELDS.has(field) ? 'warning' : 'error', | ||
| }); | ||
| const absolute = () => problem(value, `is absolute; the path is relative to ${ACTOR_SPECIFICATION_FOLDER}/`); | ||
| const resolvedPath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, value); | ||
| const outside = () => problem(resolvedPath, `lies outside the uploaded folder ${uploadRoot}`); | ||
|
|
||
| // As the platform reads the value: an absolute or escaping path fails the build, even with a backslash in it. | ||
| if (value.startsWith('/')) return absolute(); | ||
| const uploadedPath = posix.join(toPosix(relative(uploadRoot, actorDir)), ACTOR_SPECIFICATION_FOLDER, value); | ||
| if (uploadedPath === '..' || uploadedPath.startsWith('../')) return outside(); | ||
| if (value.includes('\\')) { | ||
| return problem(value, 'uses a backslash; the platform resolves paths with forward slashes', true); | ||
| } | ||
| // As this system reads it, for a Windows drive path. | ||
| if (isAbsolute(value)) return absolute(); | ||
| if (isOutside(relative(uploadRoot, resolvedPath))) return outside(); | ||
|
|
||
| const { sameCase, anyCase } = findEntry(resolvedPath); | ||
| const name = sameCase ?? (caseInsensitive ? anyCase : undefined); | ||
| if (!name) { | ||
| return problem( | ||
| resolvedPath, | ||
| anyCase ? `does not exist (the name must match ${anyCase} exactly)` : 'does not exist', | ||
| true, | ||
| ); | ||
| } | ||
|
|
||
| // 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(dirname(resolvedPath), name); | ||
| let stats: Stats; | ||
| try { | ||
| stats = statSync(filePath); | ||
| } catch { | ||
| return problem(resolvedPath, 'does not exist', true); | ||
| } | ||
| 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 uploadedFilePath = toPosix(relative(uploadRoot, filePath)); | ||
| if (!uploadedFiles.has(uploadedFilePath)) { | ||
| const differentCase = [...uploadedFiles].find((file) => file.toLowerCase() === uploadedFilePath.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?)', | ||
| true, | ||
| ); | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** Whether push sends this file, or files inside this directory, so the platform finds it among the uploaded files. */ | ||
| function isUploaded(filePath: string, { uploadRoot, uploadedFiles }: PathCheckScope): boolean { | ||
| const uploadedPath = toPosix(relative(uploadRoot, filePath)); | ||
| return uploadedFiles.has(uploadedPath) || [...uploadedFiles].some((file) => file.startsWith(`${uploadedPath}/`)); | ||
| } | ||
|
|
||
| /** | ||
| * Without `dockerfile`, the platform takes the first default Dockerfile among the uploaded files, else copies its | ||
| * built-in one into the Docker context. A default that `.gitignore` or `.actorignore` drops never reaches the | ||
| * platform, so the lookup moves on to the next one. The found default must pass the same rules as a set `dockerfile`. | ||
| * The built-in one is the documented default for a context that is the Actor folder itself; in any other context it | ||
| * would build files the built-in Dockerfile was never meant for, and silently ignore a Dockerfile the context may hold. | ||
| */ | ||
| function checkDefaultDockerfile(scope: PathCheckScope & { dockerContextRoot: string }): ActorPathProblem | undefined { | ||
| const { actorDir, dockerContextRoot } = scope; | ||
| const value = DEFAULT_DOCKERFILE_PATHS.find((candidate) => { | ||
| const candidatePath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, candidate); | ||
| const { sameCase, anyCase } = findEntry(candidatePath); | ||
| const name = sameCase ?? anyCase; | ||
| return name !== undefined && isUploaded(join(dirname(candidatePath), name), scope); | ||
| }); | ||
| if (value) { | ||
| const problem = checkPathField({ field: 'dockerfile', value, caseInsensitive: true }, scope); | ||
| return ( | ||
| problem && { | ||
| ...problem, | ||
| value: '', | ||
| reason: `is not set, so the platform would use the default ${problem.resolvedPath}, which ${problem.reason}`, | ||
| } | ||
| ); | ||
| } | ||
| if (relative(actorDir, dockerContextRoot) === '') return undefined; | ||
| return { | ||
| field: 'dockerfile', | ||
| value: '', | ||
| resolvedPath: join(dockerContextRoot, 'Dockerfile'), | ||
| reason: | ||
| `is not set and push uploads no ${ACTOR_SPECIFICATION_FOLDER}/Dockerfile or Dockerfile in the Actor folder, ` + | ||
| `so the platform would build the Docker context ${dockerContextRoot} with its built-in Node.js Dockerfile; ` + | ||
| 'add a Dockerfile, or set "dockerfile" to one inside the Docker context', | ||
| severity: 'error', | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * 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. With a Docker context and no `dockerfile`, the default Dockerfile is checked too. | ||
| */ | ||
| 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, | ||
| }; | ||
| const pathFields = collectPathFields(options.config); | ||
| const problems = pathFields.flatMap((pathField) => checkPathField(pathField, scope) ?? []); | ||
|
|
||
| const { dockerContextRoot } = scope; | ||
| if (dockerContextRoot && !pathFields.some(({ field }) => field === 'dockerfile')) { | ||
| const problem = checkDefaultDockerfile({ ...scope, dockerContextRoot }); | ||
| if (problem) problems.push(problem); | ||
| } | ||
| return problems; | ||
| } | ||
|
|
||
| const PROBLEM_HEADERS: Record<ActorPathSeverity, string> = { | ||
| error: '.actor/actor.json refers to files the Apify platform could not use:', | ||
| warning: | ||
| '.actor/actor.json refers to files the Apify platform will not find. ' + | ||
| 'The push continues, and the platform falls back to its default README or CHANGELOG:', | ||
| }; | ||
|
|
||
| /** The message for the problems of one severity, with a header that says what happens; empty when there are none. */ | ||
| export function formatActorPathProblems(problems: ActorPathProblem[], severity: ActorPathSeverity): string { | ||
| const selected = problems.filter((problem) => problem.severity === severity); | ||
| if (selected.length === 0) return ''; | ||
| return [ | ||
| PROBLEM_HEADERS[severity], | ||
| ...selected.map(({ field, value, resolvedPath, reason }) => | ||
| value ? ` "${field}": "${value}" (${resolvedPath}) ${reason}.` : ` "${field}" ${reason}.`, | ||
| ), | ||
| `Paths are relative to ${ACTOR_SPECIFICATION_FOLDER}/ and must point to files inside the uploaded folder.`, | ||
| ].join('\n'); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.