fix(manifest): refuse checkout paths that are not absolute (#49) #52

Merged
dan-claude-bot merged 1 commit from fix/compose-file-path into main 2026-07-14 22:41:59 +00:00
dan-claude-bot commented 2026-07-14 22:20:55 +00:00 (Migrated from github.com)

The problem

A first apply, planning create application core, got a bare 422 from Coolify. The manifest said compose_file: docker-compose.yaml; Coolify wanted /docker-compose.yaml. By the time the 422 landed, apply had already created the project and the environment — a deterministic, manifest-shaped error paid for with a half-applied run.

cast passed the field through verbatim (resolve.ts:361) and validated nothing about its shape (manifest.ts:25 required only that it be present). Meanwhile cast's own draft writer already defaults it to /docker-compose.yaml (draft.ts:459), because that is what it reads off a live box: cast wrote the correct value and accepted the incorrect one. And docs/semantics.md:85 taught the incorrect one — anyone hand-writing a manifest from the docs wrote a manifest that could not be applied to a fresh box.

What changed

  • src/manifest.tscompose_file, base_directory and publish_directory are now refined to Coolify's own path patterns, with messages that name the fix (e.g. "compose_file must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /docker-compose.yaml, not docker-compose.yaml").
    A refine, not a normalization — deliberately. cast does not quietly rewrite what the manifest says: the manifest is the artifact under review, so a value that would 422 gets fixed in the file, in a commit, once, rather than repaired in memory on every run. And it is a parse-time refusal, so it costs zero API calls and fires on every verb, unlike the translated errors for the multi-destination 400 (#41) and the domain 409 (#44) — this one is fully knowable before cast talks to anything.
  • docs/semantics.md — fixed the compose example, and added a short "Checkout paths are absolute" paragraph covering all three fields and citing the Coolify rule.
  • fixtures carrying the old value (test/manifest.test.ts, test/resolve.test.ts, test/capture.test.ts) — every one of them is evidence for the issue: they are what let a value Coolify rejects look like a tested one.

README.md does not teach these fields (grepped) — untouched.

Evidence for the Coolify claim

Read from the v4.1.2 source, not the vendored OpenAPI spec (which documents these fields as bare strings and is provably incomplete):

  • bootstrap/helpers/api.phpsharedDataApplications(), merged into the validation rules of every application-create path (ApplicationsController::create_application, lines ~1038/1241/1482/1680/1798) and of the update path:
    'base_directory'         => ValidationPatterns::directoryPathRules(),
    'publish_directory'      => ValidationPatterns::directoryPathRules(),
    'docker_compose_location' => ValidationPatterns::filePathRules(),
    
  • app/Support/ValidationPatterns.php:
    const FILE_PATH_PATTERN      = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
    const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/'; // like FILE_PATH_PATTERN but also allows bare "/"
    
    Failing either is 422 Validation failed. from Validator::make(...)->fails().

Point 3 of the issue: base_directory does need the same refinement

Settled, and the answer is yes — with one difference that matters. base_directory (and publish_directory, the third field passed through the same way) is bound to directoryPathRules(), which is also anchored on a leading / — so base_directory: apps/core 422s a create exactly like compose_file: docker-compose.yaml does. The difference is that the directory pattern additionally admits the bare /, where the file pattern requires at least one character after the slash. That is why this PR carries two patterns rather than one shared "absolute path" rule: a shared rule strict enough for compose_file would refuse base_directory: /, which is what essentially every manifest says, including all of cast's own fixtures. Both patterns are transcribed with a citation comment in manifest.ts.

Tests

299/299 green (npm run check && npm run build && npm test), up from a 294 baseline. Five new cases in test/manifest.test.ts, covering the refusal paths and the boundary between the two patterns:

  • rejects a compose_file with no leading slash, and the message names the fix
  • rejects a compose_file that is the bare / (a directory, not a file — the file/directory pattern boundary)
  • rejects a base_directory with no leading slash
  • rejects a publish_directory with no leading slash
  • accepts / as base_directory and a nested absolute publish_directory (the regression guard for that boundary)

Noted, not fixed (out of scope here)

test/wire.test.ts and test/apply.test.ts still use docker_compose_location: "docker-compose.yaml" in hand-built wire-shaped objects. Those never pass through the manifest schema, so they are unaffected and still pass — but they are the same stale value, and are worth a sweep.

Closes #49.

## The problem A first apply, planning `create application core`, got a bare **422** from Coolify. The manifest said `compose_file: docker-compose.yaml`; Coolify wanted `/docker-compose.yaml`. By the time the 422 landed, `apply` had already created the project and the environment — a deterministic, manifest-shaped error paid for with a half-applied run. cast passed the field through verbatim (`resolve.ts:361`) and validated nothing about its shape (`manifest.ts:25` required only that it be *present*). Meanwhile cast's own draft writer already defaults it to `/docker-compose.yaml` (`draft.ts:459`), because that is what it reads off a live box: **cast wrote the correct value and accepted the incorrect one.** And `docs/semantics.md:85` taught the incorrect one — anyone hand-writing a manifest from the docs wrote a manifest that could not be applied to a fresh box. ## What changed - **`src/manifest.ts`** — `compose_file`, `base_directory` and `publish_directory` are now refined to Coolify's own path patterns, with messages that name the fix (e.g. *"compose_file must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /docker-compose.yaml, not docker-compose.yaml"*). A **refine, not a normalization** — deliberately. cast does not quietly rewrite what the manifest says: the manifest is the artifact under review, so a value that would 422 gets fixed in the file, in a commit, once, rather than repaired in memory on every run. And it is a *parse-time* refusal, so it costs zero API calls and fires on every verb, unlike the translated errors for the multi-destination 400 (#41) and the domain 409 (#44) — this one is fully knowable before cast talks to anything. - **`docs/semantics.md`** — fixed the compose example, and added a short *"Checkout paths are absolute"* paragraph covering all three fields and citing the Coolify rule. - **fixtures** carrying the old value (`test/manifest.test.ts`, `test/resolve.test.ts`, `test/capture.test.ts`) — every one of them is evidence for the issue: they are what let a value Coolify rejects look like a tested one. `README.md` does not teach these fields (grepped) — untouched. ## Evidence for the Coolify claim Read from the v4.1.2 source, not the vendored OpenAPI spec (which documents these fields as bare strings and is provably incomplete): - [`bootstrap/helpers/api.php`](https://github.com/coollabsio/coolify/blob/v4.1.2/bootstrap/helpers/api.php) — `sharedDataApplications()`, merged into the validation rules of *every* application-create path (`ApplicationsController::create_application`, lines ~1038/1241/1482/1680/1798) and of the update path: ```php 'base_directory' => ValidationPatterns::directoryPathRules(), 'publish_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), ``` - [`app/Support/ValidationPatterns.php`](https://github.com/coollabsio/coolify/blob/v4.1.2/app/Support/ValidationPatterns.php): ```php const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/'; const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/'; // like FILE_PATH_PATTERN but also allows bare "/" ``` Failing either is `422 Validation failed.` from `Validator::make(...)->fails()`. ## Point 3 of the issue: `base_directory` **does** need the same refinement Settled, and the answer is yes — with one difference that matters. `base_directory` (and `publish_directory`, the third field passed through the same way) is bound to `directoryPathRules()`, which is **also anchored on a leading `/`** — so `base_directory: apps/core` 422s a create exactly like `compose_file: docker-compose.yaml` does. The difference is that the *directory* pattern additionally admits the bare `/`, where the *file* pattern requires at least one character after the slash. That is why this PR carries two patterns rather than one shared "absolute path" rule: a shared rule strict enough for `compose_file` would refuse `base_directory: /`, which is what essentially every manifest says, including all of cast's own fixtures. Both patterns are transcribed with a citation comment in `manifest.ts`. ## Tests **299/299 green** (`npm run check && npm run build && npm test`), up from a 294 baseline. Five new cases in `test/manifest.test.ts`, covering the refusal paths and the boundary between the two patterns: - rejects a `compose_file` with no leading slash, and the message names the fix - rejects a `compose_file` that is the bare `/` (a directory, not a file — the file/directory pattern boundary) - rejects a `base_directory` with no leading slash - rejects a `publish_directory` with no leading slash - accepts `/` as `base_directory` and a nested absolute `publish_directory` (the regression guard for that boundary) ## Noted, not fixed (out of scope here) `test/wire.test.ts` and `test/apply.test.ts` still use `docker_compose_location: "docker-compose.yaml"` in hand-built **wire**-shaped objects. Those never pass through the manifest schema, so they are unaffected and still pass — but they are the same stale value, and are worth a sweep. Closes #49.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#52
No description provided.