UNPKG

pure-js-sftp

Version:

A pure JavaScript SFTP client with revolutionary RSA-SHA2 compatibility fixes. Zero native dependencies, built on ssh2-streams with 100% SSH key support.

491 lines 20.2 kB
"use strict"; /** * SFTP Client using ssh2-streams transport */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SSH2StreamsSFTPClient = void 0; const events_1 = require("events"); const ssh2_streams_transport_1 = require("../ssh/ssh2-streams-transport"); const types_1 = require("../ssh/types"); class SSH2StreamsSFTPClient extends events_1.EventEmitter { constructor(options) { super(); this.sftpChannel = null; this.requestId = 0; this.ready = false; this.pendingRequests = new Map(); this.dataBuffer = Buffer.alloc(0); this.transport = new ssh2_streams_transport_1.SSH2StreamsTransport(options); this.setupTransportHandlers(); } setupTransportHandlers() { this.transport.on('ready', () => { this.emit('debug', 'SSH transport ready'); }); this.transport.on('error', (err) => { this.emit('error', err); }); this.transport.on('close', () => { this.ready = false; this.emit('close'); }); this.transport.on('debug', (msg) => { this.emit('debug', msg); }); } /** * Connect and initialize SFTP */ async connect() { try { // Connect SSH transport await this.transport.connect(); // Open SFTP channel this.sftpChannel = await this.transport.openSFTP(); // Set up SFTP channel handlers BEFORE sending INIT this.setupSFTPHandlers(); // Small delay to ensure handlers are ready await new Promise(resolve => setTimeout(resolve, 100)); // Send SFTP INIT await this.initializeSFTP(); this.ready = true; this.emit('ready'); } catch (error) { this.emit('error', error); throw error; } } setupSFTPHandlers() { if (!this.sftpChannel) return; this.sftpChannel.on('data', (data) => { this.emit('debug', `SFTP channel received data: ${data.length} bytes`); this.handleSFTPData(data); }); this.sftpChannel.on('close', () => { this.ready = false; this.emit('close'); }); this.sftpChannel.on('error', (err) => { this.emit('error', err); }); } async initializeSFTP() { if (!this.sftpChannel) { throw new Error('SFTP channel not available'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('SFTP initialization timeout')); }, 10000); // Listen for SFTP VERSION response const handleVersion = (packet) => { if (packet.type === types_1.SFTP_MSG.VERSION) { clearTimeout(timeout); const version = packet.payload.readUInt32BE(0); if (version === types_1.SFTP_VERSION) { this.removeListener('sftpPacket', handleVersion); resolve(); } else { this.removeListener('sftpPacket', handleVersion); reject(new types_1.SFTPError(`Unsupported SFTP version: ${version}`)); } } }; this.on('sftpPacket', handleVersion); // Send SFTP INIT packet const initPacket = Buffer.from([ 0, 0, 0, 5, // Length: 5 bytes types_1.SFTP_MSG.INIT, // SSH_FXP_INIT 0, 0, 0, types_1.SFTP_VERSION // Version: 3 ]); this.sftpChannel.write(initPacket); }); } handleSFTPData(data) { // Append new data to buffer this.dataBuffer = Buffer.concat([this.dataBuffer, data]); let offset = 0; while (offset + 4 <= this.dataBuffer.length) { const length = this.dataBuffer.readUInt32BE(offset); // Check if we have the complete packet if (offset + 4 + length > this.dataBuffer.length) { break; // Wait for more data } const packetData = this.dataBuffer.subarray(offset + 4, offset + 4 + length); const type = packetData.readUInt8(0); let id; let payload = packetData.subarray(1); if (type !== types_1.SFTP_MSG.INIT && type !== types_1.SFTP_MSG.VERSION) { id = payload.readUInt32BE(0); payload = payload.subarray(4); } const packet = { type, id, payload }; this.handleSFTPPacket(packet); offset += 4 + length; } // Remove processed data from buffer if (offset > 0) { this.dataBuffer = this.dataBuffer.subarray(offset); } } handleSFTPPacket(packet) { this.emit('debug', `SFTP packet received: type=${packet.type}, id=${packet.id}`); this.emit('sftpPacket', packet); if (packet.id !== undefined) { const request = this.pendingRequests.get(packet.id); if (request) { this.pendingRequests.delete(packet.id); switch (packet.type) { case types_1.SFTP_MSG.STATUS: this.handleStatusPacket(packet, request); break; case types_1.SFTP_MSG.HANDLE: request.resolve(packet.payload.subarray(4)); // Skip length prefix break; case types_1.SFTP_MSG.DATA: { const dataLength = packet.payload.readUInt32BE(0); const data = packet.payload.subarray(4, 4 + dataLength); request.resolve(data); break; } case types_1.SFTP_MSG.NAME: { const entries = this.parseNamePacket(packet.payload); request.resolve(entries); break; } case types_1.SFTP_MSG.ATTRS: { const { attrs } = this.parseFileAttributes(packet.payload, 0); request.resolve(attrs); break; } } } } } handleStatusPacket(packet, request) { const code = packet.payload.readUInt32BE(0); if (code === types_1.SFTP_STATUS.OK) { request.resolve(); } else { let message = 'Unknown error'; if (packet.payload.length > 4) { const messageLength = packet.payload.readUInt32BE(4); message = packet.payload.subarray(8, 8 + messageLength).toString('utf8'); } request.reject(new types_1.SFTPError(message, code)); } } parseNamePacket(payload) { const count = payload.readUInt32BE(0); const entries = []; let offset = 4; for (let i = 0; i < count; i++) { const filenameLength = payload.readUInt32BE(offset); offset += 4; const filename = payload.subarray(offset, offset + filenameLength).toString('utf8'); offset += filenameLength; const longnameLength = payload.readUInt32BE(offset); offset += 4; const longname = payload.subarray(offset, offset + longnameLength).toString('utf8'); offset += longnameLength; const { attrs, bytesRead } = this.parseFileAttributes(payload, offset); offset += bytesRead; entries.push({ filename, longname, attrs }); } return entries; } parseFileAttributes(buffer, offset) { const flags = buffer.readUInt32BE(offset); const attrs = { flags }; let bytesRead = 4; if (flags & types_1.SFTP_ATTR.SIZE) { const size64 = buffer.readBigUInt64BE(offset + bytesRead); attrs.size = Number(size64); // Handle 64-bit file sizes correctly bytesRead += 8; } if (flags & types_1.SFTP_ATTR.UIDGID) { attrs.uid = buffer.readUInt32BE(offset + bytesRead); attrs.gid = buffer.readUInt32BE(offset + bytesRead + 4); bytesRead += 8; } if (flags & types_1.SFTP_ATTR.PERMISSIONS) { attrs.permissions = buffer.readUInt32BE(offset + bytesRead); bytesRead += 4; } if (flags & types_1.SFTP_ATTR.ACMODTIME) { attrs.atime = buffer.readUInt32BE(offset + bytesRead); attrs.mtime = buffer.readUInt32BE(offset + bytesRead + 4); bytesRead += 8; } // Add helper methods for file type detection (always present for ssh2-sftp-client compatibility) if (attrs.permissions !== undefined) { const mode = attrs.permissions; attrs.isFile = () => (mode & 0o170000) === 0o100000; // S_IFREG attrs.isDirectory = () => (mode & 0o170000) === 0o040000; // S_IFDIR attrs.isSymbolicLink = () => (mode & 0o170000) === 0o120000; // S_IFLNK attrs.isBlockDevice = () => (mode & 0o170000) === 0o060000; // S_IFBLK attrs.isCharacterDevice = () => (mode & 0o170000) === 0o020000; // S_IFCHR attrs.isFIFO = () => (mode & 0o170000) === 0o010000; // S_IFIFO attrs.isSocket = () => (mode & 0o170000) === 0o140000; // S_IFSOCK } else { // Fallback when permissions not available - always provide functions attrs.isFile = () => false; attrs.isDirectory = () => false; attrs.isSymbolicLink = () => false; attrs.isBlockDevice = () => false; attrs.isCharacterDevice = () => false; attrs.isFIFO = () => false; attrs.isSocket = () => false; } return { attrs: attrs, bytesRead }; } sendSFTPRequest(type, payload, expectResponse = true) { if (!this.sftpChannel) { throw new Error('SFTP channel not available'); } return new Promise((resolve, reject) => { const id = ++this.requestId; if (expectResponse) { this.pendingRequests.set(id, { resolve, reject, type: types_1.SFTP_MSG[type] }); } // Build SFTP packet const idBuffer = Buffer.allocUnsafe(4); idBuffer.writeUInt32BE(id, 0); const packetPayload = Buffer.concat([Buffer.from([type]), idBuffer, payload]); const lengthBuffer = Buffer.allocUnsafe(4); lengthBuffer.writeUInt32BE(packetPayload.length, 0); const packet = Buffer.concat([lengthBuffer, packetPayload]); this.sftpChannel.write(packet); if (!expectResponse) { resolve(undefined); } }); } /** * Open a file */ async openFile(path, flags = types_1.SFTP_OPEN_FLAGS.READ) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const flagsBuffer = Buffer.allocUnsafe(4); flagsBuffer.writeUInt32BE(flags, 0); const attrsBuffer = Buffer.allocUnsafe(4); attrsBuffer.writeUInt32BE(0, 0); // No attributes const payload = Buffer.concat([pathLength, pathBuffer, flagsBuffer, attrsBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.OPEN, payload); } /** * Close a file */ async closeFile(handle) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const payload = Buffer.concat([handleLength, handle]); return this.sendSFTPRequest(types_1.SFTP_MSG.CLOSE, payload); } /** * Read file data */ async readFile(handle, offset, length) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const offsetBuffer = Buffer.allocUnsafe(8); offsetBuffer.writeBigUInt64BE(BigInt(offset), 0); // Use consistent 64-bit handling const lengthBuffer = Buffer.allocUnsafe(4); lengthBuffer.writeUInt32BE(length, 0); const payload = Buffer.concat([handleLength, handle, offsetBuffer, lengthBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.READ, payload); } /** * List directory contents */ async listDirectory(path) { const handle = await this.openDirectory(path); const entries = []; try { while (true) { try { const batch = await this.readDirectory(handle); entries.push(...batch); } catch (error) { if (error instanceof types_1.SFTPError && error.code === types_1.SFTP_STATUS.EOF) { break; } throw error; } } } finally { await this.closeFile(handle); } return entries; } async openDirectory(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.OPENDIR, payload); } async readDirectory(handle) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const payload = Buffer.concat([handleLength, handle]); return this.sendSFTPRequest(types_1.SFTP_MSG.READDIR, payload); } /** * Get file stats */ async stat(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.STAT, payload); } /** * Write data to a file handle */ async writeFile(handle, offset, data) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const offsetBuffer = Buffer.allocUnsafe(8); offsetBuffer.writeBigUInt64BE(BigInt(offset), 0); const dataLength = Buffer.allocUnsafe(4); dataLength.writeUInt32BE(data.length, 0); const payload = Buffer.concat([handleLength, handle, offsetBuffer, dataLength, data]); return this.sendSFTPRequest(types_1.SFTP_MSG.WRITE, payload); } /** * Remove a file */ async removeFile(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.REMOVE, payload); } /** * Rename a file */ async renameFile(oldPath, newPath) { const oldPathBuffer = Buffer.from(oldPath, 'utf8'); const oldPathLength = Buffer.allocUnsafe(4); oldPathLength.writeUInt32BE(oldPathBuffer.length, 0); const newPathBuffer = Buffer.from(newPath, 'utf8'); const newPathLength = Buffer.allocUnsafe(4); newPathLength.writeUInt32BE(newPathBuffer.length, 0); const payload = Buffer.concat([oldPathLength, oldPathBuffer, newPathLength, newPathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.RENAME, payload); } /** * Create a directory */ async makeDirectory(path, attrs) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); // Simple attributes - just set directory flag const attrFlags = Buffer.allocUnsafe(4); attrFlags.writeUInt32BE(types_1.SFTP_ATTR.PERMISSIONS, 0); const permissions = Buffer.allocUnsafe(4); permissions.writeUInt32BE(attrs?.permissions || 0o755, 0); const payload = Buffer.concat([pathLength, pathBuffer, attrFlags, permissions]); return this.sendSFTPRequest(types_1.SFTP_MSG.MKDIR, payload); } /** * Remove a directory */ async removeDirectory(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.RMDIR, payload); } /** * Set file attributes */ async setAttributes(path, attrs) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); // Build attributes in correct RFC order: size, uid/gid, permissions, atime/mtime let flags = 0; let attrData = Buffer.alloc(0); // Order matters! Follow RFC: size, uid/gid, permissions, atime/mtime if (attrs.size !== undefined) { flags |= types_1.SFTP_ATTR.SIZE; const sizeBuffer = Buffer.allocUnsafe(8); sizeBuffer.writeBigUInt64BE(BigInt(attrs.size), 0); attrData = Buffer.concat([attrData, sizeBuffer]); } if (attrs.uid !== undefined && attrs.gid !== undefined) { flags |= types_1.SFTP_ATTR.UIDGID; const uidBuffer = Buffer.allocUnsafe(4); const gidBuffer = Buffer.allocUnsafe(4); uidBuffer.writeUInt32BE(attrs.uid, 0); gidBuffer.writeUInt32BE(attrs.gid, 0); attrData = Buffer.concat([attrData, uidBuffer, gidBuffer]); } if (attrs.permissions !== undefined) { flags |= types_1.SFTP_ATTR.PERMISSIONS; const permBuffer = Buffer.allocUnsafe(4); permBuffer.writeUInt32BE(attrs.permissions, 0); attrData = Buffer.concat([attrData, permBuffer]); } if (attrs.atime !== undefined && attrs.mtime !== undefined) { flags |= types_1.SFTP_ATTR.ACMODTIME; const atimeBuffer = Buffer.allocUnsafe(4); const mtimeBuffer = Buffer.allocUnsafe(4); atimeBuffer.writeUInt32BE(attrs.atime, 0); mtimeBuffer.writeUInt32BE(attrs.mtime, 0); attrData = Buffer.concat([attrData, atimeBuffer, mtimeBuffer]); } const attrFlags = Buffer.allocUnsafe(4); attrFlags.writeUInt32BE(flags, 0); const payload = Buffer.concat([pathLength, pathBuffer, attrFlags, attrData]); return this.sendSFTPRequest(types_1.SFTP_MSG.SETSTAT, payload); } /** * Get real path (resolve symbolic links) */ async realPath(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); // sendSFTPRequest for REALPATH returns DirectoryEntry[] from parseNamePacket const entries = await this.sendSFTPRequest(types_1.SFTP_MSG.REALPATH, payload); if (entries && entries.length === 1) { return entries[0].filename; // Return the resolved absolute path } throw new Error('Invalid realpath response: expected exactly one path entry'); } /** * Disconnect */ disconnect() { this.ready = false; if (this.sftpChannel) { this.sftpChannel.end(); } this.transport.disconnect(); } /** * Check if connected and ready */ isReady() { return this.ready && this.transport.isConnected(); } } exports.SSH2StreamsSFTPClient = SSH2StreamsSFTPClient; //# sourceMappingURL=ssh2-streams-client.js.map