From a46c98bdbccf0e6af3e9b90f8807289eacd2e18a Mon Sep 17 00:00:00 2001 From: timint Date: Sat, 28 Jun 2025 22:54:10 +0200 Subject: [PATCH 1/3] + Add ability to retrieve folder contents with specific list command (LIST or MLSD) --- src/Client.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 3579c0d..98b3aa8 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -552,16 +552,24 @@ export class Client { * * @param [path] Path to remote file or directory. */ - async list(path = ""): Promise { + async list(path = "", command?: "MLSD" | "LIST"): Promise { const validPath = await this.protectWhitespace(path) let lastError: any - for (const candidate of this.availableListCommands) { - const command = validPath === "" ? candidate : `${candidate} ${validPath}` + + // If a specific command is provided, use only that command. + const listCommands = command + ? [command === "MLSD" ? "MLSD" : "LIST"] + : this.availableListCommands + + for (const candidate of listCommands) { + const cmd = validPath === "" ? candidate : `${candidate} ${validPath}` await this.prepareTransfer(this.ftp) try { - const parsedList = await this._requestListWithCommand(command) - // Use successful candidate for all subsequent requests. - this.availableListCommands = [ candidate ] + const parsedList = await this._requestListWithCommand(cmd) + // Use successful candidate for all subsequent requests if not using explicit command. + if (!command) { + this.availableListCommands = [candidate] + } return parsedList } catch (err) { From 00abe6d7ba8e874ab6cf50878b2ce939fa6f99b5 Mon Sep 17 00:00:00 2001 From: timint Date: Sat, 28 Jun 2025 22:55:23 +0200 Subject: [PATCH 2/3] + Add support for resloving MLSD with permissions --- test/parseListSpec.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/parseListSpec.js b/test/parseListSpec.js index 23e5307..71cff2b 100644 --- a/test/parseListSpec.js +++ b/test/parseListSpec.js @@ -26,6 +26,11 @@ const listDOS = ` 12-05-96 05:03PM myDir 11-14-97 04:21PM 953 MYFILE.INI` +const listMLSDWithPerm = ` +type=file;size=24;modify=20250628164658.025;perm=rw; awesome.txt +type=file;size=9;modify=20250628164657.973;perm=rw; fake.txt +type=file;size=1091;modify=20250628164658.013;perm=rw; LICENSE` + describe("Directory listing", function() { let f; const tests = [ @@ -234,6 +239,30 @@ describe("Directory listing", function() { f) ] }, + { + title: "MLSD with perm fact (perm fact ignored)", + list: listMLSDWithPerm, + exp: [ + (f = new FileInfo("awesome.txt"), + f.type = FileType.File, + f.size = 24, + f.rawModifiedAt = "2025-06-28T16:46:58.025Z", + f.modifiedAt = new Date("2025-06-28T16:46:58.025Z"), + f), + (f = new FileInfo("fake.txt"), + f.type = FileType.File, + f.size = 9, + f.rawModifiedAt = "2025-06-28T16:46:57.973Z", + f.modifiedAt = new Date("2025-06-28T16:46:57.973Z"), + f), + (f = new FileInfo("LICENSE"), + f.type = FileType.File, + f.size = 1091, + f.rawModifiedAt = "2025-06-28T16:46:58.013Z", + f.modifiedAt = new Date("2025-06-28T16:46:58.013Z"), + f) + ] + }, { title: "Regular Unix list", list: listUnix, From 7e838b3e56f8df8de2f62fe4e3d41eed3b7d850f Mon Sep 17 00:00:00 2001 From: timint Date: Sat, 28 Jun 2025 22:57:24 +0200 Subject: [PATCH 3/3] + Add support for Easily Parsed LIST Format --- src/parseList.ts | 5 +- src/parseListEPLF.ts | 113 ++++++++++++++++++++++++++++++++++++++++++ test/parseListSpec.js | 60 ++++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/parseListEPLF.ts diff --git a/src/parseList.ts b/src/parseList.ts index 7dd7703..c6bd461 100644 --- a/src/parseList.ts +++ b/src/parseList.ts @@ -2,6 +2,7 @@ import { FileInfo } from "./FileInfo" import * as dosParser from "./parseListDOS" import * as unixParser from "./parseListUnix" import * as mlsdParser from "./parseListMLSD" +import * as eplParser from "./parseListEPLF" interface Parser { testLine(line: string): boolean @@ -16,6 +17,7 @@ interface Parser { const availableParsers: Parser[] = [ dosParser, unixParser, + eplParser, mlsdParser // Keep MLSD last, may accept filename only ] @@ -47,7 +49,8 @@ export function parseList(rawList: string): FileInfo[] { const testLine = lines[lines.length - 1] const parser = firstCompatibleParser(testLine, availableParsers) if (!parser) { - throw new Error("This library only supports MLSD, Unix- or DOS-style directory listing. Your FTP server seems to be using another format. You can see the transmitted listing when setting `client.ftp.verbose = true`. You can then provide a custom parser to `client.parseList`, see the documentation for details.") + console.debug("DEBUG:", { testLine: testLine, availableParsers: availableParsers } ) + throw new Error("This library only supports MLSD, Unix-, DOS-, or EPLF-style directory listing. Your FTP server seems to be using another format. You can see the transmitted listing when setting `client.ftp.verbose = true`. You can then provide a custom parser to `client.parseList`, see the documentation for details.") } const files = lines .map(parser.parseLine) diff --git a/src/parseListEPLF.ts b/src/parseListEPLF.ts new file mode 100644 index 0000000..109c6e0 --- /dev/null +++ b/src/parseListEPLF.ts @@ -0,0 +1,113 @@ +import { FileInfo, FileType } from "./FileInfo" + +/** + * This parser handles EPLF (Easily Parsed LIST Format) directory listings. + * EPLF format specification: http://cr.yp.to/ftp/list/eplf.html + * + * Format: +facts TAB name + * Facts are comma-separated and can include: + * - / = directory + * - r = file + * - s#### = size in bytes + * - m#### = modification time (Unix timestamp) + * - i#### = inode number + * - up#### = Unix permissions (octal format) + */ + +/** + * Returns true if a given line might be an EPLF-style listing. + * EPLF lines start with '+' character. + */ +export function testLine(line: string): boolean { + return line.startsWith('+') +} + +/** + * Parse a single line of an EPLF directory listing. + */ +export function parseLine(line: string): FileInfo | undefined { + if (!line.startsWith('+')) { + return undefined + } + + // Split on tab character or find where filename starts after spaces + const tabIndex = line.indexOf('\t') + let factsStr: string + let filename: string + + if (tabIndex !== -1) { + // Tab-separated format + factsStr = line.substring(1, tabIndex) // Remove '+' prefix + filename = line.substring(tabIndex + 1) + } else { + // Space-separated format - find the filename after the facts + // Find the last comma-separated fact, then look for spaces before filename + const match = line.match(/^\+(.+?)\s+(\S.*)$/) + if (!match) { + return undefined + } + factsStr = match[1] + filename = match[2].trim() + } + + if (!filename || filename === '.' || filename === '..') { + return undefined + } + + const file = new FileInfo(filename) + + // Parse comma-separated facts + const facts = factsStr.split(',') + + for (const fact of facts) { + const trimmedFact = fact.trim() + + if (trimmedFact === '/') { + // Directory indicator + file.type = FileType.Directory + } else if (trimmedFact === 'r') { + // File indicator + file.type = FileType.File + } else if (trimmedFact.startsWith('s')) { + // Size in bytes + const sizeStr = trimmedFact.substring(1) + const size = parseInt(sizeStr, 10) + if (!isNaN(size)) { + file.size = size + } + } else if (trimmedFact.startsWith('m')) { + // Modification time (Unix timestamp) + const timestampStr = trimmedFact.substring(1) + const timestamp = parseInt(timestampStr, 10) + if (!isNaN(timestamp)) { + const date = new Date(timestamp * 1000) + file.rawModifiedAt = date.toISOString() + file.modifiedAt = date + } + } else if (trimmedFact.startsWith('up')) { + // Unix permissions (octal format) + const permStr = trimmedFact.substring(2) + const perm = parseInt(permStr, 8) // Parse as octal + if (!isNaN(perm)) { + file.permissions = { + user: (perm >> 6) & 7, // Extract user permissions (bits 6-8) + group: (perm >> 3) & 7, // Extract group permissions (bits 3-5) + world: perm & 7 // Extract world permissions (bits 0-2) + } + } + } + // Note: 'i' (inode) fact is parsed but not stored in FileInfo + // as there's no corresponding property + } + + // Default to file type if not specified + if (file.type === undefined) { + file.type = FileType.File + } + + return file +} + +export function transformList(files: FileInfo[]): FileInfo[] { + return files +} diff --git a/test/parseListSpec.js b/test/parseListSpec.js index 71cff2b..2b85572 100644 --- a/test/parseListSpec.js +++ b/test/parseListSpec.js @@ -31,6 +31,10 @@ type=file;size=24;modify=20250628164658.025;perm=rw; awesome.txt type=file;size=9;modify=20250628164657.973;perm=rw; fake.txt type=file;size=1091;modify=20250628164658.013;perm=rw; LICENSE` +const listEPLFComprehensive = ` ++i8388621.29609,m824255902,/, lib ++i640ecfac.1400000014a761,s1057,m1751129904,up644,r LICENSE.txt` + describe("Directory listing", function() { let f; const tests = [ @@ -330,6 +334,62 @@ describe("Directory listing", function() { f), ] }, + { + title: "EPLF format - directory", + list: `+i8388621.29609,m824255902,/, bin`, + exp: [ + (f = new FileInfo("bin"), + f.type = FileType.Directory, + f.rawModifiedAt = "1996-02-13T23:58:22.000Z", + f.modifiedAt = new Date("1996-02-13T23:58:22.000Z"), + f) + ] + }, + { + title: "EPLF format - file with size", + list: `+i8388621.44468,m824255902,r,s10376, ls-lR.Z`, + exp: [ + (f = new FileInfo("ls-lR.Z"), + f.type = FileType.File, + f.size = 10376, + f.rawModifiedAt = "1996-02-13T23:58:22.000Z", + f.modifiedAt = new Date("1996-02-13T23:58:22.000Z"), + f) + ] + }, + { + title: "EPLF format - file without size", + list: `+i8388621.29609,m824255902,r, file.txt`, + exp: [ + (f = new FileInfo("file.txt"), + f.type = FileType.File, + f.rawModifiedAt = "1996-02-13T23:58:22.000Z", + f.modifiedAt = new Date("1996-02-13T23:58:22.000Z"), + f) + ] + }, + { + title: "EPLF format - comprehensive test (all variants)", + list: listEPLFComprehensive, + exp: [ + (f = new FileInfo("lib"), + f.type = FileType.Directory, + f.rawModifiedAt = "1996-02-13T23:58:22.000Z", + f.modifiedAt = new Date("1996-02-13T23:58:22.000Z"), + f), + (f = new FileInfo("LICENSE.txt"), + f.type = FileType.File, + f.size = 1057, + f.rawModifiedAt = "2025-06-28T16:58:24.000Z", + f.modifiedAt = new Date("2025-06-28T16:58:24.000Z"), + f.permissions = { + user: 6, + group: 4, + world: 4 + }, + f) + ] + }, { title: "Unknown format", list: "aaa",