File size: 6,927 Bytes
fc93158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
  ArchiveSecurityError,
  createArchiveSymlinkTraversalError,
  mergeExtractedTreeIntoDestination,
  prepareArchiveDestinationDir,
  prepareArchiveOutputPath,
  withStagedArchiveDestination,
} from "./archive-staging.js";

const directorySymlinkType = process.platform === "win32" ? "junction" : undefined;

async function withTempDir(prefix: string, run: (dir: string) => Promise<void>) {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
  try {
    await run(dir);
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
}

describe("archive-staging helpers", () => {
  it("accepts real destination directories and returns their real path", async () => {
    await withTempDir("openclaw-archive-staging-", async (rootDir) => {
      const destDir = path.join(rootDir, "dest");
      await fs.mkdir(destDir, { recursive: true });

      await expect(prepareArchiveDestinationDir(destDir)).resolves.toBe(await fs.realpath(destDir));
    });
  });

  it.runIf(process.platform !== "win32")(
    "rejects symlink and non-directory archive destinations",
    async () => {
      await withTempDir("openclaw-archive-staging-", async (rootDir) => {
        const realDestDir = path.join(rootDir, "real-dest");
        const symlinkDestDir = path.join(rootDir, "dest-link");
        const fileDest = path.join(rootDir, "dest.txt");
        await fs.mkdir(realDestDir, { recursive: true });
        await fs.symlink(realDestDir, symlinkDestDir, directorySymlinkType);
        await fs.writeFile(fileDest, "nope", "utf8");

        await expect(prepareArchiveDestinationDir(symlinkDestDir)).rejects.toMatchObject({
          code: "destination-symlink",
        } satisfies Partial<ArchiveSecurityError>);
        await expect(prepareArchiveDestinationDir(fileDest)).rejects.toMatchObject({
          code: "destination-not-directory",
        } satisfies Partial<ArchiveSecurityError>);
      });
    },
  );

  it("creates in-destination parent directories for file outputs", async () => {
    await withTempDir("openclaw-archive-staging-", async (rootDir) => {
      const destDir = path.join(rootDir, "dest");
      await fs.mkdir(destDir, { recursive: true });
      const destinationRealDir = await prepareArchiveDestinationDir(destDir);
      const outPath = path.join(destDir, "nested", "payload.txt");

      await expect(
        prepareArchiveOutputPath({
          destinationDir: destDir,
          destinationRealDir,
          relPath: "nested/payload.txt",
          outPath,
          originalPath: "nested/payload.txt",
          isDirectory: false,
        }),
      ).resolves.toBeUndefined();

      await expect(fs.stat(path.dirname(outPath))).resolves.toMatchObject({
        isDirectory: expect.any(Function),
      });
    });
  });

  it.runIf(process.platform !== "win32")(
    "rejects output paths that traverse a destination symlink",
    async () => {
      await withTempDir("openclaw-archive-staging-", async (rootDir) => {
        const destDir = path.join(rootDir, "dest");
        const outsideDir = path.join(rootDir, "outside");
        const linkDir = path.join(destDir, "escape");
        await fs.mkdir(destDir, { recursive: true });
        await fs.mkdir(outsideDir, { recursive: true });
        await fs.symlink(outsideDir, linkDir, directorySymlinkType);
        const destinationRealDir = await prepareArchiveDestinationDir(destDir);

        await expect(
          prepareArchiveOutputPath({
            destinationDir: destDir,
            destinationRealDir,
            relPath: "escape/payload.txt",
            outPath: path.join(linkDir, "payload.txt"),
            originalPath: "escape/payload.txt",
            isDirectory: false,
          }),
        ).rejects.toMatchObject({
          code: "destination-symlink-traversal",
        } satisfies Partial<ArchiveSecurityError>);
      });
    },
  );

  it("cleans up staged archive directories after success and failure", async () => {
    await withTempDir("openclaw-archive-staging-", async (rootDir) => {
      const destDir = path.join(rootDir, "dest");
      await fs.mkdir(destDir, { recursive: true });
      const destinationRealDir = await prepareArchiveDestinationDir(destDir);
      let successStage = "";

      await withStagedArchiveDestination({
        destinationRealDir,
        run: async (stagingDir) => {
          successStage = stagingDir;
          await fs.writeFile(path.join(stagingDir, "payload.txt"), "ok", "utf8");
        },
      });
      await expect(fs.stat(successStage)).rejects.toMatchObject({ code: "ENOENT" });

      let failureStage = "";
      await expect(
        withStagedArchiveDestination({
          destinationRealDir,
          run: async (stagingDir) => {
            failureStage = stagingDir;
            throw new Error("boom");
          },
        }),
      ).rejects.toThrow("boom");
      await expect(fs.stat(failureStage)).rejects.toMatchObject({ code: "ENOENT" });
    });
  });

  it.runIf(process.platform !== "win32")(
    "merges staged trees and rejects symlink entries from the source",
    async () => {
      await withTempDir("openclaw-archive-staging-", async (rootDir) => {
        const sourceDir = path.join(rootDir, "source");
        const sourceNestedDir = path.join(sourceDir, "nested");
        const destDir = path.join(rootDir, "dest");
        const outsideDir = path.join(rootDir, "outside");
        await fs.mkdir(sourceNestedDir, { recursive: true });
        await fs.mkdir(destDir, { recursive: true });
        await fs.mkdir(outsideDir, { recursive: true });
        await fs.writeFile(path.join(sourceNestedDir, "payload.txt"), "hi", "utf8");

        const destinationRealDir = await prepareArchiveDestinationDir(destDir);
        await mergeExtractedTreeIntoDestination({
          sourceDir,
          destinationDir: destDir,
          destinationRealDir,
        });
        await expect(
          fs.readFile(path.join(destDir, "nested", "payload.txt"), "utf8"),
        ).resolves.toBe("hi");

        await fs.symlink(outsideDir, path.join(sourceDir, "escape"), directorySymlinkType);
        await expect(
          mergeExtractedTreeIntoDestination({
            sourceDir,
            destinationDir: destDir,
            destinationRealDir,
          }),
        ).rejects.toMatchObject({
          code: "destination-symlink-traversal",
        } satisfies Partial<ArchiveSecurityError>);
      });
    },
  );

  it("builds a typed archive symlink traversal error", () => {
    const error = createArchiveSymlinkTraversalError("nested/payload.txt");
    expect(error).toBeInstanceOf(ArchiveSecurityError);
    expect(error.code).toBe("destination-symlink-traversal");
    expect(error.message).toContain("nested/payload.txt");
  });
});