Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t

## Recent Changes

- Enhancement: Added a max depth of 100 for the `UssUtils.deleteDirectory` function. Relative directories are now filtered from the list of items to delete. [#214](https://github.com/zowe/zowe-cli-ftp-plugin/pull/214)
- BugFix: Added Node 24 support. [#185](https://github.com/zowe/zowe-cli-ftp-plugin/pull/185)

## `2.1.9`
Expand All @@ -15,12 +16,10 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t
- BugFix: Upload dataset using Buffer, stead of string. [2533](https://github.com/zowe/vscode-extension-for-zowe/issues/2533)
- Simplify the preparation for JCL system tests


## `2.1.7`

- Update the version of zos-node-accessor to 1.0.16


## `2.1.6`

- BugFix: Add missing npm-shrinkwrap
Expand All @@ -30,6 +29,7 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t
- Add checking the uss file path when upload file or stdin to uss file.

## `2.1.4`

- BugFix: Provide new utility function that checks file names for valid characters [143](https://github.com/zowe/zowe-cli-ftp-plugin/issues/143).

## `2.1.3`
Expand All @@ -38,7 +38,7 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t

## `2.1.2`

- Updated the `zos-node-accessor` package to 1.0.14 for technical currency.
- Updated the `zos-node-accessor` package to 1.0.14 for technical currency.

## `2.1.1`

Expand Down Expand Up @@ -76,7 +76,7 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t
## `1.8.6`

- BugFix: Refine error message of uploading partition dataset member.
Refine description of parameter dcb of uploading sequential dataset.
Refine description of parameter dcb of uploading sequential dataset.
- Change `bright` command to `zowe` in test scripts.

## `1.8.5`
Expand All @@ -88,22 +88,26 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t
- BugFix: Included an npm-shrinkwrap file to lock-down all transitive dependencies.

## `1.8.3`

- Fix `download uss-file` and `view uss-file` the file under the directory of symbol link.

## `1.8.2`

- Add some second shortcuts to match with zowe core CLI options.

## `1.8.1`

- Fix Windows path problem when using `delete uss-file` command.

## `1.8.0`
- Support listing USS files with file name pattern containing *.

- Support listing USS files with file name pattern containing \*.

## `1.7.0`

- Support listing loadlib members
- Clear password error message
- Support listing jobs without default prefix *.
- Support listing jobs without default prefix \*.

## `1.6.0`

Expand Down Expand Up @@ -150,4 +154,3 @@ All notable changes to the z/OS FTP Plug-in for Zowe CLI will be documented in t
## `1.0.0`

- Plugin released

171 changes: 171 additions & 0 deletions __tests__/__unit__/api/UssUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
import { ImperativeError } from "@zowe/imperative";
import { UssUtils } from "../../../src/api/UssUtils";

function fileEntry(name: string) {
return { name, isDirectory: false };
}

function dirEntry(name: string) {
return { name, isDirectory: true };
}

describe("UssUtils", () => {

it("should return the normalized path as is", () => {
Expand All @@ -37,4 +45,167 @@ describe("UssUtils", () => {
};
expect(result).toThrow(ImperativeError);
});

describe("deleteDirectory", () => {
let connection: any;
let response: any;

beforeEach(() => {
connection = {
listDataset: jest.fn(),
deleteDataset: jest.fn().mockResolvedValue(undefined),
};
response = { log: jest.fn() };
});

// ── Regression ──────────────────────────────────────────────────────

it("deletes an empty directory", async () => {
connection.listDataset.mockResolvedValue([]);

await UssUtils.deleteDirectory(connection, "/u/user/dir");

expect(connection.listDataset).toHaveBeenCalledWith("/u/user/dir");
expect(connection.deleteDataset).toHaveBeenCalledTimes(1);
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir");
});

it("deletes every file in a flat directory and logs each deletion", async () => {
connection.listDataset.mockResolvedValue([
fileEntry("a.txt"),
fileEntry("b.txt"),
]);

await UssUtils.deleteDirectory(connection, "/u/user/dir", response);

expect(connection.listDataset).toHaveBeenCalledTimes(1);
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir/a.txt");
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir/b.txt");
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir");
expect(connection.deleteDataset).toHaveBeenCalledTimes(3);
expect(response.log).toHaveBeenCalledTimes(3);
});

it("recurses into subdirectories and deletes children before parents", async () => {
connection.listDataset
.mockResolvedValueOnce([dirEntry("sub"), fileEntry("root.txt")])
.mockResolvedValueOnce([fileEntry("child.txt")]);

await UssUtils.deleteDirectory(connection, "/u/user/dir", response);

expect(connection.listDataset).toHaveBeenCalledTimes(2);
const order: string[] = connection.deleteDataset.mock.calls.map((c: any[]) => c[0]);
expect(order.indexOf("/u/user/dir/sub/child.txt")).toBeLessThan(order.indexOf("/u/user/dir/sub"));
expect(order.indexOf("/u/user/dir/sub")).toBeLessThan(order.indexOf("/u/user/dir"));
});

it("completes without error when no response object is provided", async () => {
connection.listDataset.mockResolvedValue([fileEntry("x.txt")]);

await expect(
UssUtils.deleteDirectory(connection, "/u/user/dir")
).resolves.toBeUndefined();

expect(connection.deleteDataset).toHaveBeenCalledTimes(2);
});

// ── Dot / dot-dot filtering ──────────────────────────────────────────

it("skips '.' entries so the same directory is not re-listed", async () => {
connection.listDataset.mockResolvedValue([
dirEntry("."),
fileEntry("real.txt"),
]);

await UssUtils.deleteDirectory(connection, "/u/user/dir", response);

expect(connection.listDataset).toHaveBeenCalledTimes(1);
expect(connection.deleteDataset).not.toHaveBeenCalledWith("/u/user/dir/.");
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir/real.txt");
});

it("skips '..' entries", async () => {
connection.listDataset.mockResolvedValue([
dirEntry(".."),
fileEntry("real.txt"),
]);

await UssUtils.deleteDirectory(connection, "/u/user/dir", response);

expect(connection.listDataset).toHaveBeenCalledTimes(1);
expect(connection.deleteDataset).not.toHaveBeenCalledWith("/u/user/dir/..");
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir/real.txt");
});

it("skips both '.' and '..' — only the directory itself is deleted", async () => {
connection.listDataset.mockResolvedValue([dirEntry("."), dirEntry("..")]);

await UssUtils.deleteDirectory(connection, "/u/user/dir");

expect(connection.listDataset).toHaveBeenCalledTimes(1);
expect(connection.deleteDataset).toHaveBeenCalledTimes(1);
expect(connection.deleteDataset).toHaveBeenCalledWith("/u/user/dir");
});

it("resolves without recursing when the only entry is '.'", async () => {
connection.listDataset.mockResolvedValue([dirEntry(".")]);

await expect(
UssUtils.deleteDirectory(connection, "/u/user/dir")
).resolves.toBeUndefined();

expect(connection.listDataset).toHaveBeenCalledTimes(1);
});

// ── Depth guard ──────────────────────────────────────────────────────

it("throws ImperativeError before listing when depth already exceeds MAX_DELETE_DEPTH", async () => {
const maxDepth = (UssUtils as any).MAX_DELETE_DEPTH as number;

await expect(
UssUtils.deleteDirectory(connection, "/u/user/dir", undefined, maxDepth + 1)
).rejects.toThrow(ImperativeError);

expect(connection.listDataset).not.toHaveBeenCalled();
});

it("error message contains the offending path and the depth limit", async () => {
const maxDepth = (UssUtils as any).MAX_DELETE_DEPTH as number;
const offendingPath = "/u/user/very/deep/dir";

await expect(
UssUtils.deleteDirectory(connection, offendingPath, undefined, maxDepth + 1)
).rejects.toThrow(offendingPath);

await expect(
UssUtils.deleteDirectory(connection, offendingPath, undefined, maxDepth + 1)
).rejects.toThrow(String(maxDepth));
});

it("succeeds at exactly MAX_DELETE_DEPTH (boundary)", async () => {
const maxDepth = (UssUtils as any).MAX_DELETE_DEPTH as number;
connection.listDataset.mockResolvedValue([]);

await expect(
UssUtils.deleteDirectory(connection, "/u/user/dir", undefined, maxDepth)
).resolves.toBeUndefined();
});

it("throws ImperativeError when a server synthesises an unbounded directory tree", async () => {
const maxDepth = (UssUtils as any).MAX_DELETE_DEPTH as number;
let listCount = 0;
connection.listDataset.mockImplementation(() => {
listCount++;
return Promise.resolve([dirEntry(`level${listCount}`)]);
});

await expect(
UssUtils.deleteDirectory(connection, "/u/user/dir")
).rejects.toThrow(ImperativeError);

// listDataset is called once per depth level from 0 to MAX_DELETE_DEPTH
// then the guard fires at depth MAX_DELETE_DEPTH+1 before the next list
expect(listCount).toBe(maxDepth + 1);
});
});
});
18 changes: 16 additions & 2 deletions src/api/UssUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,26 @@ export class UssUtils {
}
}

public static async deleteDirectory(connection: any, dir: string, response?: IHandlerResponseConsoleApi): Promise<void> {
// Matches POSIX path depth: /a/b/c has depth 3. Chosen to be well above any
// realistic USS directory tree while still bounding runaway server-driven recursion.
private static readonly MAX_DELETE_DEPTH = 100;

public static async deleteDirectory(connection: any, dir: string,
response?: IHandlerResponseConsoleApi, depth = 0): Promise<void> {
if (depth > UssUtils.MAX_DELETE_DEPTH) {
throw new ImperativeError({
msg: `deleteDirectory exceeded maximum recursion depth (${UssUtils.MAX_DELETE_DEPTH}) at '${dir}'. ` +
"The remote server may have returned a malformed directory listing."
});
}
const files = await connection.listDataset(dir);
for (const file of files) {
if (file.name === "." || file.name === "..") {
continue;
}
const filePath = PATH.posix.join(dir, file.name);
if (file.isDirectory) {
await this.deleteDirectory(connection, filePath, response);
await this.deleteDirectory(connection, filePath, response, depth + 1);
} else {
await connection.deleteDataset(filePath);
if (response) {
Expand Down
Loading