Skip to content

Commit c884121

Browse files
authored
fix: prevent temp directory leak in node-modules-utils (#788)
* fix: prevent temp directory leak in node-modules-utils The persistNodeModules function had a scoping bug that could leak temporary directories on disk. The outer variables tmpDir and tempProjectRoot were initialized as empty strings (lines 79-80). Inside the try block, createTempProjectDir created a real temp directory and destructured the result into NEW block-scoped variables with the same names (line 92: const { tmpDir, tempProjectRoot } = ...). If saveOnDisk or any subsequent step threw, the catch block returned the OUTER empty strings — not the temp directory that was actually created. The caller, seeing empty strings, would skip cleanup (line 116: if (!tempDir) continue), leaving the temp directory orphaned. Fix: removed the shadowing const destructure and instead assign to the outer let variables, so the catch block always returns the actual temp directory path for cleanup. Also added type annotations to createFile() parameters (filePath, fileContent were implicit any). * test: add unit tests for node-modules-utils temp dir leak fix Adds unit tests for the persistNodeModules function to ensure that temporary directories are not leaked when an error occurs during disk write operations.
1 parent e8595a5 commit c884121

File tree

2 files changed

+207
-9
lines changed

2 files changed

+207
-9
lines changed

lib/analyzer/applications/node-modules-utils.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,20 +77,23 @@ async function persistNodeModules(
7777
fileNamesGroupedByDirectory: FilesByDirMap,
7878
): Promise<ScanPaths> {
7979
const modules = fileNamesGroupedByDirectory.get(project);
80-
const tmpDir: string = "";
81-
const tempProjectRoot: string = "";
8280

8381
if (!modules || modules.size === 0) {
8482
debug(`Empty application directory tree.`);
85-
86-
return {
87-
tempDir: tmpDir,
88-
tempProjectPath: tempProjectRoot,
89-
};
83+
return { tempDir: "", tempProjectPath: "" };
9084
}
9185

86+
// Create the temp directory first so we can return it in the catch block
87+
// for cleanup. Previously, the outer tmpDir/tempProjectRoot were always
88+
// empty strings, meaning any temp directory created before a failure in
89+
// saveOnDisk or later steps would be leaked (caller couldn't clean it up).
90+
let tmpDir = "";
91+
let tempProjectRoot = "";
92+
9293
try {
93-
const { tmpDir, tempProjectRoot } = await createTempProjectDir(project);
94+
const created = await createTempProjectDir(project);
95+
tmpDir = created.tmpDir;
96+
tempProjectRoot = created.tempProjectRoot;
9497

9598
await saveOnDisk(tmpDir, modules, filePathToContent);
9699

@@ -123,7 +126,10 @@ async function persistNodeModules(
123126
}
124127
}
125128

126-
async function createFile(filePath, fileContent): Promise<void> {
129+
async function createFile(
130+
filePath: string,
131+
fileContent: string,
132+
): Promise<void> {
127133
try {
128134
await mkdir(path.dirname(filePath), { recursive: true });
129135
await writeFile(filePath, fileContent, "utf-8");
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import * as fsPromises from "fs/promises";
2+
import * as path from "path";
3+
import { persistNodeModules } from "../../../../lib/analyzer/applications/node-modules-utils";
4+
import {
5+
FilePathToContent,
6+
FilesByDirMap,
7+
} from "../../../../lib/analyzer/applications/types";
8+
9+
// Mock fs/promises
10+
jest.mock("fs/promises", () => ({
11+
mkdtemp: jest.fn(),
12+
mkdir: jest.fn(),
13+
writeFile: jest.fn(),
14+
stat: jest.fn(),
15+
rm: jest.fn(),
16+
}));
17+
18+
const mockMkdtemp = fsPromises.mkdtemp as jest.MockedFunction<
19+
typeof fsPromises.mkdtemp
20+
>;
21+
const mockMkdir = fsPromises.mkdir as jest.MockedFunction<
22+
typeof fsPromises.mkdir
23+
>;
24+
const mockWriteFile = fsPromises.writeFile as jest.MockedFunction<
25+
typeof fsPromises.writeFile
26+
>;
27+
const mockStat = fsPromises.stat as jest.MockedFunction<typeof fsPromises.stat>;
28+
29+
describe("node-modules-utils", () => {
30+
beforeEach(() => {
31+
jest.resetAllMocks();
32+
});
33+
34+
describe("persistNodeModules", () => {
35+
const project = "my-project";
36+
37+
it("should return empty paths immediately if modules set is empty or undefined", async () => {
38+
const fileNamesGroupedByDirectory: FilesByDirMap = new Map();
39+
const filePathToContent: FilePathToContent = {};
40+
41+
const result = await persistNodeModules(
42+
project,
43+
filePathToContent,
44+
fileNamesGroupedByDirectory,
45+
);
46+
47+
expect(result).toEqual({ tempDir: "", tempProjectPath: "" });
48+
expect(mockMkdtemp).not.toHaveBeenCalled();
49+
expect(mockMkdir).not.toHaveBeenCalled();
50+
expect(mockWriteFile).not.toHaveBeenCalled();
51+
});
52+
53+
it("should successfully persist modules and return populated ScanPaths", async () => {
54+
const fileNamesGroupedByDirectory: FilesByDirMap = new Map();
55+
fileNamesGroupedByDirectory.set(project, new Set(["module1", "module2"]));
56+
57+
const filePathToContent: FilePathToContent = {
58+
module1: "content1",
59+
module2: "content2",
60+
};
61+
62+
const mockTmpDir = "/tmp/snyk-random123";
63+
mockMkdtemp.mockResolvedValue(mockTmpDir);
64+
mockMkdir.mockResolvedValue(undefined);
65+
mockWriteFile.mockResolvedValue(undefined);
66+
67+
// Simulate that package.json exists
68+
mockStat.mockResolvedValue({} as any);
69+
70+
const result = await persistNodeModules(
71+
project,
72+
filePathToContent,
73+
fileNamesGroupedByDirectory,
74+
);
75+
76+
const expectedTempProjectPath = path.join(mockTmpDir, project);
77+
78+
expect(result).toEqual({
79+
tempDir: mockTmpDir,
80+
tempProjectPath: expectedTempProjectPath,
81+
manifestPath: path.join(
82+
expectedTempProjectPath.substring(mockTmpDir.length),
83+
"package.json",
84+
),
85+
});
86+
87+
expect(mockMkdtemp).toHaveBeenCalledWith("snyk");
88+
expect(mockMkdir).toHaveBeenCalledWith(expectedTempProjectPath, {
89+
recursive: true,
90+
});
91+
expect(mockWriteFile).toHaveBeenCalledTimes(2); // One for each module
92+
// No synthetic manifest created
93+
});
94+
95+
it("should create synthetic manifest if stat fails (package.json does not exist)", async () => {
96+
const fileNamesGroupedByDirectory: FilesByDirMap = new Map();
97+
fileNamesGroupedByDirectory.set(project, new Set(["module1"]));
98+
99+
const filePathToContent: FilePathToContent = {
100+
module1: "content1",
101+
};
102+
103+
const mockTmpDir = "/tmp/snyk-random123";
104+
mockMkdtemp.mockResolvedValue(mockTmpDir);
105+
mockMkdir.mockResolvedValue(undefined);
106+
mockWriteFile.mockResolvedValue(undefined);
107+
108+
// Simulate that package.json DOES NOT exist
109+
mockStat.mockRejectedValue(new Error("ENOENT"));
110+
111+
const result = await persistNodeModules(
112+
project,
113+
filePathToContent,
114+
fileNamesGroupedByDirectory,
115+
);
116+
117+
const expectedTempProjectPath = path.join(mockTmpDir, project);
118+
119+
// manifestPath is deleted from result if synthetic manifest is created
120+
expect(result).toEqual({
121+
tempDir: mockTmpDir,
122+
tempProjectPath: expectedTempProjectPath,
123+
});
124+
125+
// One for module1, one for synthetic manifest
126+
expect(mockWriteFile).toHaveBeenCalledTimes(2);
127+
expect(mockWriteFile).toHaveBeenCalledWith(
128+
path.join(expectedTempProjectPath, "package.json"),
129+
"{}",
130+
"utf-8",
131+
);
132+
});
133+
134+
it("should catch saveOnDisk failure and return populated temporary paths for cleanup (PR #788 fix)", async () => {
135+
const fileNamesGroupedByDirectory: FilesByDirMap = new Map();
136+
fileNamesGroupedByDirectory.set(project, new Set(["module1"]));
137+
138+
const filePathToContent: FilePathToContent = {
139+
module1: "content1",
140+
};
141+
142+
const mockTmpDir = "/tmp/snyk-random123";
143+
mockMkdtemp.mockResolvedValue(mockTmpDir);
144+
mockMkdir.mockResolvedValue(undefined);
145+
146+
// Simulate a failure during saveOnDisk
147+
mockWriteFile.mockRejectedValue(new Error("Simulated write error"));
148+
149+
const result = await persistNodeModules(
150+
project,
151+
filePathToContent,
152+
fileNamesGroupedByDirectory,
153+
);
154+
155+
const expectedTempProjectPath = path.join(mockTmpDir, project);
156+
157+
expect(result).toEqual({
158+
tempDir: mockTmpDir,
159+
tempProjectPath: expectedTempProjectPath,
160+
});
161+
expect(mockMkdtemp).toHaveBeenCalled();
162+
expect(mockMkdir).toHaveBeenCalled();
163+
expect(mockWriteFile).toHaveBeenCalled();
164+
});
165+
166+
it("should catch initialization failure and return empty paths", async () => {
167+
const fileNamesGroupedByDirectory: FilesByDirMap = new Map();
168+
fileNamesGroupedByDirectory.set(project, new Set(["module1"]));
169+
170+
const filePathToContent: FilePathToContent = {
171+
module1: "content1",
172+
};
173+
174+
// Simulate a failure during mkdtemp (before assigning local variables)
175+
mockMkdtemp.mockRejectedValue(new Error("Simulated mkdtemp error"));
176+
177+
const result = await persistNodeModules(
178+
project,
179+
filePathToContent,
180+
fileNamesGroupedByDirectory,
181+
);
182+
183+
expect(result).toEqual({
184+
tempDir: "",
185+
tempProjectPath: "",
186+
});
187+
expect(mockMkdtemp).toHaveBeenCalled();
188+
expect(mockMkdir).not.toHaveBeenCalled();
189+
expect(mockWriteFile).not.toHaveBeenCalled();
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)