Skip to content

Commit 58f9ac2

Browse files
committed
fix(clean): refuse to delete outside the project directory
The paths reaching cleanPath now include the configurable build directory, so a buildPath such as '../build' resolved onto a sibling of the project and deleted it. Paths that escape the project are skipped with a warning and reported as unsuccessful.
1 parent 344d570 commit 58f9ac2

2 files changed

Lines changed: 100 additions & 10 deletions

File tree

lib/services/project-cleanup-service.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ export class ProjectCleanupService implements IProjectCleanupService {
2020
private $fs: IFileSystem,
2121
private $logger: ILogger,
2222
private $projectHelper: IProjectHelper,
23-
private $terminalSpinnerService: ITerminalSpinnerService
23+
private $terminalSpinnerService: ITerminalSpinnerService,
2424
) {}
2525

2626
public async clean(
2727
pathsToClean: string[],
28-
options?: IProjectCleanupOptions
28+
options?: IProjectCleanupOptions,
2929
): Promise<IProjectCleanupResult> {
3030
this.spinner = this.$terminalSpinnerService.createSpinner({
3131
isSilent: options?.silent,
@@ -39,10 +39,10 @@ export class ProjectCleanupService implements IProjectCleanupService {
3939
(error) => {
4040
this.$logger.trace(
4141
`Encountered error while cleaning. Error is: ${error.message}.`,
42-
error
42+
error,
4343
);
4444
return { ok: false };
45-
}
45+
},
4646
);
4747
if (stats && "size" in cleanRes) {
4848
stats.set(pathToClean, cleanRes.size);
@@ -63,7 +63,7 @@ export class ProjectCleanupService implements IProjectCleanupService {
6363

6464
public async cleanPath(
6565
pathToClean: string,
66-
options?: IProjectCleanupOptions
66+
options?: IProjectCleanupOptions,
6767
): Promise<IProjectPathCleanupResult> {
6868
const dryRun = options?.dryRun ?? false;
6969
const logPrefix = dryRun ? color.grey("(dry run) ") : "";
@@ -77,9 +77,21 @@ export class ProjectCleanupService implements IProjectCleanupService {
7777
}
7878

7979
const filePath = path.resolve(this.$projectHelper.projectDir, pathToClean);
80-
const displayPath = color.yellow(
81-
`${path.relative(this.$projectHelper.projectDir, filePath)}`
80+
const relativePath = path.relative(
81+
this.$projectHelper.projectDir,
82+
filePath,
8283
);
84+
const displayPath = color.yellow(`${relativePath}`);
85+
86+
// Paths reach here from the project config - buildPath and
87+
// cli.pathsToClean among them - where a leading `..` resolves onto
88+
// directories the project does not own.
89+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
90+
this.$logger.warn(
91+
`Skipping '${filePath}' because it is outside the project directory.`,
92+
);
93+
return { ok: false };
94+
}
8395

8496
this.$logger.trace(`${logPrefix}Trying to clean '${filePath}'`);
8597

@@ -93,13 +105,13 @@ export class ProjectCleanupService implements IProjectCleanupService {
93105

94106
if (stat.isDirectory()) {
95107
this.$logger.trace(
96-
`${logPrefix}Path '${filePath}' is a directory, deleting.`
108+
`${logPrefix}Path '${filePath}' is a directory, deleting.`,
97109
);
98110
!dryRun && this.$fs.deleteDirectorySafe(filePath);
99111
fileType = "directory";
100112
} else {
101113
this.$logger.trace(
102-
`${logPrefix}Path '${filePath}' is a file, deleting.`
114+
`${logPrefix}Path '${filePath}' is a file, deleting.`,
103115
);
104116
!dryRun && this.$fs.deleteFile(filePath);
105117
fileType = "file";
@@ -122,7 +134,7 @@ export class ProjectCleanupService implements IProjectCleanupService {
122134

123135
this.$logger.trace(`${logPrefix}Path '${filePath}' not found, skipping.`);
124136
this.spinner.info(
125-
`${logPrefix}Skipping ${displayPath} because it doesn't exist.`
137+
`${logPrefix}Skipping ${displayPath} because it doesn't exist.`,
126138
);
127139

128140
if (options?.stats) {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { assert } from "chai";
2+
import * as path from "path";
3+
import { Yok } from "../../lib/common/yok";
4+
import { ProjectCleanupService } from "../../lib/services/project-cleanup-service";
5+
import { IInjector } from "../../lib/common/definitions/yok";
6+
7+
const projectDir = path.join("/tmp", "nsm-cleanup-project");
8+
9+
function createTestInjector(deletedPaths: string[]): IInjector {
10+
const testInjector = new Yok();
11+
testInjector.register("fs", {
12+
exists: (p: string) => !deletedPaths.includes(p),
13+
getFsStats: () => ({ isDirectory: () => true }),
14+
getSize: () => 0,
15+
deleteDirectorySafe: (p: string) => deletedPaths.push(p),
16+
deleteFile: (p: string) => deletedPaths.push(p),
17+
});
18+
testInjector.register("logger", {
19+
trace: (): void => undefined,
20+
warn: (): void => undefined,
21+
info: (): void => undefined,
22+
});
23+
testInjector.register("projectHelper", { projectDir });
24+
testInjector.register("terminalSpinnerService", {
25+
createSpinner: () => ({
26+
clear: (): void => undefined,
27+
start: (): void => undefined,
28+
stop: (): void => undefined,
29+
succeed: (): void => undefined,
30+
fail: (): void => undefined,
31+
text: "",
32+
}),
33+
});
34+
35+
return testInjector;
36+
}
37+
38+
describe("projectCleanupService", () => {
39+
let deletedPaths: string[];
40+
let service: ProjectCleanupService;
41+
42+
beforeEach(() => {
43+
deletedPaths = [];
44+
service = createTestInjector(deletedPaths).resolve(ProjectCleanupService);
45+
});
46+
47+
it("cleans a path inside the project", async () => {
48+
const result = await service.clean(["platforms"], { silent: true });
49+
50+
assert.isTrue(result.ok);
51+
assert.deepStrictEqual(deletedPaths, [path.join(projectDir, "platforms")]);
52+
});
53+
54+
it("refuses a path that escapes the project directory", async () => {
55+
const result = await service.clean(["../sibling"], { silent: true });
56+
57+
assert.isFalse(result.ok);
58+
assert.deepStrictEqual(deletedPaths, []);
59+
});
60+
61+
it("refuses an absolute path outside the project directory", async () => {
62+
const result = await service.clean([path.join("/tmp", "elsewhere")], {
63+
silent: true,
64+
});
65+
66+
assert.isFalse(result.ok);
67+
assert.deepStrictEqual(deletedPaths, []);
68+
});
69+
70+
it("allows an absolute path that resolves inside the project", async () => {
71+
const result = await service.clean([path.join(projectDir, "platforms")], {
72+
silent: true,
73+
});
74+
75+
assert.isTrue(result.ok);
76+
assert.deepStrictEqual(deletedPaths, [path.join(projectDir, "platforms")]);
77+
});
78+
});

0 commit comments

Comments
 (0)