Skip to content
Open
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
20 changes: 14 additions & 6 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,16 +552,24 @@ export class Client {
*
* @param [path] Path to remote file or directory.
*/
async list(path = ""): Promise<FileInfo[]> {
async list(path = "", command?: "MLSD" | "LIST"): Promise<FileInfo[]> {
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) {
Expand Down
5 changes: 4 additions & 1 deletion src/parseList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ interface Parser {
const availableParsers: Parser[] = [
dosParser,
unixParser,
eplParser,
mlsdParser // Keep MLSD last, may accept filename only
]

Expand Down Expand Up @@ -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)
Expand Down
113 changes: 113 additions & 0 deletions src/parseListEPLF.ts
Original file line number Diff line number Diff line change
@@ -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
}
89 changes: 89 additions & 0 deletions test/parseListSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ const listDOS = `
12-05-96 05:03PM <DIR> 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`

const listEPLFComprehensive = `
+i8388621.29609,m824255902,/, lib
+i640ecfac.1400000014a761,s1057,m1751129904,up644,r LICENSE.txt`

describe("Directory listing", function() {
let f;
const tests = [
Expand Down Expand Up @@ -234,6 +243,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,
Expand Down Expand Up @@ -301,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",
Expand Down