From 479bdd1fa8245c9b68cdf42e76dcd0dd345145bf Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 25 Jun 2026 09:55:51 -0400 Subject: [PATCH 1/2] feat: add max depth to UssUtils.deleteDirectory Signed-off-by: Trae Yelovich --- __tests__/__unit__/api/UssUtils.test.ts | 171 ++++++++++++++++++++++++ src/api/UssUtils.ts | 18 ++- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/__tests__/__unit__/api/UssUtils.test.ts b/__tests__/__unit__/api/UssUtils.test.ts index 3d378003..495655a0 100644 --- a/__tests__/__unit__/api/UssUtils.test.ts +++ b/__tests__/__unit__/api/UssUtils.test.ts @@ -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", () => { @@ -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); + }); + }); }); diff --git a/src/api/UssUtils.ts b/src/api/UssUtils.ts index 4a70ffb5..6cb7f277 100644 --- a/src/api/UssUtils.ts +++ b/src/api/UssUtils.ts @@ -182,12 +182,26 @@ export class UssUtils { } } - public static async deleteDirectory(connection: any, dir: string, response?: IHandlerResponseConsoleApi): Promise { + // 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 { + 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) { From 8be983b39dc1928abec44ee89ed84399c01dc0ee Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 29 Jun 2026 09:34:36 -0400 Subject: [PATCH 2/2] chore: changelog Signed-off-by: Trae Yelovich --- CHANGELOG.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e4f093..5e56b713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` @@ -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 @@ -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` @@ -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` @@ -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` @@ -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` @@ -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 -