diff --git a/docs/features/containers.md b/docs/features/containers.md index 55331e8c9..86802eba6 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -160,6 +160,8 @@ container.copyContentToContainer([{ container.copyArchiveToContainer(nodeReadable, "/some/nested/remotedir"); ``` +When copying files, symbolic links in `source` are followed and the linked file content is copied into the container. + An optional `mode` can be specified in octal for setting file permissions: ```js diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 7b7346015..4ec2c8c59 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -6,6 +6,7 @@ import { PullPolicy } from "../utils/pull-policy"; import { checkContainerIsHealthy, checkContainerIsHealthyUdp, + createTempSymlinkedFile, getDockerEventStream, getRunningContainerNames, waitForDockerEvent, @@ -364,6 +365,23 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining("hello world")); }); + it("should follow symlink when copying file to container", async () => { + if (process.platform === "win32") { + return; + } + + const content = `hello world ${new RandomUuid().nextUuid()}`; + const target = "/tmp/test.txt"; + await using symlinkedFile = await createTempSymlinkedFile(content); + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withCopyFilesToContainer([{ source: symlinkedFile.symlink, target }]) + .withExposedPorts(8080) + .start(); + + expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content)); + expect((await container.exec(["sh", "-c", `[ -L ${target} ]`])).exitCode).toBe(1); + }); + it("should copy file to container with permissions", async () => { const source = path.resolve(fixtures, "docker", "test.txt"); const target = "/tmp/test.txt"; @@ -389,6 +407,24 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining("hello world")); }); + it("should follow symlink when copying file to started container", async () => { + if (process.platform === "win32") { + return; + } + + const content = `hello world ${new RandomUuid().nextUuid()}`; + const target = "/tmp/test.txt"; + await using symlinkedFile = await createTempSymlinkedFile(content); + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); + + await container.copyFilesToContainer([{ source: symlinkedFile.symlink, target }]); + + expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content)); + expect((await container.exec(["sh", "-c", `[ -L ${target} ]`])).exitCode).toBe(1); + }); + it("should copy directory to container", async () => { const source = path.resolve(fixtures, "docker"); const target = "/tmp"; diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index bada4e422..490cf3e29 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,6 +1,7 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; +import { promises as fs } from "fs"; import { Readable } from "stream"; import { containerLog, hash, log, toNanos } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; @@ -179,7 +180,7 @@ export class GenericContainer implements TestContainer { } if (this.filesToCopy.length > 0 || this.directoriesToCopy.length > 0 || this.contentsToCopy.length > 0) { - const archive = this.createArchiveToCopyToContainer(); + const archive = await this.createArchiveToCopyToContainer(); archive.finalize(); await client.container.putArchive(container, archive, "/"); } @@ -255,11 +256,17 @@ export class GenericContainer implements TestContainer { } } - private createArchiveToCopyToContainer(): archiver.Archiver { + private async createArchiveToCopyToContainer(): Promise { const tar = archiver("tar"); + const filesToCopyWithStats = await Promise.all( + this.filesToCopy.map(async (fileToCopy) => ({ + ...fileToCopy, + stats: await fs.stat(fileToCopy.source), + })) + ); - for (const { source, target, mode } of this.filesToCopy) { - tar.file(source, { name: target, mode }); + for (const { source, target, mode, stats } of filesToCopyWithStats) { + tar.file(source, { name: target, mode, stats }); } for (const { source, target, mode } of this.directoriesToCopy) { tar.directory(source, target, { mode }); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 32f7d3925..b349f4d90 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -1,6 +1,7 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; import Dockerode, { ContainerInspectInfo } from "dockerode"; +import { promises as fs } from "fs"; import { Readable } from "stream"; import { containerLog, log } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; @@ -183,7 +184,13 @@ export class StartedGenericContainer implements StartedTestContainer { log.debug(`Copying files to container...`, { containerId: this.container.id }); const client = await getContainerRuntimeClient(); const tar = archiver("tar"); - filesToCopy.forEach(({ source, target }) => tar.file(source, { name: target })); + const filesToCopyWithStats = await Promise.all( + filesToCopy.map(async (fileToCopy) => ({ + ...fileToCopy, + stats: await fs.stat(fileToCopy.source), + })) + ); + filesToCopyWithStats.forEach(({ source, target, mode, stats }) => tar.file(source, { name: target, mode, stats })); tar.finalize(); await client.container.putArchive(this.container, tar, "/"); log.debug(`Copied files to container`, { containerId: this.container.id }); diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 70e4e8fbe..7028849f6 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -2,7 +2,7 @@ import { GetEventsOptions, ImageInspectInfo } from "dockerode"; import { createServer, Server } from "http"; import { createSocket } from "node:dgram"; import fs from "node:fs"; -import { EOL } from "node:os"; +import { EOL, tmpdir } from "node:os"; import path from "node:path"; import { Readable } from "stream"; import { Agent, request } from "undici"; @@ -202,3 +202,21 @@ export async function createTestServer(port: number): Promise { await new Promise((resolve) => server.listen(port, resolve)); return server; } + +export const createTempSymlinkedFile = async ( + content: string +): Promise<{ source: string; symlink: string } & AsyncDisposable> => { + const directory = await fs.promises.mkdtemp(path.join(tmpdir(), "testcontainers-")); + const source = path.join(directory, "source.txt"); + const symlink = path.join(directory, "symlink.txt"); + await fs.promises.writeFile(source, content); + await fs.promises.symlink(source, symlink); + + return { + source, + symlink, + [Symbol.asyncDispose]: async () => { + await fs.promises.rm(directory, { recursive: true, force: true }); + }, + }; +};