Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/dokploy/__test__/compose/file-mounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createMount } from "@dokploy/server/services/mount";
import {
createFile,
getCreateFileCommand,
} from "@dokploy/server/utils/docker/utils";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({ insert: vi.fn() }));

vi.mock("@dokploy/server/db", () => ({
db: { insert: mocks.insert },
}));

const makeFilesDir = () =>
fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-file-mounts-"));

describe("file mounts", () => {
beforeEach(() => {
mocks.insert.mockReset();
});

it("createFile replaces a directory Docker created at the mount path", async () => {
const filesDir = makeFilesDir();
fs.mkdirSync(path.join(filesDir, "sql/init.sql"), { recursive: true });

await createFile(filesDir, "sql/init.sql", "CREATE TABLE t (id int);");

const target = path.join(filesDir, "sql/init.sql");
expect(fs.statSync(target).isFile()).toBe(true);
expect(fs.readFileSync(target, "utf-8")).toBe("CREATE TABLE t (id int);");
});

it("getCreateFileCommand replaces a directory Docker created at the mount path", () => {
const filesDir = makeFilesDir();
fs.mkdirSync(path.join(filesDir, "app.conf"));

execSync(getCreateFileCommand(filesDir, "app.conf", "listen = 8080"), {
shell: "/bin/sh",
});

const target = path.join(filesDir, "app.conf");
expect(fs.statSync(target).isFile()).toBe(true);
expect(fs.readFileSync(target, "utf-8")).toBe("listen = 8080");
});

it("createFile keeps a non-empty directory at the mount path", async () => {
const filesDir = makeFilesDir();
fs.mkdirSync(path.join(filesDir, "sql"));
fs.writeFileSync(path.join(filesDir, "sql/init.sql"), "keep");

await expect(createFile(filesDir, "sql", "x")).rejects.toThrow();
expect(fs.readFileSync(path.join(filesDir, "sql/init.sql"), "utf-8")).toBe(
"keep",
);
});

it("getCreateFileCommand keeps a non-empty directory at the mount path", () => {
const filesDir = makeFilesDir();
fs.mkdirSync(path.join(filesDir, "sql"));
fs.writeFileSync(path.join(filesDir, "sql/init.sql"), "keep");

expect(() =>
execSync(getCreateFileCommand(filesDir, "sql", "x"), {
shell: "/bin/sh",
stdio: "ignore",
}),
).toThrow();
expect(fs.readFileSync(path.join(filesDir, "sql/init.sql"), "utf-8")).toBe(
"keep",
);
});

it.each([".", "./", "..", "../outside.conf", "sql/../.."])(
"rejects filePath %j outside the files directory",
async (filePath) => {
const filesDir = makeFilesDir();
await expect(createFile(filesDir, filePath, "x")).rejects.toThrow(
"Invalid file path",
);
expect(() => getCreateFileCommand(filesDir, filePath, "x")).toThrow(
"Invalid file path",
);
},
);

it.each([".", "..", "../outside.conf"])(
"createMount rejects filePath %j before inserting it",
async (filePath) => {
await expect(
createMount({
serviceId: "compose-id",
serviceType: "compose",
type: "file",
mountPath: "/",
filePath,
content: "x",
}),
).rejects.toThrow("Invalid file path");
expect(mocks.insert).not.toHaveBeenCalled();
},
);

it.each([undefined, "", " "])(
"createMount rejects a file mount with filePath %j before inserting it",
async (filePath) => {
await expect(
createMount({
serviceId: "compose-id",
serviceType: "compose",
type: "file",
mountPath: "/",
filePath,
content: "listen = 8080",
}),
).rejects.toThrow("File path is required for file mounts");
expect(mocks.insert).not.toHaveBeenCalled();
},
);
});
37 changes: 27 additions & 10 deletions packages/server/src/services/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
} from "@dokploy/server/db/schema";
import {
createFile,
encodeBase64,
getCreateFileCommand,
isValidMountFilePath,
} from "@dokploy/server/utils/docker/utils";
import { removeFileOrDirectory } from "@dokploy/server/utils/filesystem/directory";
import {
Expand All @@ -25,6 +25,20 @@ export type Mount = typeof mounts.$inferSelect;

export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
try {
// An empty filePath would write the content over the service's whole files/ directory
if (input.type === "file" && !input.filePath?.trim()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "File path is required for file mounts",
});
}
// Checked before insert: deleting a saved row runs rm -rf on its path
if (input.type === "file" && !isValidMountFilePath(input.filePath ?? "")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid file path: ${input.filePath}`,
});
}
const { serviceId, ...rest } = input;
const value = await db
.insert(mounts)
Expand Down Expand Up @@ -176,6 +190,12 @@ export const updateMount = async (
mountId: string,
mountData: Partial<Mount>,
) => {
if (mountData.filePath && !isValidMountFilePath(mountData.filePath)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid file path: ${mountData.filePath}`,
});
}
const mount = await db.transaction(async (tx) => {
const mount = await tx
.update(mounts)
Expand Down Expand Up @@ -261,17 +281,14 @@ export const updateFileMount = async (mountId: string) => {
const mount = await findMountById(mountId);
if (!mount || !mount.filePath) return;
const basePath = await getBaseFilesPath(mountId);
const fullPath = path.join(basePath, mount.filePath);
const directory = path.dirname(fullPath);

try {
const serverId = await getServerId(mount);
const encodedContent = encodeBase64(mount.content || "");
const command = `
mkdir -p ${quote([directory])};
if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi;
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
`;
const command = getCreateFileCommand(
basePath,
mount.filePath,
mount.content || "",
);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
Expand All @@ -289,7 +306,7 @@ export const updateFileMount = async (mountId: string) => {

export const deleteFileMount = async (mountId: string) => {
const mount = await findMountById(mountId);
if (!mount.filePath) return;
if (!mount.filePath || !isValidMountFilePath(mount.filePath)) return;
const basePath = await getBaseFilesPath(mountId);

const fullPath = path.join(basePath, mount.filePath);
Expand Down
23 changes: 21 additions & 2 deletions packages/server/src/utils/docker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,20 +763,38 @@ export const generateFileMounts = (
});
};

export const isValidMountFilePath = (filePath: string) => {
const relative = path.relative("/base", path.join("/base", filePath));
return (
!!relative && relative !== ".." && !relative.startsWith(`..${path.sep}`)
);
};

const resolveMountFilePath = (outputPath: string, filePath: string) => {
if (!isValidMountFilePath(filePath)) {
throw new Error(`Invalid file path: ${filePath}`);
}
return path.join(outputPath, filePath);
};

export const createFile = async (
outputPath: string,
filePath: string,
content: string,
) => {
try {
const fullPath = path.join(outputPath, filePath);
const fullPath = resolveMountFilePath(outputPath, filePath);
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
fs.mkdirSync(fullPath, { recursive: true });
return;
}

const directory = path.dirname(fullPath);
fs.mkdirSync(directory, { recursive: true });
// Docker creates missing bind sources as empty directories if the stack was deployed before the mount existed
if (fs.statSync(fullPath, { throwIfNoEntry: false })?.isDirectory()) {
fs.rmdirSync(fullPath);
}
fs.writeFileSync(fullPath, content || "");
} catch (error) {
throw error;
Expand All @@ -790,7 +808,7 @@ export const getCreateFileCommand = (
filePath: string,
content: string,
) => {
const fullPath = path.join(outputPath, filePath);
const fullPath = resolveMountFilePath(outputPath, filePath);
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
return `mkdir -p ${quote([fullPath])};`;
}
Expand All @@ -799,6 +817,7 @@ export const getCreateFileCommand = (
const encodedContent = encodeBase64(content);
return `
mkdir -p ${quote([directory])};
if [ -d ${quote([fullPath])} ]; then rmdir ${quote([fullPath])}; fi;
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
`;
};
Expand Down
Loading