Skip to content

Commit 6274827

Browse files
Follow symlinks when copying files into containers (#1235)
1 parent 5dc5293 commit 6274827

File tree

5 files changed

+76
-6
lines changed

5 files changed

+76
-6
lines changed

docs/features/containers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ container.copyContentToContainer([{
160160
container.copyArchiveToContainer(nodeReadable, "/some/nested/remotedir");
161161
```
162162

163+
When copying files, symbolic links in `source` are followed and the linked file content is copied into the container.
164+
163165
An optional `mode` can be specified in octal for setting file permissions:
164166

165167
```js

packages/testcontainers/src/generic-container/generic-container.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PullPolicy } from "../utils/pull-policy";
66
import {
77
checkContainerIsHealthy,
88
checkContainerIsHealthyUdp,
9+
createTempSymlinkedFile,
910
getDockerEventStream,
1011
getRunningContainerNames,
1112
waitForDockerEvent,
@@ -374,6 +375,23 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
374375
expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining("hello world"));
375376
});
376377

378+
it("should follow symlink when copying file to container", async () => {
379+
if (process.platform === "win32") {
380+
return;
381+
}
382+
383+
const content = `hello world ${new RandomUuid().nextUuid()}`;
384+
const target = "/tmp/test.txt";
385+
await using symlinkedFile = await createTempSymlinkedFile(content);
386+
await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
387+
.withCopyFilesToContainer([{ source: symlinkedFile.symlink, target }])
388+
.withExposedPorts(8080)
389+
.start();
390+
391+
expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content));
392+
expect((await container.exec(["sh", "-c", `[ -L ${target} ]`])).exitCode).toBe(1);
393+
});
394+
377395
it("should copy file to container with permissions", async () => {
378396
const source = path.resolve(fixtures, "docker", "test.txt");
379397
const target = "/tmp/test.txt";
@@ -399,6 +417,24 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
399417
expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining("hello world"));
400418
});
401419

420+
it("should follow symlink when copying file to started container", async () => {
421+
if (process.platform === "win32") {
422+
return;
423+
}
424+
425+
const content = `hello world ${new RandomUuid().nextUuid()}`;
426+
const target = "/tmp/test.txt";
427+
await using symlinkedFile = await createTempSymlinkedFile(content);
428+
await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
429+
.withExposedPorts(8080)
430+
.start();
431+
432+
await container.copyFilesToContainer([{ source: symlinkedFile.symlink, target }]);
433+
434+
expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content));
435+
expect((await container.exec(["sh", "-c", `[ -L ${target} ]`])).exitCode).toBe(1);
436+
});
437+
402438
it("should copy directory to container", async () => {
403439
const source = path.resolve(fixtures, "docker");
404440
const target = "/tmp";

packages/testcontainers/src/generic-container/generic-container.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import archiver from "archiver";
22
import AsyncLock from "async-lock";
33
import { Container, ContainerCreateOptions, HostConfig } from "dockerode";
4+
import { promises as fs } from "fs";
45
import { Readable } from "stream";
56
import { containerLog, hash, log, toNanos } from "../common";
67
import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime";
@@ -179,7 +180,7 @@ export class GenericContainer implements TestContainer {
179180
}
180181

181182
if (this.filesToCopy.length > 0 || this.directoriesToCopy.length > 0 || this.contentsToCopy.length > 0) {
182-
const archive = this.createArchiveToCopyToContainer();
183+
const archive = await this.createArchiveToCopyToContainer();
183184
archive.finalize();
184185
await client.container.putArchive(container, archive, "/");
185186
}
@@ -255,11 +256,17 @@ export class GenericContainer implements TestContainer {
255256
}
256257
}
257258

258-
private createArchiveToCopyToContainer(): archiver.Archiver {
259+
private async createArchiveToCopyToContainer(): Promise<archiver.Archiver> {
259260
const tar = archiver("tar");
261+
const filesToCopyWithStats = await Promise.all(
262+
this.filesToCopy.map(async (fileToCopy) => ({
263+
...fileToCopy,
264+
stats: await fs.stat(fileToCopy.source),
265+
}))
266+
);
260267

261-
for (const { source, target, mode } of this.filesToCopy) {
262-
tar.file(source, { name: target, mode });
268+
for (const { source, target, mode, stats } of filesToCopyWithStats) {
269+
tar.file(source, { name: target, mode, stats });
263270
}
264271
for (const { source, target, mode } of this.directoriesToCopy) {
265272
tar.directory(source, target, { mode });

packages/testcontainers/src/generic-container/started-generic-container.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import archiver from "archiver";
22
import AsyncLock from "async-lock";
33
import Dockerode, { ContainerInspectInfo } from "dockerode";
4+
import { promises as fs } from "fs";
45
import { Readable } from "stream";
56
import { containerLog, log } from "../common";
67
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
@@ -183,7 +184,13 @@ export class StartedGenericContainer implements StartedTestContainer {
183184
log.debug(`Copying files to container...`, { containerId: this.container.id });
184185
const client = await getContainerRuntimeClient();
185186
const tar = archiver("tar");
186-
filesToCopy.forEach(({ source, target }) => tar.file(source, { name: target }));
187+
const filesToCopyWithStats = await Promise.all(
188+
filesToCopy.map(async (fileToCopy) => ({
189+
...fileToCopy,
190+
stats: await fs.stat(fileToCopy.source),
191+
}))
192+
);
193+
filesToCopyWithStats.forEach(({ source, target, mode, stats }) => tar.file(source, { name: target, mode, stats }));
187194
tar.finalize();
188195
await client.container.putArchive(this.container, tar, "/");
189196
log.debug(`Copied files to container`, { containerId: this.container.id });

packages/testcontainers/src/utils/test-helper.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { GetEventsOptions, ImageInspectInfo } from "dockerode";
22
import { createServer, Server } from "http";
33
import { createSocket } from "node:dgram";
44
import fs from "node:fs";
5-
import { EOL } from "node:os";
5+
import { EOL, tmpdir } from "node:os";
66
import path from "node:path";
77
import { Readable } from "stream";
88
import { Agent, request } from "undici";
@@ -198,3 +198,21 @@ export async function createTestServer(port: number): Promise<Server> {
198198
await new Promise<void>((resolve) => server.listen(port, resolve));
199199
return server;
200200
}
201+
202+
export const createTempSymlinkedFile = async (
203+
content: string
204+
): Promise<{ source: string; symlink: string } & AsyncDisposable> => {
205+
const directory = await fs.promises.mkdtemp(path.join(tmpdir(), "testcontainers-"));
206+
const source = path.join(directory, "source.txt");
207+
const symlink = path.join(directory, "symlink.txt");
208+
await fs.promises.writeFile(source, content);
209+
await fs.promises.symlink(source, symlink);
210+
211+
return {
212+
source,
213+
symlink,
214+
[Symbol.asyncDispose]: async () => {
215+
await fs.promises.rm(directory, { recursive: true, force: true });
216+
},
217+
};
218+
};

0 commit comments

Comments
 (0)