fix: File Mount with nested File Path created as directory instead of file - #5153
fix: File Mount with nested File Path created as directory instead of file#5153vaelu wants to merge 2 commits into
Conversation
… file updateFileMount never ensured the parent directory existed and never checked whether fullPath already existed as a directory before writing, so `echo ... > fullPath` failed with "Is a directory" whenever something (most commonly Docker's own bind-mount fallback, when a compose service's volumes: declaration references the path before any File Mount exists for it) had already created a directory there. The error was silently swallowed (bare catch, console.log only), so the UI reported success regardless. createFileMount's getCreateFileCommand had the same blind spot: it mkdir -p's the parent directory but never checked fullPath itself. Fixes Dokploy#5152 - getCreateFileCommand and createFile now clear a pre-existing directory at fullPath before writing, so both the remote (shell) and local (fs) code paths self-heal instead of failing silently. - updateFileMount now reuses getCreateFileCommand instead of duplicating a broken inline command, so both create and update share the same fixed logic. - Adds a regression test that reproduces the bug (fails on the pre-fix code with the exact "Is a directory" / EISDIR errors we hit in production) and verifies the fix.
| const encodedContent = encodeBase64(content); | ||
| return ` | ||
| mkdir -p ${quote([directory])}; | ||
| if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi; |
There was a problem hiding this comment.
Uncontained recursive path deletion
When a file mount uses enough ../ segments to resolve to an existing directory outside its files root, the unrestricted filePath reaches the new rm -rf or fs.rmSync branch, recursively deleting unrelated host data before writing the file. How this was verified: The mount schema accepts an unconstrained filePath, which is joined to the base path and deleted without a canonical containment check.
There was a problem hiding this comment.
Good catch — fixed in 7121209 by adding assertPathIsContained, which resolves the path and throws if it escapes the base files directory. Added a regression test for it too.
| const encodedContent = encodeBase64(content); | ||
| return ` | ||
| mkdir -p ${quote([directory])}; | ||
| if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi; |
There was a problem hiding this comment.
Populated directories are destroyed
When the configured file target is a populated directory rather than Docker's stale empty fallback directory, this branch recursively removes all of its contents and replaces it with one file. The guard checks only whether the target is a directory, so ordinary mount creation or save can destroy data stored beneath that path.
There was a problem hiding this comment.
Also fixed in 7121209 — the cleanup now only removes the target if it's an empty directory (rmdir/fs.rmdirSync, which fail on non-empty), matching the actual bug scenario (Docker's bind-mount fallback always creates an empty one) without being able to touch a directory that holds real data. A populated directory now survives the write failing loudly instead. Added a regression test for this too.
Greptile flagged two real issues in the initial fix: - filePath is unconstrained user input; the rm -rf/fs.rmSync additions had no check that the resolved path stayed inside the app's files directory, so a crafted "../../..." filePath could delete arbitrary host paths. - The stale-directory removal didn't check the directory was actually empty, so a File Mount pointed at a real, populated directory would have its contents wiped rather than the write failing loudly. Adds assertPathIsContained (resolves and checks the target stays under the base files directory, throws otherwise) and switches the cleanup from a recursive force-delete to rmdir/fs.rmdirSync, which only ever removes an empty directory — matching the actual bug scenario (Docker's bind-mount fallback always creates an empty one) without being able to touch anything with real content in it. Extends the regression test with both properties: a crafted escaping filePath is rejected, and a populated directory at the target path survives a failed write untouched.
Summary
Fixes #5152 — a File Mount whose File Path contains a
/(e.g.nginx/nginx.conf.template) gets created as an empty directory instead of a file, both on Create and on Edit → Save, with the error silently swallowed.Root cause
updateFileMount(packages/server/src/services/mount.ts) never ranmkdir -pon the parent directory, and never checked whetherfullPathalready existed as a directory before runningecho ... > fullPath. If it did (typically because Docker's own bind-mount fallback created an empty directory there first, when the compose service'svolumes:declaration referenced the path before any File Mount content existed for it), the shell redirection failed withIs a directory— and the barecatch { console.log(...) }swallowed it, so the UI reported success regardless.createFileMount→getCreateFileCommand(packages/server/src/utils/docker/utils.ts) already didmkdir -pthe parent directory, but had the same blind spot forfullPathitself.Fix
getCreateFileCommandandcreateFilenow clear a pre-existing directory atfullPathbefore writing (rm -rf/fs.rmSyncguarded by an existence+type check), so both the remote (shell) and local (fs) code paths self-heal instead of failing silently.updateFileMountnow reusesgetCreateFileCommandinstead of duplicating a broken inline command — both create and update share the same fixed logic, and the duplicateencodeBase64construction is gone.updateFileMount's catch block now logs the actual error instead of swallowing it, to make any future failure here visible in logs instead of silent.Test plan
Added
apps/dokploy/__test__/deploy/file-mount-nested-directory.test.ts, following the existing pattern inapps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts(build the shell command, execute it withexecFileSync, assert on the resulting file).Is a directoryfrom the shell command,EISDIRfromcreateFile) — confirmed by temporarily stashing the fix and re-running.pnpm --filter=dokploy exec vitest run --config __test__/vitest.config.ts __test__/deploy— same pre-existing failures inapplication.real.test.ts(network/Docker-build dependent "REAL Execution Tests") occur identically with or without this change; nothing in the affected file's test suite regressed.pnpm exec biome checkon the three changed files — clean (pre-existing, unrelated warnings elsewhere inutils.tsleft untouched, out of scope for this fix).tsc --noEmitonpackages/server— clean.Ran on a local clone (not a full server + UI click-through, since this fix is at the file-system/shell-command layer and is covered by the regression test above) — happy to also verify end-to-end against a running Dokploy instance if useful.
Greptile Summary
The PR fixes nested file mounts that were mistakenly created as directories and incorporates safeguards requested in the previous review.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Reviews (2): Last reviewed commit: "fix: address review — path containment +..." | Re-trigger Greptile