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
JavaScript
"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