initial commit

This commit is contained in:
2026-02-20 17:14:35 +11:00
commit aef20b9d35
32 changed files with 8212 additions and 0 deletions

80
.gitignore vendored Normal file
View File

@@ -0,0 +1,80 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Build output
dist/
build/

250
bin/sftp-proxy.ts Normal file
View File

@@ -0,0 +1,250 @@
#!/usr/bin/env node
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { homedir, platform } from 'node:os'
import { Command } from 'commander'
import { ConfigSchema, type Config } from '../src/types.js'
import { BaseCache } from '../src/adapters/cache/base.js'
import { MemoryCache } from '../src/adapters/cache/memory.js'
import { SqliteCache } from '../src/adapters/cache/sqlite.js'
import { BaseClient } from '../src/adapters/client/base.js'
import { WebDAVClientAdapter } from '../src/adapters/client/webdav.js'
import { FTPClientAdapter } from '../src/adapters/client/ftp.js'
import { SFTPClientAdapter } from '../src/adapters/client/sftp.js'
import { SFTPServer } from '../src/adapters/server/sftp.js'
import { FTPServer } from '../src/adapters/server/ftp.js'
import { WebDAVServer } from '../src/adapters/server/webdav.js'
import { NFSServer } from '../src/adapters/server/nfs.js'
import { SMBServer } from '../src/adapters/server/smb.js'
import type { BaseServer } from '../src/adapters/server/base.js'
import { logger, attachSqliteTransport } from '../src/logger.js'
import * as launchd from '../src/service/launchd.js'
import * as systemd from '../src/service/systemd.js'
const CONFIG_PATH = join(homedir(), '.config', 'sftp-proxy', 'config.json')
/**
* Loads and validates the config file from ~/.config/sftp-proxy/config.json.
* Exits with a clear error message if the file is missing or invalid.
*/
function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
console.error(`Config file not found: ${CONFIG_PATH}`)
console.error('Create one with your client configuration. See documentation for schema.')
process.exit(1)
}
let raw: unknown
try {
raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
} catch (err) {
console.error(`Failed to parse config file: ${(err as Error).message}`)
process.exit(1)
}
const result = ConfigSchema.safeParse(raw)
if (!result.success) {
console.error('Invalid config:')
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
}
process.exit(1)
}
return result.data
}
/**
* Creates a cache instance based on the client's cache config.
* If using SQLite, also attaches the winston transport.
*/
function createCache(type: 'sqlite' | 'memory'): BaseCache {
if (type === 'sqlite') {
const cache = new SqliteCache()
attachSqliteTransport(cache)
return cache
}
return new MemoryCache()
}
/**
* Creates a client adapter based on the client type config.
*/
function createClient(
clientConfig: Config['clients'][number],
cache: BaseCache,
): BaseClient {
const baseConfig = {
url: clientConfig.url,
basePath: clientConfig.basePath,
username: clientConfig.username,
password: clientConfig.password,
mountPath: clientConfig.mountPath,
concurrency: clientConfig.concurrency,
}
switch (clientConfig.type) {
case 'webdav':
return new WebDAVClientAdapter(baseConfig, cache)
case 'ftp':
return new FTPClientAdapter(baseConfig, cache)
case 'sftp':
return new SFTPClientAdapter(baseConfig, cache)
default:
throw new Error(`Unknown client type: ${clientConfig.type}`)
}
}
/**
* Detects whether we're on macOS (launchd) or Linux (systemd).
*/
function getServiceManager() {
return platform() === 'darwin' ? launchd : systemd
}
const program = new Command()
program
.name('sftp-proxy')
.description('Local protocol servers that proxy to remote storage')
.version('1.0.0')
program
.command('start')
.description('Start all configured servers in the foreground')
.action(async () => {
const config = loadConfig()
// Build clients
const clients: BaseClient[] = config.clients.map(clientConf => {
const cache = createCache(clientConf.cache.type)
return createClient(clientConf, cache)
})
// Start sync workers for all clients
for (const client of clients) {
client.sync.start()
logger.info(`Sync started for ${client.mountPath}`)
}
// Track all started servers for graceful shutdown
const servers: BaseServer[] = []
// Start SFTP server (always on — it's the primary protocol)
const sftpPort = config.servers?.sftp?.port ?? config.port
const sftpServer = new SFTPServer(clients, sftpPort, config.credentials)
await sftpServer.start()
servers.push(sftpServer)
// Start WebDAV server if configured
if (config.servers?.webdav) {
logger.info(`Starting WebDAV server on port ${config.servers.webdav.port}`)
const webdavServer = new WebDAVServer(clients, config.servers.webdav.port, config.credentials)
await webdavServer.start()
servers.push(webdavServer)
}
// Start FTP server if configured
if (config.servers?.ftp) {
const ftpConf = config.servers.ftp
const ftpServer = new FTPServer(clients, {
port: ftpConf.port,
pasv_url: ftpConf.pasv_url,
pasv_min: ftpConf.pasv_min,
pasv_max: ftpConf.pasv_max,
}, config.credentials)
await ftpServer.start()
servers.push(ftpServer)
}
// Warn about unimplemented servers
if (config.servers?.nfs) {
logger.warn('NFS server is configured but not yet implemented. Skipping.')
}
if (config.servers?.smb) {
logger.warn('SMB server is configured but not yet implemented. Skipping.')
}
// Graceful shutdown
const shutdown = async () => {
logger.info('Shutting down...')
for (const client of clients) {
client.sync.stop()
}
for (const server of servers) {
await server.stop()
}
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
})
program
.command('install')
.description('Install as background service (auto-detect launchd vs systemd)')
.action(() => {
const svc = getServiceManager()
svc.install()
})
program
.command('uninstall')
.description('Remove background service')
.action(() => {
const svc = getServiceManager()
svc.uninstall()
})
program
.command('status')
.description('Show service status')
.action(() => {
const svc = getServiceManager()
console.log(svc.status())
})
program
.command('sync')
.description('Force re-index all clients')
.action(async () => {
const config = loadConfig()
const clients: BaseClient[] = config.clients.map(clientConf => {
const cache = createCache(clientConf.cache.type)
return createClient(clientConf, cache)
})
logger.info('Force syncing all clients...')
const syncPromises = clients.map(async (client) => {
client.sync.start()
await client.sync.forceSync('/')
client.sync.stop()
logger.info(`Sync complete for ${client.mountPath}`)
})
await Promise.all(syncPromises)
logger.info('All clients synced')
process.exit(0)
})
program
.command('logs')
.description('Print last 500 log entries')
.action(() => {
const cache = new SqliteCache()
const entries = cache.readLogs(500)
// Print in chronological order (readLogs returns newest first)
for (const entry of entries.reverse()) {
const ts = new Date(entry.timestamp).toISOString()
console.log(`${ts} [${entry.level}] ${entry.message}`)
}
cache.close()
})
program.parse()

3168
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "sftp-proxy",
"version": "1.0.0",
"description": "Local protocol servers (SFTP, FTP, WebDAV) that proxy to remote storage via a generic client layer",
"main": "dist/bin/sftp-proxy.js",
"bin": {
"sftp-proxy": "dist/bin/sftp-proxy.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/bin/sftp-proxy.js start",
"dev": "tsx bin/sftp-proxy.ts start",
"sync": "node dist/bin/sftp-proxy.js sync",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"basic-ftp": "^5.1.0",
"better-sqlite3": "^12.6.2",
"commander": "^14.0.3",
"ftp-srv": "^4.6.3",
"ssh2": "^1.17.0",
"webdav": "^5.9.0",
"winston": "^3.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.3.0",
"@types/ssh2": "^1.15.5",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,247 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import * as ftp from 'basic-ftp'
import { FTPServer } from '../adapters/server/ftp.js'
import type { BaseClient } from '../adapters/client/base.js'
import {
createMockClient, populateCache,
HOME_TREE, HOME_FILES, STORAGE_TREE, STORAGE_FILES,
} from './helpers.js'
vi.mock('../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
const TEST_PORT = 2391
const TEST_USER = 'testuser'
const TEST_PASS = 'testpass'
/** Creates a connected + authenticated FTP client. */
async function connectFTP(): Promise<ftp.Client> {
const client = new ftp.Client()
await client.access({
host: '127.0.0.1',
port: TEST_PORT,
user: TEST_USER,
password: TEST_PASS,
secure: false,
})
return client
}
describe('E2E: FTP Server', () => {
let server: FTPServer
let homeClient: BaseClient
let storageClient: BaseClient
beforeAll(async () => {
homeClient = createMockClient('/home', HOME_TREE, HOME_FILES)
storageClient = createMockClient('/storage', STORAGE_TREE, STORAGE_FILES)
await populateCache(homeClient, HOME_TREE)
await populateCache(storageClient, STORAGE_TREE)
server = new FTPServer(
[homeClient, storageClient],
{ port: TEST_PORT, pasv_url: '127.0.0.1', pasv_min: 3000, pasv_max: 3100 },
{ username: TEST_USER, password: TEST_PASS },
)
await server.start()
})
afterAll(async () => {
await server.stop()
})
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
it('should reject invalid credentials', async () => {
const client = new ftp.Client()
await expect(
client.access({
host: '127.0.0.1',
port: TEST_PORT,
user: 'wrong',
password: 'wrong',
secure: false,
})
).rejects.toThrow()
client.close()
})
// ---------------------------------------------------------------------------
// Directory listing
// ---------------------------------------------------------------------------
it('should list the virtual root', async () => {
const client = await connectFTP()
try {
const list = await client.list('/')
const names = list.map(e => e.name)
expect(names).toContain('home')
expect(names).toContain('storage')
} finally {
client.close()
}
})
it('should list mount point contents', async () => {
const client = await connectFTP()
try {
const list = await client.list('/home')
const names = list.map(e => e.name)
expect(names).toContain('docs')
expect(names).toContain('readme.txt')
} finally {
client.close()
}
})
it('should list nested directory contents', async () => {
const client = await connectFTP()
try {
const list = await client.list('/home/docs')
const names = list.map(e => e.name)
expect(names).toContain('hello.txt')
expect(names).toContain('world.txt')
} finally {
client.close()
}
})
it('should list storage mount contents', async () => {
const client = await connectFTP()
try {
const list = await client.list('/storage/backups')
const names = list.map(e => e.name)
expect(names).toContain('db.sql')
} finally {
client.close()
}
})
// ---------------------------------------------------------------------------
// Directory navigation
// ---------------------------------------------------------------------------
it('should change directory to mount point', async () => {
const client = await connectFTP()
try {
await client.cd('/home')
const pwd = await client.pwd()
expect(pwd).toBe('/home')
} finally {
client.close()
}
})
it('should change directory to nested path', async () => {
const client = await connectFTP()
try {
await client.cd('/home/docs')
const pwd = await client.pwd()
expect(pwd).toBe('/home/docs')
} finally {
client.close()
}
})
// ---------------------------------------------------------------------------
// File download
// ---------------------------------------------------------------------------
it('should download a file', async () => {
const client = await connectFTP()
try {
const chunks: Buffer[] = []
const writable = new (await import('node:stream')).PassThrough()
writable.on('data', (chunk: Buffer) => chunks.push(chunk))
await client.downloadTo(writable, '/home/readme.txt')
const content = Buffer.concat(chunks).toString()
expect(content).toBe('Hello from home!')
} finally {
client.close()
}
})
it('should download a nested file', async () => {
const client = await connectFTP()
try {
const chunks: Buffer[] = []
const writable = new (await import('node:stream')).PassThrough()
writable.on('data', (chunk: Buffer) => chunks.push(chunk))
await client.downloadTo(writable, '/home/docs/hello.txt')
const content = Buffer.concat(chunks).toString()
expect(content).toBe('Hello, World!')
} finally {
client.close()
}
})
// ---------------------------------------------------------------------------
// File upload
// ---------------------------------------------------------------------------
it('should upload a file', async () => {
const client = await connectFTP()
try {
const { Readable } = await import('node:stream')
const readable = Readable.from(Buffer.from('Uploaded content'))
await client.uploadFrom(readable, '/home/uploaded.txt')
expect(homeClient.write).toHaveBeenCalled()
} finally {
client.close()
}
})
// ---------------------------------------------------------------------------
// Directory creation
// ---------------------------------------------------------------------------
it('should create a directory', async () => {
const client = await connectFTP()
try {
// Use raw MKD command — ensureDir does CWD+MKD which fails on virtual paths
await client.send('MKD /home/new-dir')
expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir')
} finally {
client.close()
}
})
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
it('should delete a file', async () => {
const client = await connectFTP()
try {
await client.remove('/home/readme.txt')
expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt')
} finally {
client.close()
}
})
// ---------------------------------------------------------------------------
// Rename
// ---------------------------------------------------------------------------
it('should rename a file', async () => {
const client = await connectFTP()
try {
await client.rename('/home/readme.txt', '/home/renamed.txt')
expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt')
} finally {
client.close()
}
})
})

View File

@@ -0,0 +1,329 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import http from 'node:http'
import { WebDAVServer } from '../adapters/server/webdav.js'
import type { BaseClient } from '../adapters/client/base.js'
import {
createMockClient, populateCache,
HOME_TREE, HOME_FILES, STORAGE_TREE, STORAGE_FILES,
} from './helpers.js'
vi.mock('../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
const TEST_PORT = 2390
const TEST_USER = 'testuser'
const TEST_PASS = 'testpass'
const AUTH_HEADER = 'Basic ' + Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64')
const BAD_AUTH = 'Basic ' + Buffer.from('wrong:wrong').toString('base64')
/** Helper: makes an HTTP request and returns status, headers, and body. */
function request(opts: {
method: string
path: string
headers?: Record<string, string>
body?: string
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port: TEST_PORT,
method: opts.method,
path: opts.path,
headers: {
Authorization: AUTH_HEADER,
...opts.headers,
},
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
resolve({
status: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString(),
})
})
})
req.on('error', reject)
if (opts.body) req.write(opts.body)
req.end()
})
}
describe('E2E: WebDAV Server', () => {
let server: WebDAVServer
let homeClient: BaseClient
let storageClient: BaseClient
beforeAll(async () => {
homeClient = createMockClient('/home', HOME_TREE, HOME_FILES)
storageClient = createMockClient('/storage', STORAGE_TREE, STORAGE_FILES)
await populateCache(homeClient, HOME_TREE)
await populateCache(storageClient, STORAGE_TREE)
server = new WebDAVServer(
[homeClient, storageClient],
TEST_PORT,
{ username: TEST_USER, password: TEST_PASS },
)
await server.start()
})
afterAll(async () => {
await server.stop()
})
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
it('should reject requests without auth', async () => {
const res = await new Promise<{ status: number }>((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port: TEST_PORT,
method: 'PROPFIND',
path: '/',
}, (res) => {
res.resume()
res.on('end', () => resolve({ status: res.statusCode ?? 0 }))
})
req.on('error', reject)
req.end()
})
expect(res.status).toBe(401)
})
it('should reject invalid credentials', async () => {
const res = await request({
method: 'PROPFIND',
path: '/',
headers: { Authorization: BAD_AUTH },
})
expect(res.status).toBe(401)
})
// ---------------------------------------------------------------------------
// OPTIONS
// ---------------------------------------------------------------------------
it('should return allowed methods on OPTIONS', async () => {
const res = await request({ method: 'OPTIONS', path: '/' })
expect(res.status).toBe(200)
expect(res.headers['allow']).toContain('PROPFIND')
expect(res.headers['allow']).toContain('GET')
expect(res.headers['allow']).toContain('PUT')
expect(res.headers['dav']).toContain('1')
})
// ---------------------------------------------------------------------------
// PROPFIND
// ---------------------------------------------------------------------------
it('should PROPFIND root at Depth: 0', async () => {
const res = await request({
method: 'PROPFIND',
path: '/',
headers: { Depth: '0' },
})
expect(res.status).toBe(207)
expect(res.body).toContain('<D:multistatus')
expect(res.body).toContain('<D:href>/</D:href>')
expect(res.body).toContain('<D:collection/>')
// Should NOT include children at Depth: 0
expect(res.body).not.toContain('home')
})
it('should PROPFIND root at Depth: 1 showing mount points', async () => {
const res = await request({
method: 'PROPFIND',
path: '/',
headers: { Depth: '1' },
})
expect(res.status).toBe(207)
expect(res.body).toContain('/home/')
expect(res.body).toContain('/storage/')
})
it('should PROPFIND /home at Depth: 1 showing contents', async () => {
const res = await request({
method: 'PROPFIND',
path: '/home',
headers: { Depth: '1' },
})
expect(res.status).toBe(207)
expect(res.body).toContain('docs')
expect(res.body).toContain('readme.txt')
})
it('should PROPFIND nested directory', async () => {
const res = await request({
method: 'PROPFIND',
path: '/home/docs',
headers: { Depth: '1' },
})
expect(res.status).toBe(207)
expect(res.body).toContain('hello.txt')
expect(res.body).toContain('world.txt')
})
it('should reject Depth: infinity', async () => {
const res = await request({
method: 'PROPFIND',
path: '/',
headers: { Depth: 'infinity' },
})
expect(res.status).toBe(403)
})
it('should return 404 for nonexistent path', async () => {
const res = await request({
method: 'PROPFIND',
path: '/nonexistent',
headers: { Depth: '0' },
})
expect(res.status).toBe(404)
})
// ---------------------------------------------------------------------------
// GET / HEAD
// ---------------------------------------------------------------------------
it('should GET a file', async () => {
const res = await request({ method: 'GET', path: '/home/readme.txt' })
expect(res.status).toBe(200)
expect(res.body).toBe('Hello from home!')
})
it('should GET a nested file', async () => {
const res = await request({ method: 'GET', path: '/home/docs/hello.txt' })
expect(res.status).toBe(200)
expect(res.body).toBe('Hello, World!')
})
it('should GET a file from storage mount', async () => {
const res = await request({ method: 'GET', path: '/storage/backups/db.sql' })
expect(res.status).toBe(200)
expect(res.body).toBe('CREATE TABLE test;')
})
it('should return 404 for GET on nonexistent file', async () => {
const res = await request({ method: 'GET', path: '/home/nonexistent.txt' })
expect(res.status).toBe(404)
})
it('should HEAD a file', async () => {
const res = await request({ method: 'HEAD', path: '/home/readme.txt' })
expect(res.status).toBe(200)
expect(res.headers['content-length']).toBe('42')
expect(res.body).toBe('')
})
// ---------------------------------------------------------------------------
// PUT
// ---------------------------------------------------------------------------
it('should PUT a file', async () => {
const res = await request({
method: 'PUT',
path: '/home/new-file.txt',
body: 'New content!',
})
expect(res.status).toBe(201)
expect(homeClient.write).toHaveBeenCalled()
})
it('should reject PUT to virtual root', async () => {
const res = await request({
method: 'PUT',
path: '/new-file.txt',
body: 'content',
})
expect(res.status).toBe(403)
})
// ---------------------------------------------------------------------------
// DELETE
// ---------------------------------------------------------------------------
it('should DELETE a file', async () => {
const res = await request({ method: 'DELETE', path: '/home/readme.txt' })
expect(res.status).toBe(204)
expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt')
})
// ---------------------------------------------------------------------------
// MKCOL
// ---------------------------------------------------------------------------
it('should MKCOL to create a directory', async () => {
const res = await request({ method: 'MKCOL', path: '/home/new-dir' })
expect(res.status).toBe(201)
expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir')
})
// ---------------------------------------------------------------------------
// MOVE
// ---------------------------------------------------------------------------
it('should MOVE (rename) a file', async () => {
const res = await request({
method: 'MOVE',
path: '/home/readme.txt',
headers: { Destination: 'http://127.0.0.1:2390/home/renamed.txt' },
})
expect(res.status).toBe(201)
expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt')
})
it('should reject MOVE without Destination header', async () => {
const res = await request({ method: 'MOVE', path: '/home/readme.txt' })
expect(res.status).toBe(400)
})
it('should reject MOVE across mount points', async () => {
const res = await request({
method: 'MOVE',
path: '/home/readme.txt',
headers: { Destination: 'http://127.0.0.1:2390/storage/readme.txt' },
})
expect(res.status).toBe(403)
})
// ---------------------------------------------------------------------------
// LOCK / UNLOCK
// ---------------------------------------------------------------------------
it('should handle LOCK with a fake token', async () => {
const res = await request({ method: 'LOCK', path: '/home/readme.txt' })
expect(res.status).toBe(200)
expect(res.headers['lock-token']).toBeTruthy()
expect(res.body).toContain('opaquelocktoken:')
expect(res.body).toContain('<D:lockdiscovery>')
})
it('should handle UNLOCK', async () => {
const res = await request({
method: 'UNLOCK',
path: '/home/readme.txt',
headers: { 'Lock-Token': '<opaquelocktoken:fake>' },
})
expect(res.status).toBe(204)
})
// ---------------------------------------------------------------------------
// Unsupported methods
// ---------------------------------------------------------------------------
it('should return 405 for unsupported methods', async () => {
const res = await request({ method: 'PATCH', path: '/' })
expect(res.status).toBe(405)
})
})

405
src/__tests__/e2e.test.ts Normal file
View File

@@ -0,0 +1,405 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import { Readable } from 'node:stream'
import { posix, join } from 'node:path'
import { mkdirSync, rmSync } from 'node:fs'
import ssh2 from 'ssh2'
const { Client: SSHClient } = ssh2
import { MemoryCache } from '../adapters/cache/memory.js'
import { SFTPServer } from '../adapters/server/sftp.js'
import type { BaseClient } from '../adapters/client/base.js'
import type { GenericNode } from '../types.js'
import { SyncWorker } from '../sync.js'
/** Suppress logger output during tests. */
vi.mock('../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
const TEST_PORT = 2299
const TEST_USER = 'testuser'
const TEST_PASS = 'testpass'
const TEST_TEMP_DIR = join(process.cwd(), '.test-temp')
const TEST_HOST_KEY = join(TEST_TEMP_DIR, 'host.key')
function makeNode(path: string, isDir = false, size = 1024): GenericNode {
const parts = path.split('/')
return {
path,
name: parts[parts.length - 1] || '/',
isDir,
size: isDir ? 0 : size,
modified: new Date('2025-06-01T12:00:00Z'),
etag: undefined,
}
}
/**
* Creates a mock client with an in-memory filesystem.
* The filesystem is backed by a simple map of path → content for files
* and the cache for metadata.
*/
function createMockClient(mountPath: string, tree: Record<string, GenericNode[]>, fileContents: Record<string, string> = {}): BaseClient {
const cache = new MemoryCache()
const mockClient = {
mountPath,
cache,
sync: {
start: vi.fn(),
stop: vi.fn(),
forceSync: vi.fn().mockResolvedValue(undefined),
prioritise: vi.fn(),
waitForDrain: vi.fn().mockResolvedValue(undefined),
} as unknown as SyncWorker,
list: vi.fn(async (path: string) => {
return tree[path] ?? []
}),
stat: vi.fn(async (path: string) => {
// Search all tree entries for the matching node
for (const nodes of Object.values(tree)) {
const found = nodes.find(n => n.path === path)
if (found) return found
}
throw new Error(`Not found: ${path}`)
}),
read: vi.fn(async (path: string) => {
const content = fileContents[path] ?? `content of ${path}`
return Readable.from(Buffer.from(content))
}),
write: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
delete: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
}
return mockClient as unknown as BaseClient
}
/** Connects an ssh2 client and returns the SFTP wrapper. */
function connectSFTP(): Promise<{ client: SSHClient; sftp: any }> {
return new Promise((resolve, reject) => {
const client = new SSHClient()
client.on('ready', () => {
client.sftp((err: Error | undefined, sftp: any) => {
if (err) return reject(err)
resolve({ client, sftp })
})
})
client.on('error', reject)
client.connect({
host: '127.0.0.1',
port: TEST_PORT,
username: TEST_USER,
password: TEST_PASS,
})
})
}
/** Promise wrapper around sftp.readdir. */
function sftpReaddir(sftp: any, path: string): Promise<any[]> {
return new Promise((resolve, reject) => {
sftp.readdir(path, (err: Error | null, list: any[]) => {
if (err) return reject(err)
resolve(list)
})
})
}
/** Promise wrapper around sftp.stat. */
function sftpStat(sftp: any, path: string): Promise<any> {
return new Promise((resolve, reject) => {
sftp.stat(path, (err: Error | null, stats: any) => {
if (err) return reject(err)
resolve(stats)
})
})
}
/** Reads a full remote file into a string. */
function sftpReadFile(sftp: any, path: string): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
const stream = sftp.createReadStream(path)
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks).toString()))
stream.on('error', reject)
})
}
/** Writes a string to a remote file. */
function sftpWriteFile(sftp: any, path: string, content: string): Promise<void> {
return new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(path)
stream.on('close', resolve)
stream.on('error', reject)
stream.end(Buffer.from(content))
})
}
/** Promise wrapper around sftp.mkdir. */
function sftpMkdir(sftp: any, path: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, (err: Error | null) => {
if (err) return reject(err)
resolve()
})
})
}
/** Promise wrapper around sftp.unlink. */
function sftpUnlink(sftp: any, path: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(path, (err: Error | null) => {
if (err) return reject(err)
resolve()
})
})
}
/** Promise wrapper around sftp.rename. */
function sftpRename(sftp: any, from: string, to: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rename(from, to, (err: Error | null) => {
if (err) return reject(err)
resolve()
})
})
}
describe('E2E: SFTP Server', () => {
let server: SFTPServer
let homeClient: BaseClient
let storageClient: BaseClient
const homeTree: Record<string, GenericNode[]> = {
'/': [
makeNode('/docs', true),
makeNode('/readme.txt', false, 42),
],
'/docs': [
makeNode('/docs/hello.txt', false, 13),
makeNode('/docs/world.txt', false, 11),
],
}
const homeFiles: Record<string, string> = {
'/readme.txt': 'Hello from home!',
'/docs/hello.txt': 'Hello, World!',
'/docs/world.txt': 'World file.',
}
const storageTree: Record<string, GenericNode[]> = {
'/': [
makeNode('/backups', true),
],
'/backups': [
makeNode('/backups/db.sql', false, 999),
],
}
const storageFiles: Record<string, string> = {
'/backups/db.sql': 'CREATE TABLE test;',
}
beforeAll(async () => {
mkdirSync(TEST_TEMP_DIR, { recursive: true })
homeClient = createMockClient('/home', homeTree, homeFiles)
storageClient = createMockClient('/storage', storageTree, storageFiles)
// Pre-populate caches so the server can return data from cache
for (const [dir, nodes] of Object.entries(homeTree)) {
for (const node of nodes) {
await homeClient.cache.set(node.path, node)
}
}
for (const [dir, nodes] of Object.entries(storageTree)) {
for (const node of nodes) {
await storageClient.cache.set(node.path, node)
}
}
server = new SFTPServer(
[homeClient, storageClient],
TEST_PORT,
{ username: TEST_USER, password: TEST_PASS },
TEST_HOST_KEY,
)
await server.start()
})
afterAll(async () => {
await server.stop()
rmSync(TEST_TEMP_DIR, { recursive: true, force: true })
})
it('should reject invalid credentials', async () => {
await expect(
new Promise((resolve, reject) => {
const client = new SSHClient()
client.on('ready', () => reject(new Error('Should not authenticate')))
client.on('error', (err: Error) => resolve(err))
client.connect({
host: '127.0.0.1',
port: TEST_PORT,
username: 'wrong',
password: 'wrong',
})
})
).resolves.toBeTruthy()
})
it('should list virtual root directory', async () => {
const { client, sftp } = await connectSFTP()
try {
const entries = await sftpReaddir(sftp, '/')
const names = entries.map((e: any) => e.filename)
expect(names).toContain('home')
expect(names).toContain('storage')
} finally {
client.end()
}
})
it('should list mount point contents', async () => {
const { client, sftp } = await connectSFTP()
try {
const entries = await sftpReaddir(sftp, '/home')
const names = entries.map((e: any) => e.filename)
expect(names).toContain('docs')
expect(names).toContain('readme.txt')
} finally {
client.end()
}
})
it('should list nested directory contents', async () => {
const { client, sftp } = await connectSFTP()
try {
const entries = await sftpReaddir(sftp, '/home/docs')
const names = entries.map((e: any) => e.filename)
expect(names).toContain('hello.txt')
expect(names).toContain('world.txt')
} finally {
client.end()
}
})
it('should stat the root directory', async () => {
const { client, sftp } = await connectSFTP()
try {
const stats = await sftpStat(sftp, '/')
expect(stats.isDirectory()).toBe(true)
} finally {
client.end()
}
})
it('should stat a file', async () => {
const { client, sftp } = await connectSFTP()
try {
const stats = await sftpStat(sftp, '/home/readme.txt')
expect(stats.isDirectory()).toBe(false)
expect(stats.size).toBe(42)
} finally {
client.end()
}
})
it('should read a file', async () => {
const { client, sftp } = await connectSFTP()
try {
const content = await sftpReadFile(sftp, '/home/readme.txt')
expect(content).toBe('Hello from home!')
} finally {
client.end()
}
})
it('should read a file from a nested directory', async () => {
const { client, sftp } = await connectSFTP()
try {
const content = await sftpReadFile(sftp, '/home/docs/hello.txt')
expect(content).toBe('Hello, World!')
} finally {
client.end()
}
})
it('should read a file from the storage mount', async () => {
const { client, sftp } = await connectSFTP()
try {
const content = await sftpReadFile(sftp, '/storage/backups/db.sql')
expect(content).toBe('CREATE TABLE test;')
} finally {
client.end()
}
})
it('should write a file', async () => {
const { client, sftp } = await connectSFTP()
try {
await sftpWriteFile(sftp, '/home/new-file.txt', 'New content!')
expect(homeClient.write).toHaveBeenCalled()
} finally {
client.end()
}
})
it('should create a directory', async () => {
const { client, sftp } = await connectSFTP()
try {
await sftpMkdir(sftp, '/home/new-dir')
expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir')
} finally {
client.end()
}
})
it('should delete a file', async () => {
const { client, sftp } = await connectSFTP()
try {
await sftpUnlink(sftp, '/home/readme.txt')
expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt')
} finally {
client.end()
}
})
it('should rename a file', async () => {
const { client, sftp } = await connectSFTP()
try {
await sftpRename(sftp, '/home/readme.txt', '/home/renamed.txt')
expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt')
} finally {
client.end()
}
})
it('should resolve REALPATH', async () => {
const { client, sftp } = await connectSFTP()
try {
const realpath = await new Promise<string>((resolve, reject) => {
sftp.realpath('/home/../home/docs', (err: Error | null, path: string) => {
if (err) return reject(err)
resolve(path)
})
})
expect(realpath).toBe('/home/docs')
} finally {
client.end()
}
})
})

110
src/__tests__/helpers.ts Normal file
View File

@@ -0,0 +1,110 @@
import { vi } from 'vitest'
import { Readable } from 'node:stream'
import { MemoryCache } from '../adapters/cache/memory.js'
import type { BaseClient } from '../adapters/client/base.js'
import type { GenericNode } from '../types.js'
import { SyncWorker } from '../sync.js'
/** Creates a GenericNode for testing. */
export function makeNode(path: string, isDir = false, size = 1024): GenericNode {
const parts = path.split('/')
return {
path,
name: parts[parts.length - 1] || '/',
isDir,
size: isDir ? 0 : size,
modified: new Date('2025-06-01T12:00:00Z'),
etag: undefined,
}
}
/**
* Creates a mock client backed by an in-memory cache.
* `tree` maps directory paths to their children, `fileContents` maps file paths to content strings.
*/
export function createMockClient(
mountPath: string,
tree: Record<string, GenericNode[]>,
fileContents: Record<string, string> = {},
): BaseClient {
const cache = new MemoryCache()
const mockClient = {
mountPath,
cache,
sync: {
start: vi.fn(),
stop: vi.fn(),
forceSync: vi.fn().mockResolvedValue(undefined),
prioritise: vi.fn(),
waitForDrain: vi.fn().mockResolvedValue(undefined),
} as unknown as SyncWorker,
list: vi.fn(async (path: string) => {
return tree[path] ?? []
}),
stat: vi.fn(async (path: string) => {
for (const nodes of Object.values(tree)) {
const found = nodes.find(n => n.path === path)
if (found) return found
}
throw new Error(`Not found: ${path}`)
}),
read: vi.fn(async (path: string) => {
const content = fileContents[path] ?? `content of ${path}`
return Readable.from(Buffer.from(content))
}),
write: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
delete: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
}
return mockClient as unknown as BaseClient
}
/** Populates a mock client's cache from its tree definition. */
export async function populateCache(client: BaseClient, tree: Record<string, GenericNode[]>): Promise<void> {
for (const nodes of Object.values(tree)) {
for (const node of nodes) {
await client.cache.set(node.path, node)
}
}
}
/** Standard test tree for the `/home` mount. */
export const HOME_TREE: Record<string, GenericNode[]> = {
'/': [
makeNode('/docs', true),
makeNode('/readme.txt', false, 42),
],
'/docs': [
makeNode('/docs/hello.txt', false, 13),
makeNode('/docs/world.txt', false, 11),
],
}
/** Standard file contents for the `/home` mount. */
export const HOME_FILES: Record<string, string> = {
'/readme.txt': 'Hello from home!',
'/docs/hello.txt': 'Hello, World!',
'/docs/world.txt': 'World file.',
}
/** Standard test tree for the `/storage` mount. */
export const STORAGE_TREE: Record<string, GenericNode[]> = {
'/': [
makeNode('/backups', true),
],
'/backups': [
makeNode('/backups/db.sql', false, 999),
],
}
/** Standard file contents for the `/storage` mount. */
export const STORAGE_FILES: Record<string, string> = {
'/backups/db.sql': 'CREATE TABLE test;',
}

198
src/__tests__/sync.test.ts Normal file
View File

@@ -0,0 +1,198 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { SyncWorker, type SyncableClient } from '../sync.js'
import { MemoryCache } from '../adapters/cache/memory.js'
import type { GenericNode } from '../types.js'
/** Suppress logger output during tests. */
vi.mock('../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
function makeNode(path: string, isDir = false): GenericNode {
const parts = path.split('/')
return {
path,
name: parts[parts.length - 1] || '/',
isDir,
size: isDir ? 0 : 1024,
modified: new Date('2025-01-01'),
etag: `etag-${path}`,
}
}
/**
* Creates a mock client that returns a pre-configured directory tree.
* The tree is a map from directory path to its direct children.
*/
function createMockClient(tree: Record<string, GenericNode[]>): SyncableClient {
return {
list: vi.fn(async (path: string) => {
return tree[path] ?? []
}),
}
}
describe('SyncWorker', () => {
let cache: MemoryCache
beforeEach(() => {
cache = new MemoryCache()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should sync root directory contents to cache', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [
makeNode('/docs', true),
makeNode('/readme.txt'),
],
'/docs': [
makeNode('/docs/file.txt'),
],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 2)
worker.start()
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/docs')).toBeTruthy()
expect(await cache.get('/readme.txt')).toBeTruthy()
expect(await cache.get('/docs/file.txt')).toBeTruthy()
})
it('should remove deleted nodes from cache', async () => {
// Pre-populate cache with a node that no longer exists on remote
await cache.set('/old-file.txt', makeNode('/old-file.txt'))
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/new-file.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/old-file.txt')).toBeNull()
expect(await cache.get('/new-file.txt')).toBeTruthy()
})
it('should update changed nodes based on etag', async () => {
const originalNode = makeNode('/file.txt')
originalNode.etag = 'old-etag'
await cache.set('/file.txt', originalNode)
const updatedNode = makeNode('/file.txt')
updatedNode.etag = 'new-etag'
const tree: Record<string, GenericNode[]> = {
'/': [updatedNode],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
const cached = await cache.get('/file.txt')
expect(cached?.etag).toBe('new-etag')
})
it('should handle forceSync', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/docs', true)],
'/docs': [makeNode('/docs/new.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 2)
worker.start()
await worker.forceSync('/docs')
worker.stop()
expect(await cache.get('/docs/new.txt')).toBeTruthy()
})
it('should handle prioritise without blocking', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/docs', true)],
'/docs': [makeNode('/docs/file.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 2)
worker.start()
worker.prioritise('/docs')
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/docs/file.txt')).toBeTruthy()
})
it('should recursively sync nested directories', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/a', true)],
'/a': [makeNode('/a/b', true)],
'/a/b': [makeNode('/a/b/c', true)],
'/a/b/c': [makeNode('/a/b/c/deep.txt')],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
expect(await cache.get('/a/b/c/deep.txt')).toBeTruthy()
})
it('should handle empty directories', async () => {
const tree: Record<string, GenericNode[]> = {
'/': [makeNode('/empty', true)],
'/empty': [],
}
const client = createMockClient(tree)
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
const children = await cache.children('/empty')
expect(children).toHaveLength(0)
})
it('should handle client.list errors gracefully', async () => {
const client: SyncableClient = {
list: vi.fn(async () => {
throw new Error('Network failure')
}),
}
const worker = new SyncWorker(client, cache, 1)
worker.start()
await worker.waitForDrain()
worker.stop()
// Should not throw, just log the error
expect(await cache.all()).toHaveLength(0)
})
})

103
src/__tests__/types.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest'
import { ConfigSchema } from '../types.js'
describe('ConfigSchema', () => {
const validConfig = {
port: 2022,
credentials: {
username: 'localuser',
password: 'localpassword',
},
clients: [
{
type: 'webdav' as const,
url: 'https://files.example.com',
basePath: '/',
username: 'user',
password: 'pass',
mountPath: '/home',
concurrency: 5,
cache: { type: 'sqlite' as const },
},
],
}
it('should accept a valid config', () => {
const result = ConfigSchema.safeParse(validConfig)
expect(result.success).toBe(true)
})
it('should apply default values', () => {
const minimal = {
credentials: {
username: 'user',
password: 'pass',
},
clients: [
{
type: 'webdav',
url: 'https://example.com',
username: 'u',
password: 'p',
mountPath: '/mount',
},
],
}
const result = ConfigSchema.safeParse(minimal)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.port).toBe(2022)
expect(result.data.clients[0].concurrency).toBe(5)
expect(result.data.clients[0].basePath).toBe('/')
expect(result.data.clients[0].cache.type).toBe('sqlite')
}
})
it('should reject config with no clients', () => {
const noClients = {
...validConfig,
clients: [],
}
const result = ConfigSchema.safeParse(noClients)
expect(result.success).toBe(false)
})
it('should reject config with missing credentials', () => {
const { credentials, ...rest } = validConfig
const result = ConfigSchema.safeParse(rest)
expect(result.success).toBe(false)
})
it('should reject config with invalid client type', () => {
const badType = {
...validConfig,
clients: [{ ...validConfig.clients[0], type: 'invalid' }],
}
const result = ConfigSchema.safeParse(badType)
expect(result.success).toBe(false)
})
it('should accept all valid client types', () => {
for (const type of ['webdav', 'ftp', 'sftp'] as const) {
const config = {
...validConfig,
clients: [{ ...validConfig.clients[0], type }],
}
const result = ConfigSchema.safeParse(config)
expect(result.success).toBe(true)
}
})
it('should accept optional servers config', () => {
const withServers = {
...validConfig,
servers: {
sftp: { port: 2022 },
ftp: { port: 2121 },
webdav: { port: 8080 },
},
}
const result = ConfigSchema.safeParse(withServers)
expect(result.success).toBe(true)
})
})

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { MemoryCache } from '../memory.js'
import type { GenericNode } from '../../../types.js'
function makeNode(path: string, isDir = false): GenericNode {
const parts = path.split('/')
return {
path,
name: parts[parts.length - 1] || '/',
isDir,
size: isDir ? 0 : 1024,
modified: new Date('2025-01-01'),
etag: undefined,
}
}
describe('MemoryCache', () => {
let cache: MemoryCache
beforeEach(() => {
cache = new MemoryCache()
})
it('should return null for missing keys', async () => {
expect(await cache.get('/nonexistent')).toBeNull()
})
it('should set and get a node', async () => {
const node = makeNode('/docs/file.txt')
await cache.set('/docs/file.txt', node)
const result = await cache.get('/docs/file.txt')
expect(result).toEqual(node)
})
it('should delete a node', async () => {
await cache.set('/docs/file.txt', makeNode('/docs/file.txt'))
await cache.delete('/docs/file.txt')
expect(await cache.get('/docs/file.txt')).toBeNull()
})
it('should return all nodes', async () => {
await cache.set('/a.txt', makeNode('/a.txt'))
await cache.set('/b.txt', makeNode('/b.txt'))
const all = await cache.all()
expect(all).toHaveLength(2)
})
it('should return all keys', async () => {
await cache.set('/a.txt', makeNode('/a.txt'))
await cache.set('/b.txt', makeNode('/b.txt'))
const keys = await cache.keys()
expect(keys).toContain('/a.txt')
expect(keys).toContain('/b.txt')
})
it('should clear all nodes', async () => {
await cache.set('/a.txt', makeNode('/a.txt'))
await cache.set('/b.txt', makeNode('/b.txt'))
await cache.clear()
expect(await cache.all()).toHaveLength(0)
})
describe('children', () => {
beforeEach(async () => {
await cache.set('/docs', makeNode('/docs', true))
await cache.set('/docs/a.txt', makeNode('/docs/a.txt'))
await cache.set('/docs/b.txt', makeNode('/docs/b.txt'))
await cache.set('/docs/sub', makeNode('/docs/sub', true))
await cache.set('/docs/sub/c.txt', makeNode('/docs/sub/c.txt'))
await cache.set('/other', makeNode('/other', true))
await cache.set('/other/d.txt', makeNode('/other/d.txt'))
})
it('should return direct children of /docs', async () => {
const children = await cache.children('/docs')
const paths = children.map(n => n.path)
expect(paths).toContain('/docs/a.txt')
expect(paths).toContain('/docs/b.txt')
expect(paths).toContain('/docs/sub')
expect(paths).not.toContain('/docs/sub/c.txt')
expect(paths).not.toContain('/other/d.txt')
})
it('should return direct children of root (/)', async () => {
const children = await cache.children('/')
const paths = children.map(n => n.path)
expect(paths).toContain('/docs')
expect(paths).toContain('/other')
expect(paths).not.toContain('/docs/a.txt')
})
it('should return direct children of /docs/sub', async () => {
const children = await cache.children('/docs/sub')
const paths = children.map(n => n.path)
expect(paths).toEqual(['/docs/sub/c.txt'])
})
it('should return empty array for path with no children', async () => {
const children = await cache.children('/empty')
expect(children).toHaveLength(0)
})
it('should handle trailing slashes', async () => {
const children = await cache.children('/docs/')
const paths = children.map(n => n.path)
expect(paths).toContain('/docs/a.txt')
})
})
})

32
src/adapters/cache/base.ts vendored Normal file
View File

@@ -0,0 +1,32 @@
import type { GenericNode } from '../../types.js'
/**
* Abstract base class for all cache implementations.
* Keys are always the full POSIX path of the node, e.g. `/docs/file.txt`.
* All methods are async even if the underlying implementation is synchronous.
*/
export abstract class BaseCache {
/** Retrieve a cached node by its path key. */
abstract get(key: string): Promise<GenericNode | null>
/** Store a node in the cache, keyed by its path. */
abstract set(key: string, value: GenericNode): Promise<void>
/** Remove a node from the cache by its path key. */
abstract delete(key: string): Promise<void>
/** Return all cached nodes. */
abstract all(): Promise<GenericNode[]>
/** Return all cached keys. */
abstract keys(): Promise<string[]>
/**
* Return direct children of the given path — one level deep only.
* For path `/docs`, this returns nodes like `/docs/file.txt` but NOT `/docs/sub/file.txt`.
*/
abstract children(path: string): Promise<GenericNode[]>
/** Remove all entries from the cache. */
abstract clear(): Promise<void>
}

53
src/adapters/cache/memory.ts vendored Normal file
View File

@@ -0,0 +1,53 @@
import { posix } from 'node:path'
import type { GenericNode } from '../../types.js'
import { BaseCache } from './base.js'
/**
* In-memory cache implementation backed by a simple Map.
* Primarily useful for testing and lightweight use cases.
*/
export class MemoryCache extends BaseCache {
private store = new Map<string, GenericNode>()
async get(key: string): Promise<GenericNode | null> {
return this.store.get(key) ?? null
}
async set(key: string, value: GenericNode): Promise<void> {
this.store.set(key, value)
}
async delete(key: string): Promise<void> {
this.store.delete(key)
}
async all(): Promise<GenericNode[]> {
return Array.from(this.store.values())
}
async keys(): Promise<string[]> {
return Array.from(this.store.keys())
}
/**
* Returns direct children of the given path — one level deep only.
* Filters keys whose dirname matches the normalised parent path.
*/
async children(path: string): Promise<GenericNode[]> {
const normalised = path === '/' ? '/' : path.replace(/\/+$/, '')
const result: GenericNode[] = []
for (const [key, node] of this.store) {
const parent = posix.dirname(key)
if (parent === normalised && key !== normalised) {
result.push(node)
}
}
return result
}
async clear(): Promise<void> {
this.store.clear()
}
}

142
src/adapters/cache/sqlite.ts vendored Normal file
View File

@@ -0,0 +1,142 @@
import { mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import Database from 'better-sqlite3'
import type { GenericNode } from '../../types.js'
import { BaseCache } from './base.js'
/** Default path for the SQLite database file. */
const DEFAULT_DB_PATH = join(homedir(), '.config', 'sftp-proxy', 'cache.db')
/**
* SQLite-backed cache implementation.
* Stores node metadata in a `nodes` table and log entries in a `logs` table.
*/
export class SqliteCache extends BaseCache {
private db: Database.Database
/**
* @param dbPath - Path to the SQLite database file. Defaults to ~/.config/sftp-proxy/cache.db
*/
constructor(dbPath: string = DEFAULT_DB_PATH) {
super()
mkdirSync(dirname(dbPath), { recursive: true })
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.initSchema()
}
/** Creates tables if they don't already exist. */
private initSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS nodes (
path TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL,
modified INTEGER NOT NULL,
etag TEXT,
synced_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL
);
`)
}
/** Maps a database row to a GenericNode. */
private rowToNode(row: Record<string, unknown>): GenericNode {
return {
path: row['path'] as string,
name: row['name'] as string,
isDir: (row['is_dir'] as number) === 1,
size: row['size'] as number,
modified: new Date(row['modified'] as number),
etag: (row['etag'] as string) ?? undefined,
}
}
async get(key: string): Promise<GenericNode | null> {
const row = this.db.prepare('SELECT * FROM nodes WHERE path = ?').get(key) as Record<string, unknown> | undefined
return row ? this.rowToNode(row) : null
}
async set(key: string, value: GenericNode): Promise<void> {
this.db.prepare(`
INSERT OR REPLACE INTO nodes (path, name, is_dir, size, modified, etag, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
key,
value.name,
value.isDir ? 1 : 0,
value.size,
value.modified.getTime(),
value.etag ?? null,
Date.now(),
)
}
async delete(key: string): Promise<void> {
this.db.prepare('DELETE FROM nodes WHERE path = ?').run(key)
}
async all(): Promise<GenericNode[]> {
const rows = this.db.prepare('SELECT * FROM nodes').all() as Record<string, unknown>[]
return rows.map(row => this.rowToNode(row))
}
async keys(): Promise<string[]> {
const rows = this.db.prepare('SELECT path FROM nodes').all() as Array<{ path: string }>
return rows.map(row => row.path)
}
/**
* Returns direct children of the given path — one level deep only.
* Uses LIKE with a NOT LIKE nested-exclusion pattern.
*/
async children(path: string): Promise<GenericNode[]> {
const normalised = path === '/' ? '' : path.replace(/\/+$/, '')
const prefix = normalised + '/'
const nestedPrefix = normalised + '/%/%'
const rows = this.db.prepare(
`SELECT * FROM nodes WHERE path LIKE ? AND path NOT LIKE ?`
).all(prefix + '%', nestedPrefix) as Record<string, unknown>[]
return rows.map(row => this.rowToNode(row))
}
async clear(): Promise<void> {
this.db.prepare('DELETE FROM nodes').run()
}
/**
* Writes a log entry into the logs table.
* Used by the winston SQLite transport.
*/
writeLog(level: string, message: string): void {
this.db.prepare(
'INSERT INTO logs (timestamp, level, message) VALUES (?, ?, ?)'
).run(Date.now(), level, message)
}
/**
* Reads the most recent log entries.
* @param limit - Maximum number of entries to return. Defaults to 500.
*/
readLogs(limit: number = 500): Array<{ timestamp: number; level: string; message: string }> {
return this.db.prepare(
'SELECT timestamp, level, message FROM logs ORDER BY id DESC LIMIT ?'
).all(limit) as Array<{ timestamp: number; level: string; message: string }>
}
/** Closes the database connection. */
close(): void {
this.db.close()
}
}

View File

@@ -0,0 +1,62 @@
import type { Readable } from 'node:stream'
import type { GenericNode, ClientConfig } from '../../types.js'
import type { BaseCache } from '../cache/base.js'
import { SyncWorker } from '../../sync.js'
/**
* Abstract base class for all remote storage clients.
*
* Each client owns its own cache and sync worker. All paths passed to
* client methods are relative to the configured `basePath` on the remote server.
*
* The client stores `mountPath` as data but never uses it for logic —
* that's purely a server concern for dispatch.
*/
export abstract class BaseClient {
/** Where this client appears in the local virtual filesystem. Server-only concern. */
readonly mountPath: string
/** Cache instance owned by this client. */
readonly cache: BaseCache
/** Background sync worker that keeps the cache fresh. */
readonly sync: SyncWorker
constructor(
protected config: ClientConfig,
cache: BaseCache,
) {
this.mountPath = config.mountPath
this.cache = cache
this.sync = new SyncWorker(this, cache, config.concurrency)
}
/** Lists direct children at the given remote path (metadata only). */
abstract list(path: string): Promise<GenericNode[]>
/** Returns metadata for a single remote path. */
abstract stat(path: string): Promise<GenericNode>
/** Returns a readable stream of the file contents at the given remote path. */
abstract read(path: string): Promise<Readable>
/** Writes the stream contents to the given remote path. */
abstract write(path: string, stream: Readable): Promise<void>
/** Creates a directory at the given remote path. */
abstract mkdir(path: string): Promise<void>
/** Deletes the file or directory at the given remote path. */
abstract delete(path: string): Promise<void>
/** Renames/moves a file or directory from one remote path to another. */
abstract rename(from: string, to: string): Promise<void>
/**
* Resolves a path relative to the client's basePath into a full remote path.
* Normalises slashes and ensures a leading slash.
*/
protected resolvePath(relativePath: string): string {
const base = this.config.basePath.replace(/\/+$/, '')
const rel = relativePath.startsWith('/') ? relativePath : `/${relativePath}`
return `${base}${rel}`
}
}

126
src/adapters/client/ftp.ts Normal file
View File

@@ -0,0 +1,126 @@
import { Readable, PassThrough } from 'node:stream'
import { posix } from 'node:path'
import * as ftp from 'basic-ftp'
import type { GenericNode, ClientConfig } from '../../types.js'
import type { BaseCache } from '../cache/base.js'
import { BaseClient } from './base.js'
import { logger } from '../../logger.js'
/**
* FTP remote storage client.
*
* Maintains a persistent connection and reconnects on failure.
* Uses `basic-ftp` for all operations.
*/
export class FTPClientAdapter extends BaseClient {
private client: ftp.Client
private connected = false
constructor(config: ClientConfig, cache: BaseCache) {
super(config, cache)
this.client = new ftp.Client()
logger.info(`FTP client created for ${config.url} (mount: ${config.mountPath})`)
}
/**
* Ensures the FTP client is connected. Reconnects if the connection was lost.
*/
private async ensureConnected(): Promise<void> {
if (this.connected && !this.client.closed) return
try {
const url = new URL(this.config.url)
await this.client.access({
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 21,
user: this.config.username,
password: this.config.password,
secure: url.protocol === 'ftps:',
})
this.connected = true
logger.info(`FTP connected to ${this.config.url}`)
} catch (err) {
this.connected = false
throw err
}
}
/** Maps an FTP FileInfo to our GenericNode format. */
private toNode(info: ftp.FileInfo, parentPath: string): GenericNode {
const nodePath = posix.join(parentPath, info.name)
return {
path: nodePath,
name: info.name,
isDir: info.isDirectory,
size: info.size,
modified: info.modifiedAt ?? new Date(0),
etag: undefined,
}
}
async list(path: string): Promise<GenericNode[]> {
await this.ensureConnected()
const remotePath = this.resolvePath(path)
const items = await this.client.list(remotePath)
return items
.filter(item => item.name !== '.' && item.name !== '..')
.map(item => this.toNode(item, path))
}
async stat(path: string): Promise<GenericNode> {
await this.ensureConnected()
const remotePath = this.resolvePath(path)
const parentDir = posix.dirname(remotePath)
const basename = posix.basename(remotePath)
// FTP has no direct stat — list the parent and find the entry
const items = await this.client.list(parentDir)
const match = items.find(i => i.name === basename)
if (!match) {
throw new Error(`FTP: path not found: ${path}`)
}
return this.toNode(match, posix.dirname(path))
}
async read(path: string): Promise<Readable> {
await this.ensureConnected()
const remotePath = this.resolvePath(path)
const passthrough = new PassThrough()
await this.client.downloadTo(passthrough, remotePath)
return passthrough
}
async write(path: string, stream: Readable): Promise<void> {
await this.ensureConnected()
const remotePath = this.resolvePath(path)
await this.client.uploadFrom(stream, remotePath)
}
async mkdir(path: string): Promise<void> {
await this.ensureConnected()
const remotePath = this.resolvePath(path)
await this.client.ensureDir(remotePath)
}
async delete(path: string): Promise<void> {
await this.ensureConnected()
const remotePath = this.resolvePath(path)
// Try removing as file first, then as directory
try {
await this.client.remove(remotePath)
} catch {
await this.client.removeDir(remotePath)
}
}
async rename(from: string, to: string): Promise<void> {
await this.ensureConnected()
const remoteFrom = this.resolvePath(from)
const remoteTo = this.resolvePath(to)
await this.client.rename(remoteFrom, remoteTo)
}
}

198
src/adapters/client/sftp.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Readable, PassThrough } from 'node:stream'
import { posix } from 'node:path'
import ssh2 from 'ssh2'
const { Client: SSHClient } = ssh2
type SFTPWrapper = ssh2.SFTPWrapper
type ConnectConfig = ssh2.ConnectConfig
import type { GenericNode, ClientConfig } from '../../types.js'
import type { BaseCache } from '../cache/base.js'
import { BaseClient } from './base.js'
import { logger } from '../../logger.js'
/**
* SFTP remote storage client.
*
* Maintains a persistent SSH connection with an SFTP subsystem.
* Reconnects automatically on failure.
*/
export class SFTPClientAdapter extends BaseClient {
private sshClient: InstanceType<typeof SSHClient> | null = null
private sftp: SFTPWrapper | null = null
private connected = false
constructor(config: ClientConfig, cache: BaseCache) {
super(config, cache)
logger.info(`SFTP client created for ${config.url} (mount: ${config.mountPath})`)
}
/**
* Ensures the SFTP session is connected. Reconnects if the connection was lost.
*/
private async ensureConnected(): Promise<SFTPWrapper> {
if (this.connected && this.sftp) return this.sftp
return new Promise<SFTPWrapper>((resolve, reject) => {
this.cleanup()
const url = new URL(this.config.url)
const client = new SSHClient()
const connectConfig: ConnectConfig = {
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 22,
username: this.config.username,
password: this.config.password,
}
client.on('ready', () => {
client.sftp((err, sftp) => {
if (err) {
client.end()
return reject(err)
}
this.sshClient = client
this.sftp = sftp
this.connected = true
logger.info(`SFTP connected to ${this.config.url}`)
resolve(sftp)
})
})
client.on('error', (err) => {
this.connected = false
reject(err)
})
client.on('close', () => {
this.connected = false
this.sftp = null
})
client.connect(connectConfig)
})
}
/** Tears down any existing SSH connection. */
private cleanup(): void {
if (this.sshClient) {
this.sshClient.end()
this.sshClient = null
}
this.sftp = null
this.connected = false
}
/** Maps an SFTP file stat to our GenericNode format. */
private toNode(
name: string,
attrs: { size: number; mtime: number; isDirectory: () => boolean },
parentPath: string,
): GenericNode {
const nodePath = posix.join(parentPath, name)
return {
path: nodePath,
name,
isDir: attrs.isDirectory(),
size: attrs.size,
modified: new Date(attrs.mtime * 1000),
etag: undefined,
}
}
async list(path: string): Promise<GenericNode[]> {
const sftp = await this.ensureConnected()
const remotePath = this.resolvePath(path)
return new Promise<GenericNode[]>((resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
if (err) return reject(err)
const nodes = list
.filter(entry => entry.filename !== '.' && entry.filename !== '..')
.map(entry => this.toNode(entry.filename, entry.attrs as any, path))
resolve(nodes)
})
})
}
async stat(path: string): Promise<GenericNode> {
const sftp = await this.ensureConnected()
const remotePath = this.resolvePath(path)
return new Promise<GenericNode>((resolve, reject) => {
sftp.stat(remotePath, (err, attrs) => {
if (err) return reject(err)
resolve({
path,
name: posix.basename(path) || '/',
isDir: (attrs as any).isDirectory(),
size: attrs.size,
modified: new Date(attrs.mtime * 1000),
etag: undefined,
})
})
})
}
async read(path: string): Promise<Readable> {
const sftp = await this.ensureConnected()
const remotePath = this.resolvePath(path)
return sftp.createReadStream(remotePath)
}
async write(path: string, stream: Readable): Promise<void> {
const sftp = await this.ensureConnected()
const remotePath = this.resolvePath(path)
return new Promise<void>((resolve, reject) => {
const writeStream = sftp.createWriteStream(remotePath)
stream.pipe(writeStream)
writeStream.on('close', resolve)
writeStream.on('error', reject)
stream.on('error', reject)
})
}
async mkdir(path: string): Promise<void> {
const sftp = await this.ensureConnected()
const remotePath = this.resolvePath(path)
return new Promise<void>((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) return reject(err)
resolve()
})
})
}
async delete(path: string): Promise<void> {
const sftp = await this.ensureConnected()
const remotePath = this.resolvePath(path)
// Try unlink (file) first, then rmdir (directory)
return new Promise<void>((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (!err) return resolve()
sftp.rmdir(remotePath, (rmdirErr) => {
if (rmdirErr) return reject(rmdirErr)
resolve()
})
})
})
}
async rename(from: string, to: string): Promise<void> {
const sftp = await this.ensureConnected()
const remoteFrom = this.resolvePath(from)
const remoteTo = this.resolvePath(to)
return new Promise<void>((resolve, reject) => {
sftp.rename(remoteFrom, remoteTo, (err) => {
if (err) return reject(err)
resolve()
})
})
}
}

View File

@@ -0,0 +1,127 @@
import { Readable } from 'node:stream'
import { posix } from 'node:path'
import { createClient, type WebDAVClient, type FileStat } from 'webdav'
import type { GenericNode, ClientConfig } from '../../types.js'
import type { BaseCache } from '../cache/base.js'
import { BaseClient } from './base.js'
import { logger } from '../../logger.js'
/**
* WebDAV remote storage client.
*
* Uses `PROPFIND Depth: 1` for listings — never assumes `Depth: infinity` is supported.
* Never fetches file contents during sync, only metadata.
*/
export class WebDAVClientAdapter extends BaseClient {
private webdav: WebDAVClient
constructor(config: ClientConfig, cache: BaseCache) {
super(config, cache)
this.webdav = createClient(config.url, {
username: config.username,
password: config.password,
})
logger.info(`WebDAV client created for ${config.url} (mount: ${config.mountPath})`)
}
/** Maps a webdav FileStat to our GenericNode format. */
private toNode(stat: FileStat, parentPath: string): GenericNode {
const nodePath = posix.join(parentPath, stat.basename)
return {
path: nodePath,
name: stat.basename,
isDir: stat.type === 'directory',
size: stat.size,
modified: new Date(stat.lastmod),
etag: stat.etag ?? undefined,
}
}
async list(path: string): Promise<GenericNode[]> {
const remotePath = this.resolvePath(path)
const contents = await this.webdav.getDirectoryContents(remotePath, { deep: false }) as FileStat[]
// The webdav package's built-in self-reference filter breaks when the server
// has a non-root base path (e.g. Nextcloud's /remote.php/dav/files/user).
// It compares stat.filename (relativized) against the request path (absolute),
// so they never match and the directory itself leaks into the results.
//
// stat.filename is either:
// - absolute like "/Media" (when serverBase is "/")
// - relative like "Media" (when serverBase is a deeper path)
//
// We normalise both sides and also compare basenames to catch all variants.
const normRemote = remotePath.replace(/\/+$/, '') || '/'
const parentBasename = posix.basename(normRemote)
const children = contents.filter(stat => {
// Empty basename = root self-reference
if (!stat.basename) return false
const fn = (stat.filename || '').replace(/\/+$/, '') || '/'
// Absolute match (serverBase is "/")
if (fn === normRemote) return false
// Relative match (serverBase is deeper — filename has no leading slash)
if ('/' + fn === normRemote) return false
if (fn === normRemote.slice(1)) return false
// Directory whose basename matches the listed directory AND whose
// filename doesn't contain a slash (i.e. it's a single path segment,
// meaning it's the directory itself, not a nested child)
if (
stat.type === 'directory' &&
stat.basename === parentBasename &&
!stat.filename.includes('/')
) return false
return true
})
return children.map(stat => this.toNode(stat, path))
}
async stat(path: string): Promise<GenericNode> {
const remotePath = this.resolvePath(path)
const result = await this.webdav.stat(remotePath) as FileStat
return {
path,
name: posix.basename(path) || '/',
isDir: result.type === 'directory',
size: result.size,
modified: new Date(result.lastmod),
etag: result.etag ?? undefined,
}
}
async read(path: string): Promise<Readable> {
const remotePath = this.resolvePath(path)
const stream = this.webdav.createReadStream(remotePath)
return Readable.from(stream)
}
async write(path: string, stream: Readable): Promise<void> {
const remotePath = this.resolvePath(path)
await this.webdav.putFileContents(remotePath, stream)
}
async mkdir(path: string): Promise<void> {
const remotePath = this.resolvePath(path)
await this.webdav.createDirectory(remotePath)
}
async delete(path: string): Promise<void> {
const remotePath = this.resolvePath(path)
await this.webdav.deleteFile(remotePath)
}
async rename(from: string, to: string): Promise<void> {
const remoteFrom = this.resolvePath(from)
const remoteTo = this.resolvePath(to)
await this.webdav.moveFile(remoteFrom, remoteTo)
}
}

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { BaseServer } from '../base.js'
import { MemoryCache } from '../../cache/memory.js'
import type { BaseClient } from '../../client/base.js'
import type { GenericNode } from '../../../types.js'
import { SyncWorker } from '../../../sync.js'
/** Suppress logger output during tests. */
vi.mock('../../../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
function makeNode(path: string, isDir = false): GenericNode {
const parts = path.split('/')
return {
path,
name: parts[parts.length - 1] || '/',
isDir,
size: isDir ? 0 : 1024,
modified: new Date('2025-01-01'),
etag: undefined,
}
}
/**
* Creates a mock BaseClient for testing server dispatch.
*/
function createMockClient(mountPath: string): BaseClient {
const cache = new MemoryCache()
const mockClient = {
mountPath,
cache,
sync: {
start: vi.fn(),
stop: vi.fn(),
forceSync: vi.fn().mockResolvedValue(undefined),
prioritise: vi.fn(),
waitForDrain: vi.fn().mockResolvedValue(undefined),
} as unknown as SyncWorker,
list: vi.fn(async () => []),
stat: vi.fn(async (path: string) => makeNode(path)),
read: vi.fn(),
write: vi.fn(),
mkdir: vi.fn(),
delete: vi.fn(),
rename: vi.fn(),
}
return mockClient as unknown as BaseClient
}
/**
* Concrete subclass of BaseServer for testing — exposes protected methods.
*/
class TestServer extends BaseServer {
async start() {}
async stop() {}
// Expose protected methods for testing
public testResolveClient(path: string) { return this.resolveClient(path) }
public testVirtualRoot() { return this.virtualRoot() }
public testHandleStat(path: string) { return this.handleStat(path) }
public testHandleList(path: string) { return this.handleList(path) }
}
describe('BaseServer', () => {
let server: TestServer
let homeClient: BaseClient
let storageClient: BaseClient
beforeEach(() => {
homeClient = createMockClient('/home')
storageClient = createMockClient('/storage')
server = new TestServer([homeClient, storageClient])
})
describe('resolveClient', () => {
it('should resolve paths under /home to the home client', () => {
const result = server.testResolveClient('/home/docs/file.txt')
expect(result).not.toBeNull()
expect(result!.client).toBe(homeClient)
expect(result!.remotePath).toBe('/docs/file.txt')
})
it('should resolve paths under /storage to the storage client', () => {
const result = server.testResolveClient('/storage/data/backup.zip')
expect(result).not.toBeNull()
expect(result!.client).toBe(storageClient)
expect(result!.remotePath).toBe('/data/backup.zip')
})
it('should resolve mount point itself to /', () => {
const result = server.testResolveClient('/home')
expect(result).not.toBeNull()
expect(result!.remotePath).toBe('/')
})
it('should return null for root path', () => {
expect(server.testResolveClient('/')).toBeNull()
})
it('should return null for unmatched paths', () => {
expect(server.testResolveClient('/unknown/path')).toBeNull()
})
})
describe('virtualRoot', () => {
it('should return one entry per mounted client', () => {
const entries = server.testVirtualRoot()
expect(entries).toHaveLength(2)
expect(entries[0].name).toBe('home')
expect(entries[0].isDir).toBe(true)
expect(entries[1].name).toBe('storage')
expect(entries[1].isDir).toBe(true)
})
})
describe('handleStat', () => {
it('should return a virtual node for root', async () => {
const node = await server.testHandleStat('/')
expect(node).not.toBeNull()
expect(node!.isDir).toBe(true)
expect(node!.path).toBe('/')
})
it('should return a synthetic directory node for mount-point paths', async () => {
const node = await server.testHandleStat('/home')
expect(node).not.toBeNull()
expect(node!.isDir).toBe(true)
expect(node!.name).toBe('home')
})
it('should return cached node if available', async () => {
const cached = makeNode('/docs/file.txt')
await homeClient.cache.set('/docs/file.txt', cached)
const node = await server.testHandleStat('/home/docs/file.txt')
expect(node).toEqual(cached)
})
it('should fall through to direct stat on cache miss', async () => {
const statNode = makeNode('/docs/file.txt')
;(homeClient.stat as ReturnType<typeof vi.fn>).mockResolvedValue(statNode)
const node = await server.testHandleStat('/home/docs/file.txt')
expect(node).toEqual(statNode)
expect(homeClient.stat).toHaveBeenCalledWith('/docs/file.txt')
})
it('should return null for unresolvable paths', async () => {
const node = await server.testHandleStat('/unknown/path')
expect(node).toBeNull()
})
it('should return null if stat throws', async () => {
;(homeClient.stat as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Not found'))
const node = await server.testHandleStat('/home/nonexistent')
expect(node).toBeNull()
})
})
describe('handleList', () => {
it('should return virtual root entries for /', async () => {
const entries = await server.testHandleList('/')
expect(entries).toHaveLength(2)
expect(entries.map(e => e.name)).toContain('home')
expect(entries.map(e => e.name)).toContain('storage')
})
it('should return cached children if available', async () => {
await homeClient.cache.set('/file1.txt', makeNode('/file1.txt'))
await homeClient.cache.set('/file2.txt', makeNode('/file2.txt'))
const entries = await server.testHandleList('/home')
expect(entries).toHaveLength(2)
})
it('should fall through to direct list on cache miss', async () => {
const remoteNodes = [makeNode('/a.txt'), makeNode('/b.txt')]
;(homeClient.list as ReturnType<typeof vi.fn>).mockResolvedValue(remoteNodes)
const entries = await server.testHandleList('/home')
expect(entries).toEqual(remoteNodes)
expect(homeClient.list).toHaveBeenCalledWith('/')
})
it('should return empty array for unresolvable paths', async () => {
const entries = await server.testHandleList('/unknown')
expect(entries).toHaveLength(0)
})
it('should return empty array if list throws', async () => {
;(homeClient.list as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Timeout'))
const entries = await server.testHandleList('/home')
expect(entries).toHaveLength(0)
})
})
})

196
src/adapters/server/base.ts Normal file
View File

@@ -0,0 +1,196 @@
import { posix } from 'node:path'
import type { GenericNode } from '../../types.js'
import type { BaseClient } from '../client/base.js'
import { logger } from '../../logger.js'
/** Result of resolving an incoming path to a specific client. */
export interface ResolvedPath {
client: BaseClient
remotePath: string
}
/**
* Filenames that macOS Finder and other OS-level clients probe for
* automatically. These never exist on remote storage and should be
* silently rejected to avoid log spam and unnecessary network requests.
*
* - `._*` — AppleDouble resource fork sidecar files
* - `.DS_Store` — Finder directory metadata
* - `.Spotlight-V100`, `.Trashes`, `.fseventsd` — Spotlight / Trash / FSEvents
* - `desktop.ini`, `Thumbs.db` — Windows Explorer metadata
*/
const IGNORED_BASENAMES_RE = /^\._|^\.DS_Store$|^\.Spotlight-V100$|^\.Trashes$|^\.fseventsd$|^desktop\.ini$|^Thumbs\.db$/
/** Returns true if the basename of a path matches a known OS metadata probe. */
export function isIgnoredPath(filePath: string): boolean {
const name = posix.basename(filePath)
return IGNORED_BASENAMES_RE.test(name)
}
/**
* Abstract base class for all protocol servers (SFTP, FTP, WebDAV).
*
* Servers are pure protocol translators — they dispatch requests to the correct
* client based on `mountPath`. They know nothing about remote protocols.
*/
export abstract class BaseServer {
protected clients: BaseClient[]
constructor(clients: BaseClient[]) {
this.clients = clients
}
/** Starts the protocol server. */
abstract start(): Promise<void>
/** Stops the protocol server. */
abstract stop(): Promise<void>
/**
* Resolves an incoming virtual path to the client that owns it and the
* path relative to that client's basePath.
*
* Example: `/home/docs/file.txt` with a client mounted at `/home`
* returns `{ client, remotePath: '/docs/file.txt' }`.
*
* Returns null for the virtual root (`/`) or paths that don't match any client.
*/
protected resolveClient(incomingPath: string): ResolvedPath | null {
const normalised = posix.normalize(incomingPath)
for (const client of this.clients) {
const mount = client.mountPath.replace(/\/+$/, '')
// Exact match on the mount point itself → the root of that client
if (normalised === mount) {
return { client, remotePath: '/' }
}
// Path is under this mount point
if (normalised.startsWith(mount + '/')) {
const remotePath = normalised.slice(mount.length) || '/'
return { client, remotePath }
}
}
return null
}
/**
* Returns synthetic GenericNode entries for the virtual root.
* One directory entry per mounted client.
*/
protected virtualRoot(): GenericNode[] {
return this.clients.map(client => {
const mountName = posix.basename(client.mountPath)
return {
path: client.mountPath,
name: mountName,
isDir: true,
size: 0,
modified: new Date(),
etag: undefined,
}
})
}
/**
* Handles a stat request with cache-first strategy.
* 1. Check client.cache — if hit, return immediately
* 2. If miss, prioritise the parent dir for sync and try stat directly
* 3. Never block indefinitely — return null if all else fails
*/
protected async handleStat(incomingPath: string): Promise<GenericNode | null> {
const resolved = this.resolveClient(incomingPath)
// Virtual root stat
if (!resolved) {
if (incomingPath === '/') {
return {
path: '/',
name: '/',
isDir: true,
size: 0,
modified: new Date(),
}
}
return null
}
const { client, remotePath } = resolved
// Silently ignore OS metadata probes (._files, .DS_Store, etc.)
if (isIgnoredPath(remotePath)) {
return null
}
// Mount-point root — always a synthetic directory (the client's basePath root)
if (remotePath === '/') {
const mountName = posix.basename(client.mountPath)
return {
path: client.mountPath,
name: mountName,
isDir: true,
size: 0,
modified: new Date(),
}
}
// Try cache first
const cached = await client.cache.get(remotePath)
if (cached) return cached
// Prioritise the parent directory for sync
const parentDir = posix.dirname(remotePath)
client.sync.prioritise(parentDir)
// Fall through to direct stat
try {
const node = await client.stat(remotePath)
await client.cache.set(remotePath, node)
return node
} catch (err) {
logger.error(`stat failed for ${incomingPath}: ${(err as Error).message}`)
return null
}
}
/**
* Handles a list request with cache-first strategy.
* 1. Check client.cache.children — if non-empty, return immediately
* 2. If miss, prioritise the path for sync and try list directly
* 3. Never block indefinitely — return empty array if all else fails
*/
protected async handleList(incomingPath: string): Promise<GenericNode[]> {
const resolved = this.resolveClient(incomingPath)
// Virtual root listing
if (!resolved) {
if (incomingPath === '/') {
return this.virtualRoot()
}
return []
}
const { client, remotePath } = resolved
// Try cache first
const cached = await client.cache.children(remotePath)
if (cached.length > 0) return cached
// Prioritise this path for sync
client.sync.prioritise(remotePath)
// Fall through to direct list
try {
const nodes = await client.list(remotePath)
for (const node of nodes) {
await client.cache.set(node.path, node)
}
return nodes
} catch (err) {
logger.error(`list failed for ${incomingPath}: ${(err as Error).message}`)
return []
}
}
}

301
src/adapters/server/ftp.ts Normal file
View File

@@ -0,0 +1,301 @@
import { posix } from 'node:path'
import { Readable, PassThrough } from 'node:stream'
import FtpSrvDefault from 'ftp-srv'
import type { FtpConnection } from 'ftp-srv'
import type { BaseClient } from '../client/base.js'
import { BaseServer, isIgnoredPath } from './base.js'
import { logger } from '../../logger.js'
import type { GenericNode } from '../../types.js'
// ftp-srv is CJS — handle both default export shapes
const FtpSrv = (FtpSrvDefault as any).default ?? FtpSrvDefault
/** Configuration options specific to the FTP server. */
export interface FTPServerOptions {
port: number
pasv_url?: string
pasv_min?: number
pasv_max?: number
}
/**
* FTP protocol server implemented via ftp-srv.
*
* Creates a virtual filesystem per connection that delegates all operations
* to BaseServer's dispatch helpers.
*/
export class FTPServer extends BaseServer {
private ftpServer: InstanceType<typeof FtpSrv> | null = null
private options: FTPServerOptions
private username: string
private password: string
constructor(
clients: BaseClient[],
options: FTPServerOptions,
credentials: { username: string; password: string },
) {
super(clients)
this.options = options
this.username = credentials.username
this.password = credentials.password
}
async start(): Promise<void> {
this.ftpServer = new FtpSrv({
url: `ftp://0.0.0.0:${this.options.port}`,
pasv_url: this.options.pasv_url ?? '127.0.0.1',
pasv_min: this.options.pasv_min ?? 1024,
pasv_max: this.options.pasv_max ?? 65535,
anonymous: false,
})
this.ftpServer.on('login', (
data: { connection: FtpConnection; username: string; password: string },
resolve: (config: { fs: any }) => void,
reject: (err: Error) => void,
) => {
if (data.username === this.username && data.password === this.password) {
logger.info(`FTP client authenticated: ${data.connection.ip}`)
const fs = new VirtualFileSystem(data.connection, this)
resolve({ fs })
} else {
reject(new Error('Invalid credentials'))
}
})
this.ftpServer.on('client-error', (data: { connection: FtpConnection; error: Error }) => {
logger.error(`FTP client error (${data.connection.ip}): ${data.error.message}`)
})
await this.ftpServer.listen()
logger.info(`FTP server listening on port ${this.options.port}`)
}
async stop(): Promise<void> {
if (this.ftpServer) {
await this.ftpServer.close()
this.ftpServer = null
}
}
}
/**
* Virtual filesystem that ftp-srv uses for file operations.
*
* Overrides every method from ftp-srv's built-in FileSystem to prevent
* real filesystem access. All operations delegate to BaseServer's dispatch helpers.
*/
class VirtualFileSystem {
/** Current working directory in the virtual filesystem. */
cwd: string = '/'
/** Required by ftp-srv but not used — we don't touch the real filesystem. */
readonly root: string = '/'
private connection: FtpConnection
private server: FTPServer
constructor(connection: FtpConnection, server: FTPServer) {
this.connection = connection
this.server = server
}
currentDirectory(): string {
return this.cwd
}
/**
* Resolves a potentially relative FTP path into an absolute virtual path.
*/
private resolveFtpPath(path: string = '.'): string {
if (path === '.') return this.cwd
if (posix.isAbsolute(path)) return posix.normalize(path)
return posix.normalize(posix.join(this.cwd, path))
}
/**
* Returns stat-like info for a single path.
* ftp-srv uses this for SIZE, MDTM, and pre-transfer checks.
*/
async get(fileName: string): Promise<FtpStatResult> {
const fullPath = this.resolveFtpPath(fileName)
const node = await (this.server as any).handleStat(fullPath)
if (!node) {
throw new Error(`Not found: ${fullPath}`)
}
return nodeToFtpStat(node)
}
/** Lists directory contents. Returns stat-like objects with a `.name` property. */
async list(path: string = '.'): Promise<FtpStatResult[]> {
const fullPath = this.resolveFtpPath(path)
const nodes = await (this.server as any).handleList(fullPath)
return nodes.map(nodeToFtpStat)
}
/** Changes the current working directory. Returns the new path. */
async chdir(path: string = '.'): Promise<string> {
const fullPath = this.resolveFtpPath(path)
// Verify the directory exists
if (fullPath === '/') {
this.cwd = '/'
return '/'
}
const node = await (this.server as any).handleStat(fullPath)
if (!node || !node.isDir) {
throw new Error(`Not a valid directory: ${fullPath}`)
}
this.cwd = fullPath
return this.cwd
}
/**
* Returns a writable stream for uploading a file.
* ftp-srv pipes the incoming data into this stream.
*/
write(fileName: string, _options?: { append?: boolean; start?: number }): { stream: PassThrough; clientPath: string } {
const fullPath = this.resolveFtpPath(fileName)
if (isIgnoredPath(fullPath)) {
throw new Error(`Blocked: OS metadata file: ${fullPath}`)
}
const resolved = (this.server as any).resolveClient(fullPath)
if (!resolved) {
throw new Error(`Cannot write to: ${fullPath}`)
}
const { client, remotePath } = resolved
const passthrough = new PassThrough()
// Pipe the incoming data to the remote client, then sync
const writePromise = client.write(remotePath, passthrough)
.then(() => client.sync.forceSync(posix.dirname(remotePath)))
.catch((err: Error) => logger.error(`FTP write error for ${fullPath}: ${err.message}`))
return {
stream: passthrough,
clientPath: fullPath,
}
}
/**
* Returns a readable stream for downloading a file.
* ftp-srv pipes this stream to the client.
*/
async read(fileName: string, _options?: { start?: number }): Promise<{ stream: Readable; clientPath: string }> {
const fullPath = this.resolveFtpPath(fileName)
const resolved = (this.server as any).resolveClient(fullPath)
if (!resolved) {
throw new Error(`Cannot read: ${fullPath}`)
}
const { client, remotePath } = resolved
const stream = await client.read(remotePath)
return {
stream,
clientPath: fullPath,
}
}
/** Deletes a file or directory. */
async delete(path: string): Promise<void> {
const fullPath = this.resolveFtpPath(path)
const resolved = (this.server as any).resolveClient(fullPath)
if (!resolved) {
throw new Error(`Cannot delete: ${fullPath}`)
}
const { client, remotePath } = resolved
await client.delete(remotePath)
await client.sync.forceSync(posix.dirname(remotePath))
}
/** Creates a directory. Returns the new path. */
async mkdir(path: string): Promise<string> {
const fullPath = this.resolveFtpPath(path)
if (isIgnoredPath(fullPath)) {
throw new Error(`Blocked: OS metadata directory: ${fullPath}`)
}
const resolved = (this.server as any).resolveClient(fullPath)
if (!resolved) {
throw new Error(`Cannot mkdir: ${fullPath}`)
}
const { client, remotePath } = resolved
await client.mkdir(remotePath)
await client.sync.forceSync(posix.dirname(remotePath))
return fullPath
}
/** Renames/moves a file or directory. */
async rename(from: string, to: string): Promise<void> {
const fromPath = this.resolveFtpPath(from)
const toPath = this.resolveFtpPath(to)
const resolvedFrom = (this.server as any).resolveClient(fromPath)
const resolvedTo = (this.server as any).resolveClient(toPath)
if (!resolvedFrom || !resolvedTo) {
throw new Error(`Cannot rename: ${fromPath} -> ${toPath}`)
}
if (resolvedFrom.client !== resolvedTo.client) {
throw new Error('Cannot rename across mount points')
}
await resolvedFrom.client.rename(resolvedFrom.remotePath, resolvedTo.remotePath)
await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedFrom.remotePath))
await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedTo.remotePath))
}
/** No-op — we don't support chmod on virtual files. */
async chmod(_path: string, _mode: string): Promise<void> {
// silently succeed
}
/** Generates a unique filename for uploads. */
getUniqueName(_fileName: string): string {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Shape that ftp-srv expects from stat/list results. */
interface FtpStatResult {
name: string
size: number
mtime: Date
isDirectory: () => boolean
mode: number
uid: number
gid: number
}
/** Converts a GenericNode to the stat-like shape ftp-srv expects. */
function nodeToFtpStat(node: GenericNode): FtpStatResult {
return {
name: node.name,
size: node.size,
mtime: node.modified,
isDirectory: () => node.isDir,
mode: node.isDir ? 0o40755 : 0o100644,
uid: 0,
gid: 0,
}
}

View File

@@ -0,0 +1,48 @@
import type { BaseClient } from '../client/base.js'
import { BaseServer } from './base.js'
/**
* NFS protocol server — not yet implemented.
*
* NFS (Network File System) operates over ONC RPC with XDR encoding.
* A working implementation requires:
*
* 1. **Portmapper** (port 111) — maps RPC program numbers to ports.
* 2. **Mount daemon** — handles NFS mount/unmount requests and exports.
* 3. **NFS daemon** — implements the NFS v3 or v4 protocol:
* - GETATTR / SETATTR — file attributes
* - LOOKUP / READDIR / READDIRPLUS — directory traversal
* - READ / WRITE — file I/O
* - CREATE / REMOVE / RENAME / MKDIR / RMDIR — mutations
* - FSSTAT / FSINFO / PATHCONF — filesystem metadata
*
* There is no production-ready NFS server library for Node.js.
* Implementing this from scratch would require:
* - An ONC RPC framing layer over TCP/UDP
* - XDR serialisation/deserialisation for all NFS data types
* - Proper filehandle generation and management
* - NFS v3 or v4 state machine compliance
*
* For macOS/Linux file sharing, consider using WebDAV or SFTP instead —
* both are fully supported by this tool and integrate with native file managers.
*
* If NFS support is critical, a viable alternative is running a lightweight
* NFS daemon (e.g. nfs-ganesha or unfs3) and using a FUSE adapter to bridge
* our virtual filesystem to the kernel VFS layer.
*/
export class NFSServer extends BaseServer {
constructor(clients: BaseClient[]) {
super(clients)
}
async start(): Promise<void> {
throw new Error(
'NFS server is not implemented. NFS requires ONC RPC with XDR encoding, ' +
'which has no viable Node.js library. Use SFTP or WebDAV instead.'
)
}
async stop(): Promise<void> {
throw new Error('Not implemented')
}
}

457
src/adapters/server/sftp.ts Normal file
View File

@@ -0,0 +1,457 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { join, dirname, posix } from 'node:path'
import { homedir } from 'node:os'
import { Readable } from 'node:stream'
import { generateKeyPairSync } from 'node:crypto'
import ssh2 from 'ssh2'
const { Server: SSHServer, utils: sshUtils } = ssh2
type Connection = ssh2.Connection
type Session = ssh2.Session
/** SFTP status codes from ssh2's utils namespace. */
const SFTP_STATUS_CODE = sshUtils.sftp.STATUS_CODE
/** SFTP open mode flags from ssh2's utils namespace. */
const SFTP_OPEN_MODE = sshUtils.sftp.OPEN_MODE
import type { BaseClient } from '../client/base.js'
import { BaseServer, isIgnoredPath } from './base.js'
import { logger } from '../../logger.js'
const DEFAULT_CONFIG_DIR = join(homedir(), '.config', 'sftp-proxy')
const DEFAULT_HOST_KEY_PATH = join(DEFAULT_CONFIG_DIR, 'host.key')
/**
* Generates an RSA host key and saves it to disk, or loads it if it already exists.
* @param keyPath - Path to the host key file. Defaults to ~/.config/sftp-proxy/host.key.
*/
function getOrCreateHostKey(keyPath: string = DEFAULT_HOST_KEY_PATH): string {
if (existsSync(keyPath)) {
return readFileSync(keyPath, 'utf-8')
}
mkdirSync(dirname(keyPath), { recursive: true })
const { privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
})
writeFileSync(keyPath, privateKey, { mode: 0o600 })
logger.info(`Generated new host key at ${keyPath}`)
return privateKey
}
/** Tracks open file handles for read/write operations. */
interface OpenHandle {
path: string
flags: number
/** Buffer accumulating write data (for write operations). */
writeBuffer?: Buffer[]
/** Readable stream for read operations. */
readStream?: Readable
/** Buffer of data read from the stream. */
readBuffer?: Buffer
/** Whether we've fully consumed the read stream into readBuffer. */
readComplete?: boolean
}
/**
* SFTP protocol server implemented via ssh2.
*
* Auto-generates an RSA host key on first run.
* Authenticates against the credentials from config.
* Translates all SFTP operations into calls to the base server dispatch helpers.
*/
export class SFTPServer extends BaseServer {
private server: InstanceType<typeof SSHServer> | null = null
private port: number
private username: string
private password: string
private hostKeyPath: string
constructor(
clients: BaseClient[],
port: number,
credentials: { username: string; password: string },
hostKeyPath?: string,
) {
super(clients)
this.port = port
this.username = credentials.username
this.password = credentials.password
this.hostKeyPath = hostKeyPath ?? DEFAULT_HOST_KEY_PATH
}
async start(): Promise<void> {
const hostKey = getOrCreateHostKey(this.hostKeyPath)
this.server = new SSHServer({ hostKeys: [hostKey] }, (client) => {
this.handleConnection(client)
})
return new Promise<void>((resolve, reject) => {
this.server!.on('error', reject)
this.server!.listen(this.port, '0.0.0.0', () => {
logger.info(`SFTP server listening on port ${this.port}`)
resolve()
})
})
}
async stop(): Promise<void> {
return new Promise<void>((resolve) => {
if (this.server) {
this.server.close(() => resolve())
} else {
resolve()
}
})
}
/** Handles a new SSH connection — authenticates and sets up the SFTP session. */
private handleConnection(client: Connection): void {
logger.info('New SSH connection')
client.on('authentication', (ctx) => {
if (
ctx.method === 'password' &&
ctx.username === this.username &&
ctx.password === this.password
) {
ctx.accept()
} else {
ctx.reject(['password'])
}
})
client.on('ready', () => {
logger.info('Client authenticated')
client.on('session', (accept) => {
const session: Session = accept()
session.on('sftp', (accept) => {
const sftpStream = accept()
this.handleSFTPSession(sftpStream)
})
})
})
client.on('error', (err) => {
logger.error(`SSH client error: ${err.message}`)
})
}
/**
* Handles all SFTP operations on a session.
* Maps SFTP protocol operations to GenericRequest dispatch.
*/
private handleSFTPSession(sftp: any): void {
let handleCounter = 0
const openHandles = new Map<number, OpenHandle>()
/** Creates a new handle ID and stores metadata. */
const allocHandle = (info: OpenHandle): Buffer => {
const id = handleCounter++
openHandles.set(id, info)
const buf = Buffer.alloc(4)
buf.writeUInt32BE(id)
return buf
}
/** Looks up a handle from its buffer representation. */
const getHandle = (handleBuf: Buffer): OpenHandle | undefined => {
const id = handleBuf.readUInt32BE(0)
return openHandles.get(id)
}
/** Closes and removes a handle. */
const closeHandle = (handleBuf: Buffer): boolean => {
const id = handleBuf.readUInt32BE(0)
return openHandles.delete(id)
}
/** Converts a GenericNode to the attrs format expected by SFTP. */
const nodeToAttrs = (node: { isDir: boolean; size: number; modified: Date }) => ({
mode: node.isDir ? 0o40755 : 0o100644,
uid: 0,
gid: 0,
size: node.size,
atime: Math.floor(node.modified.getTime() / 1000),
mtime: Math.floor(node.modified.getTime() / 1000),
})
// --- OPEN ---
sftp.on('OPEN', async (reqid: number, filename: string, flags: number) => {
try {
// Block writes for OS metadata files (.DS_Store, ._ files, etc.)
if ((flags & SFTP_OPEN_MODE.WRITE) && isIgnoredPath(filename)) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
const resolved = this.resolveClient(filename)
if (!resolved) {
return sftp.status(reqid, SFTP_STATUS_CODE.NO_SUCH_FILE)
}
const handle = allocHandle({ path: filename, flags })
// If reading, pre-fetch the entire file content into a buffer
if (flags & SFTP_OPEN_MODE.READ) {
const stream = await resolved.client.read(resolved.remotePath)
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const handleInfo = openHandles.get(handle.readUInt32BE(0))!
handleInfo.readBuffer = Buffer.concat(chunks)
handleInfo.readComplete = true
}
// If writing, initialise the write buffer
if (flags & SFTP_OPEN_MODE.WRITE) {
const handleInfo = openHandles.get(handle.readUInt32BE(0))!
handleInfo.writeBuffer = []
}
sftp.handle(reqid, handle)
} catch (err) {
logger.error(`OPEN error for ${filename}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- READ ---
sftp.on('READ', (reqid: number, handle: Buffer, offset: number, length: number) => {
const info = getHandle(handle)
if (!info || !info.readBuffer) {
return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
if (offset >= info.readBuffer.length) {
return sftp.status(reqid, SFTP_STATUS_CODE.EOF)
}
const end = Math.min(offset + length, info.readBuffer.length)
const data = info.readBuffer.subarray(offset, end)
sftp.data(reqid, data)
})
// --- WRITE ---
sftp.on('WRITE', (reqid: number, handle: Buffer, offset: number, data: Buffer) => {
const info = getHandle(handle)
if (!info || !info.writeBuffer) {
return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
info.writeBuffer.push(Buffer.from(data))
sftp.status(reqid, SFTP_STATUS_CODE.OK)
})
// --- CLOSE ---
sftp.on('CLOSE', async (reqid: number, handle: Buffer) => {
const info = getHandle(handle)
if (!info) {
return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
try {
// If there's pending write data, flush it to the remote
if (info.writeBuffer && info.writeBuffer.length > 0) {
const resolved = this.resolveClient(info.path)
if (resolved) {
const buf = Buffer.concat(info.writeBuffer)
const stream = Readable.from(buf)
await resolved.client.write(resolved.remotePath, stream)
await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath))
}
}
closeHandle(handle)
sftp.status(reqid, SFTP_STATUS_CODE.OK)
} catch (err) {
logger.error(`CLOSE error for ${info.path}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- OPENDIR ---
sftp.on('OPENDIR', async (reqid: number, path: string) => {
try {
const entries = await this.handleList(path)
const handle = allocHandle({ path, flags: 0 })
const handleInfo = openHandles.get(handle.readUInt32BE(0))!
;(handleInfo as any).dirEntries = entries
;(handleInfo as any).dirRead = false
sftp.handle(reqid, handle)
} catch (err) {
logger.error(`OPENDIR error for ${path}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- READDIR ---
sftp.on('READDIR', (reqid: number, handle: Buffer) => {
const info = getHandle(handle) as any
if (!info || !info.dirEntries) {
return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
// First call returns all entries; subsequent calls return EOF
if (info.dirRead) {
return sftp.status(reqid, SFTP_STATUS_CODE.EOF)
}
info.dirRead = true
const entries = (info.dirEntries as import('../../types.js').GenericNode[])
if (entries.length === 0) {
return sftp.status(reqid, SFTP_STATUS_CODE.EOF)
}
const names = entries.map(node => ({
filename: node.name,
longname: formatLongname(node),
attrs: nodeToAttrs(node),
}))
sftp.name(reqid, names)
})
// --- STAT / LSTAT / FSTAT ---
const handleStatRequest = async (reqid: number, path: string) => {
try {
const node = await this.handleStat(path)
if (!node) {
return sftp.status(reqid, SFTP_STATUS_CODE.NO_SUCH_FILE)
}
sftp.attrs(reqid, nodeToAttrs(node))
} catch (err) {
logger.error(`STAT error for ${path}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
}
sftp.on('STAT', (reqid: number, path: string) => handleStatRequest(reqid, path))
sftp.on('LSTAT', (reqid: number, path: string) => handleStatRequest(reqid, path))
sftp.on('FSTAT', (reqid: number, handle: Buffer) => {
const info = getHandle(handle)
if (!info) {
return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
handleStatRequest(reqid, info.path)
})
// --- MKDIR ---
sftp.on('MKDIR', async (reqid: number, path: string) => {
try {
if (isIgnoredPath(path)) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
const resolved = this.resolveClient(path)
if (!resolved) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
await resolved.client.mkdir(resolved.remotePath)
await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath))
sftp.status(reqid, SFTP_STATUS_CODE.OK)
} catch (err) {
logger.error(`MKDIR error for ${path}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- RMDIR ---
sftp.on('RMDIR', async (reqid: number, path: string) => {
try {
const resolved = this.resolveClient(path)
if (!resolved) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
await resolved.client.delete(resolved.remotePath)
await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath))
sftp.status(reqid, SFTP_STATUS_CODE.OK)
} catch (err) {
logger.error(`RMDIR error for ${path}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- REMOVE ---
sftp.on('REMOVE', async (reqid: number, path: string) => {
try {
const resolved = this.resolveClient(path)
if (!resolved) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
await resolved.client.delete(resolved.remotePath)
await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath))
sftp.status(reqid, SFTP_STATUS_CODE.OK)
} catch (err) {
logger.error(`REMOVE error for ${path}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- RENAME ---
sftp.on('RENAME', async (reqid: number, oldPath: string, newPath: string) => {
try {
const resolvedOld = this.resolveClient(oldPath)
const resolvedNew = this.resolveClient(newPath)
if (!resolvedOld || !resolvedNew) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
// Both paths must resolve to the same client
if (resolvedOld.client !== resolvedNew.client) {
return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED)
}
await resolvedOld.client.rename(resolvedOld.remotePath, resolvedNew.remotePath)
await resolvedOld.client.sync.forceSync(posix.dirname(resolvedOld.remotePath))
await resolvedOld.client.sync.forceSync(posix.dirname(resolvedNew.remotePath))
sftp.status(reqid, SFTP_STATUS_CODE.OK)
} catch (err) {
logger.error(`RENAME error ${oldPath} -> ${newPath}: ${(err as Error).message}`)
sftp.status(reqid, SFTP_STATUS_CODE.FAILURE)
}
})
// --- REALPATH ---
sftp.on('REALPATH', (reqid: number, path: string) => {
const normalised = posix.resolve('/', path)
sftp.name(reqid, [{
filename: normalised,
longname: normalised,
attrs: {
mode: 0o40755,
uid: 0,
gid: 0,
size: 0,
atime: Math.floor(Date.now() / 1000),
mtime: Math.floor(Date.now() / 1000),
},
}])
})
// --- SETSTAT / FSETSTAT (no-op, accept gracefully) ---
sftp.on('SETSTAT', (reqid: number) => {
sftp.status(reqid, SFTP_STATUS_CODE.OK)
})
sftp.on('FSETSTAT', (reqid: number) => {
sftp.status(reqid, SFTP_STATUS_CODE.OK)
})
}
}
/**
* Formats a GenericNode as a UNIX-style long filename string
* (similar to `ls -l` output) for READDIR responses.
*/
function formatLongname(node: import('../../types.js').GenericNode): string {
const perms = node.isDir ? 'drwxr-xr-x' : '-rw-r--r--'
const size = String(node.size).padStart(13)
const date = node.modified.toISOString().slice(0, 10)
return `${perms} 1 owner group ${size} ${date} ${node.name}`
}

View File

@@ -0,0 +1,54 @@
import type { BaseClient } from '../client/base.js'
import { BaseServer } from './base.js'
/**
* SMB protocol server — not yet implemented.
*
* SMB (Server Message Block) is the native file-sharing protocol for Windows.
* A working implementation requires the full SMB2/3 protocol stack:
*
* 1. **Negotiate** — protocol version and capability negotiation
* 2. **Session Setup** — NTLM/Kerberos authentication
* 3. **Tree Connect** — share mounting
* 4. **File operations**:
* - CREATE / CLOSE — open/close file handles
* - READ / WRITE — file I/O with credit-based flow control
* - QUERY_DIRECTORY — directory listing
* - QUERY_INFO / SET_INFO — stat and attribute management
* - CHANGE_NOTIFY — filesystem watch notifications
* 5. **Oplock/Lease management** — caching and lock semantics
*
* There is no production-ready SMB server library for Node.js.
* Implementing this from scratch would require:
* - SMB2/3 packet framing and parsing over TCP (port 445)
* - NTLM or SPNEGO/Kerberos authentication
* - Credit-based flow control for SMB3
* - Compound request handling
* - Oplock/lease state machines
*
* For Windows file sharing, consider:
* - **WebDAV** — Windows Explorer supports `\\server@port\DavWWWRoot` paths
* - **SFTP** — via WinSCP, FileZilla, or Windows OpenSSH
* - Running Samba natively and bridging with a FUSE adapter
*
* If SMB support is critical, the recommended approach is to run Samba
* alongside this tool and use a FUSE mount as the bridge between our
* virtual filesystem and Samba's VFS layer.
*/
export class SMBServer extends BaseServer {
constructor(clients: BaseClient[]) {
super(clients)
}
async start(): Promise<void> {
throw new Error(
'SMB server is not implemented. SMB requires the full SMB2/3 protocol stack ' +
'including NTLM authentication, which has no viable Node.js library. ' +
'Use WebDAV (Windows Explorer supports it natively) or SFTP instead.'
)
}
async stop(): Promise<void> {
throw new Error('Not implemented')
}
}

View File

@@ -0,0 +1,497 @@
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
import { posix } from 'node:path'
import { Readable } from 'node:stream'
import type { BaseClient } from '../client/base.js'
import { BaseServer, isIgnoredPath } from './base.js'
import { logger } from '../../logger.js'
import type { GenericNode } from '../../types.js'
/**
* WebDAV protocol server implemented over Node's built-in http module.
*
* Supports the core WebDAV methods required by macOS Finder, Windows Explorer,
* and Linux GVFS/davfs2:
* OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, LOCK, UNLOCK
*
* Authentication uses HTTP Basic Auth against config credentials.
*/
export class WebDAVServer extends BaseServer {
private httpServer: Server | null = null
private port: number
private username: string
private password: string
constructor(
clients: BaseClient[],
port: number,
credentials: { username: string; password: string },
) {
super(clients)
this.port = port
this.username = credentials.username
this.password = credentials.password
}
async start(): Promise<void> {
this.httpServer = createServer((req, res) => {
this.handleRequest(req, res).catch((err) => {
logger.error(`WebDAV unhandled error: ${(err as Error).message}`)
if (!res.headersSent) {
res.writeHead(500)
res.end()
}
})
})
return new Promise<void>((resolve, reject) => {
this.httpServer!.on('error', reject)
this.httpServer!.listen(this.port, '0.0.0.0', () => {
logger.info(`WebDAV server listening on port ${this.port}`)
resolve()
})
})
}
async stop(): Promise<void> {
return new Promise<void>((resolve) => {
if (this.httpServer) {
this.httpServer.close(() => resolve())
} else {
resolve()
}
})
}
/** Top-level request router. Authenticates first, then dispatches by method. */
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
// CORS preflight and OPTIONS don't require auth in some clients
if (req.method === 'OPTIONS') {
return this.handleOptions(req, res)
}
if (!this.authenticate(req)) {
res.writeHead(401, {
'WWW-Authenticate': 'Basic realm="sftp-proxy"',
'Content-Type': 'text/plain',
})
res.end('Authentication required')
return
}
const method = req.method?.toUpperCase()
switch (method) {
case 'PROPFIND': return this.handlePropfind(req, res)
case 'GET': return this.handleGet(req, res)
case 'HEAD': return this.handleHead(req, res)
case 'PUT': return this.handlePut(req, res)
case 'DELETE': return this.handleDelete(req, res)
case 'MKCOL': return this.handleMkcol(req, res)
case 'MOVE': return this.handleMove(req, res)
case 'LOCK': return this.handleLock(req, res)
case 'UNLOCK': return this.handleUnlock(req, res)
default:
res.writeHead(405, { Allow: ALLOWED_METHODS })
res.end()
}
}
/** Validates HTTP Basic Auth credentials. */
private authenticate(req: IncomingMessage): boolean {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Basic ')) return false
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8')
const [username, password] = decoded.split(':')
return username === this.username && password === this.password
}
// ---------------------------------------------------------------------------
// Method handlers
// ---------------------------------------------------------------------------
private handleOptions(_req: IncomingMessage, res: ServerResponse): void {
res.writeHead(200, {
Allow: ALLOWED_METHODS,
DAV: '1, 2',
'Content-Length': '0',
})
res.end()
}
private async handlePropfind(req: IncomingMessage, res: ServerResponse): Promise<void> {
const path = decodePath(req.url ?? '/')
const depth = req.headers['depth'] ?? '1'
// We don't support Depth: infinity — return 403 per spec
if (depth === 'infinity') {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Depth: infinity is not supported')
return
}
const isRoot = path === '/'
const resolved = this.resolveClient(path)
// Build the list of entries to include in the multistatus response
const entries: Array<{ node: GenericNode; href: string }> = []
if (isRoot) {
// Stat for root itself
entries.push({
node: { path: '/', name: '/', isDir: true, size: 0, modified: new Date() },
href: '/',
})
// Depth: 1 — include virtual mount directories
if (depth === '1') {
for (const node of this.virtualRoot()) {
entries.push({ node, href: ensureTrailingSlash(node.path) })
}
}
} else if (resolved) {
const { client, remotePath } = resolved
// Stat the resource itself
const stat = await this.handleStat(path)
if (!stat) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
return
}
entries.push({
node: stat,
href: stat.isDir ? ensureTrailingSlash(path) : path,
})
// Depth: 1 — include children
if (depth === '1' && stat.isDir) {
const children = await this.handleList(path)
for (const child of children) {
const childPath = posix.join(path, child.name)
entries.push({
node: child,
href: child.isDir ? ensureTrailingSlash(childPath) : childPath,
})
}
}
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
return
}
const xml = buildMultistatusXml(entries)
res.writeHead(207, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Buffer.byteLength(xml),
})
res.end(xml)
}
private async handleGet(req: IncomingMessage, res: ServerResponse): Promise<void> {
const path = decodePath(req.url ?? '/')
const resolved = this.resolveClient(path)
if (!resolved) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
return
}
const { client, remotePath } = resolved
try {
const stat = await this.handleStat(path)
if (!stat) {
res.writeHead(404)
res.end('Not Found')
return
}
if (stat.isDir) {
res.writeHead(405, { 'Content-Type': 'text/plain' })
res.end('Cannot GET a directory. Use PROPFIND.')
return
}
const stream = await client.read(remotePath)
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Last-Modified': stat.modified.toUTCString(),
...(stat.etag ? { ETag: `"${stat.etag}"` } : {}),
})
stream.pipe(res)
} catch (err) {
logger.error(`WebDAV GET error for ${path}: ${(err as Error).message}`)
if (!res.headersSent) {
res.writeHead(500)
res.end()
}
}
}
private async handleHead(req: IncomingMessage, res: ServerResponse): Promise<void> {
const path = decodePath(req.url ?? '/')
const stat = await this.handleStat(path)
if (!stat) {
res.writeHead(404)
res.end()
return
}
res.writeHead(200, {
'Content-Type': stat.isDir ? 'httpd/unix-directory' : 'application/octet-stream',
'Content-Length': stat.size,
'Last-Modified': stat.modified.toUTCString(),
...(stat.etag ? { ETag: `"${stat.etag}"` } : {}),
})
res.end()
}
private async handlePut(req: IncomingMessage, res: ServerResponse): Promise<void> {
const path = decodePath(req.url ?? '/')
if (isIgnoredPath(path)) {
req.resume()
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Blocked: OS metadata file')
return
}
const resolved = this.resolveClient(path)
if (!resolved) {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Cannot write to virtual root')
return
}
const { client, remotePath } = resolved
try {
await client.write(remotePath, req as unknown as Readable)
await client.sync.forceSync(posix.dirname(remotePath))
res.writeHead(201)
res.end()
} catch (err) {
logger.error(`WebDAV PUT error for ${path}: ${(err as Error).message}`)
res.writeHead(500)
res.end()
}
}
private async handleDelete(req: IncomingMessage, res: ServerResponse): Promise<void> {
const path = decodePath(req.url ?? '/')
const resolved = this.resolveClient(path)
if (!resolved) {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Cannot delete from virtual root')
return
}
const { client, remotePath } = resolved
try {
await client.delete(remotePath)
await client.sync.forceSync(posix.dirname(remotePath))
res.writeHead(204)
res.end()
} catch (err) {
logger.error(`WebDAV DELETE error for ${path}: ${(err as Error).message}`)
res.writeHead(500)
res.end()
}
}
private async handleMkcol(req: IncomingMessage, res: ServerResponse): Promise<void> {
const path = decodePath(req.url ?? '/')
if (isIgnoredPath(path)) {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Blocked: OS metadata directory')
return
}
const resolved = this.resolveClient(path)
if (!resolved) {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Cannot create directory in virtual root')
return
}
const { client, remotePath } = resolved
try {
await client.mkdir(remotePath)
await client.sync.forceSync(posix.dirname(remotePath))
res.writeHead(201)
res.end()
} catch (err) {
logger.error(`WebDAV MKCOL error for ${path}: ${(err as Error).message}`)
res.writeHead(500)
res.end()
}
}
private async handleMove(req: IncomingMessage, res: ServerResponse): Promise<void> {
const fromPath = decodePath(req.url ?? '/')
const destinationHeader = req.headers['destination'] as string | undefined
if (!destinationHeader) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end('Missing Destination header')
return
}
// Destination is a full URI — extract just the path
let toPath: string
try {
const url = new URL(destinationHeader, `http://${req.headers.host}`)
toPath = decodePath(url.pathname)
} catch {
toPath = decodePath(destinationHeader)
}
const resolvedFrom = this.resolveClient(fromPath)
const resolvedTo = this.resolveClient(toPath)
if (!resolvedFrom || !resolvedTo) {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Cannot move across virtual root boundaries')
return
}
if (resolvedFrom.client !== resolvedTo.client) {
res.writeHead(403, { 'Content-Type': 'text/plain' })
res.end('Cannot move across different mount points')
return
}
try {
await resolvedFrom.client.rename(resolvedFrom.remotePath, resolvedTo.remotePath)
await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedFrom.remotePath))
await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedTo.remotePath))
res.writeHead(201)
res.end()
} catch (err) {
logger.error(`WebDAV MOVE error ${fromPath} -> ${toPath}: ${(err as Error).message}`)
res.writeHead(500)
res.end()
}
}
/**
* Handles LOCK requests with a fake lock token.
* macOS Finder requires LOCK support for write operations.
* We always succeed — this proxy doesn't enforce real locking.
*/
private handleLock(req: IncomingMessage, res: ServerResponse): void {
const path = decodePath(req.url ?? '/')
const token = `opaquelocktoken:${Date.now()}-${Math.random().toString(36).slice(2)}`
const xml = `<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:">
<D:lockdiscovery>
<D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:exclusive/></D:lockscope>
<D:depth>infinity</D:depth>
<D:timeout>Second-3600</D:timeout>
<D:locktoken><D:href>${token}</D:href></D:locktoken>
<D:lockroot><D:href>${escapeXml(path)}</D:href></D:lockroot>
</D:activelock>
</D:lockdiscovery>
</D:prop>`
res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Lock-Token': `<${token}>`,
'Content-Length': Buffer.byteLength(xml),
})
res.end(xml)
}
/** UNLOCK always succeeds — no real locks are held. */
private handleUnlock(_req: IncomingMessage, res: ServerResponse): void {
res.writeHead(204)
res.end()
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ALLOWED_METHODS = 'OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, LOCK, UNLOCK'
/** Decodes a URI-encoded request path and normalises it. */
function decodePath(raw: string): string {
try {
const decoded = decodeURIComponent(raw.split('?')[0])
return posix.normalize(decoded) || '/'
} catch {
return posix.normalize(raw.split('?')[0]) || '/'
}
}
/** Ensures a path ends with `/` (for directory hrefs). */
function ensureTrailingSlash(path: string): string {
return path.endsWith('/') ? path : path + '/'
}
/** Escapes special XML characters. */
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* Builds a WebDAV `207 Multi-Status` XML response from an array of entries.
* Each entry contains a GenericNode and its absolute href.
*/
function buildMultistatusXml(entries: Array<{ node: GenericNode; href: string }>): string {
const responses = entries.map(({ node, href }) => {
const resourceType = node.isDir
? '<D:resourcetype><D:collection/></D:resourcetype>'
: '<D:resourcetype/>'
const contentLength = node.isDir
? ''
: `<D:getcontentlength>${node.size}</D:getcontentlength>`
const contentType = node.isDir
? ''
: '<D:getcontenttype>application/octet-stream</D:getcontenttype>'
const etag = node.etag
? `<D:getetag>"${escapeXml(node.etag)}"</D:getetag>`
: ''
return ` <D:response>
<D:href>${escapeXml(encodeURI(href))}</D:href>
<D:propstat>
<D:prop>
${resourceType}
<D:displayname>${escapeXml(node.name)}</D:displayname>
${contentLength}
${contentType}
<D:getlastmodified>${node.modified.toUTCString()}</D:getlastmodified>
${etag}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`
})
return `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
${responses.join('\n')}
</D:multistatus>`
}

56
src/logger.ts Normal file
View File

@@ -0,0 +1,56 @@
import { createLogger, format, transports } from 'winston'
import Transport from 'winston-transport'
import type { SqliteCache } from './adapters/cache/sqlite.js'
/**
* Custom winston transport that writes log entries to a SqliteCache instance.
*/
export class SqliteTransport extends Transport {
private cache: SqliteCache
constructor(cache: SqliteCache, opts?: Transport.TransportStreamOptions) {
super(opts)
this.cache = cache
}
log(info: { level: string; message: string }, callback: () => void): void {
setImmediate(() => this.emit('logged', info))
this.cache.writeLog(info.level, info.message)
callback()
}
}
/**
* Global logger instance. Starts with console transport only.
* The SQLite transport is added later once a SqliteCache is available.
*/
export const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp(),
format.printf(({ timestamp, level, message }) => {
return `${timestamp as string} [${level}] ${message as string}`
}),
),
transports: [
new transports.Console(),
],
})
/**
* Attaches the SQLite transport to the global logger.
* Called once during startup after the SqliteCache is created.
*/
export function attachSqliteTransport(cache: SqliteCache): void {
logger.add(new SqliteTransport(cache))
}
/**
* Removes the console transport from the logger.
* Called when the process is running as a background service.
*/
export function removeConsoleTransport(): void {
logger.transports
.filter(t => t instanceof transports.Console)
.forEach(t => logger.remove(t))
}

121
src/service/launchd.ts Normal file
View File

@@ -0,0 +1,121 @@
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { execSync } from 'node:child_process'
import { logger } from '../logger.js'
const LABEL = 'com.sftp-proxy'
const PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
const LOG_DIR = join(homedir(), '.config', 'sftp-proxy')
/**
* Resolves the absolute paths needed to launch sftp-proxy.
* All paths are resolved at install time so the plist is self-contained
* regardless of what working directory launchd starts from.
*/
function resolveLaunchPaths(): { projectDir: string; tsxBin: string; entryScript: string } {
const projectDir = resolve(process.cwd())
const tsxBin = resolve(projectDir, 'node_modules', '.bin', 'tsx')
const entryScript = resolve(projectDir, 'bin', 'sftp-proxy.ts')
if (!existsSync(tsxBin)) {
throw new Error(`tsx not found at ${tsxBin} — run npm install first`)
}
if (!existsSync(entryScript)) {
throw new Error(`Entry script not found at ${entryScript}`)
}
return { projectDir, tsxBin, entryScript }
}
/**
* Generates the launchd plist XML content.
* Uses absolute paths resolved at install time and sets WorkingDirectory
* so Node can find node_modules and the config file.
*/
function generatePlist(): string {
const { projectDir, tsxBin, entryScript } = resolveLaunchPaths()
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${tsxBin}</string>
<string>${entryScript}</string>
<string>start</string>
</array>
<key>WorkingDirectory</key>
<string>${projectDir}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${join(LOG_DIR, 'stdout.log')}</string>
<key>StandardErrorPath</key>
<string>${join(LOG_DIR, 'stderr.log')}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>`
}
/**
* Installs the sftp-proxy launchd service.
* Creates the plist, ensures directories exist, and loads via `launchctl`.
*/
export function install(): void {
mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true })
mkdirSync(LOG_DIR, { recursive: true })
const plist = generatePlist()
writeFileSync(PLIST_PATH, plist, { encoding: 'utf-8' })
logger.info(`Wrote plist to ${PLIST_PATH}`)
// Unload first in case a previous version is still loaded
try { execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`) } catch { /* ignore */ }
try {
execSync(`launchctl load ${PLIST_PATH}`, { stdio: 'inherit' })
logger.info('launchd service loaded — sftp-proxy will start now and on every login')
} catch (err) {
logger.error(`Failed to load launchd service: ${(err as Error).message}`)
}
}
/**
* Uninstalls the sftp-proxy launchd service.
* Unloads via `launchctl` and removes the plist file.
*/
export function uninstall(): void {
try {
execSync(`launchctl unload ${PLIST_PATH}`, { stdio: 'inherit' })
logger.info('launchd service unloaded')
} catch {
// may not be loaded
}
if (existsSync(PLIST_PATH)) {
unlinkSync(PLIST_PATH)
logger.info(`Removed plist at ${PLIST_PATH}`)
}
}
/**
* Returns the current status of the sftp-proxy launchd service.
*/
export function status(): string {
try {
const output = execSync(`launchctl list | grep ${LABEL}`, { encoding: 'utf-8' })
return output.trim() || 'Not running'
} catch {
return 'Not installed'
}
}

101
src/service/systemd.ts Normal file
View File

@@ -0,0 +1,101 @@
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { homedir } from 'node:os'
import { execSync } from 'node:child_process'
import { logger } from '../logger.js'
const UNIT_NAME = 'sftp-proxy'
const UNIT_PATH = join(homedir(), '.config', 'systemd', 'user', `${UNIT_NAME}.service`)
/**
* Finds the path to the sftp-proxy binary.
*/
function getBinaryPath(): string {
try {
return execSync('which sftp-proxy', { encoding: 'utf-8' }).trim()
} catch {
return join(process.cwd(), 'node_modules', '.bin', 'tsx') + ' ' + join(process.cwd(), 'bin', 'sftp-proxy.ts')
}
}
/**
* Generates the systemd user unit file content.
*/
function generateUnit(): string {
const bin = getBinaryPath()
return `[Unit]
Description=SFTP Proxy — local protocol servers for remote storage
After=network.target
[Service]
ExecStart=${bin} start
Restart=always
RestartSec=5
[Install]
WantedBy=default.target`
}
/**
* Installs the sftp-proxy systemd user service.
* Creates the unit file and enables+starts via `systemctl --user`.
*/
export function install(): void {
mkdirSync(dirname(UNIT_PATH), { recursive: true })
const unit = generateUnit()
writeFileSync(UNIT_PATH, unit, { encoding: 'utf-8' })
logger.info(`Wrote unit file to ${UNIT_PATH}`)
try {
execSync(`systemctl --user daemon-reload`, { stdio: 'inherit' })
execSync(`systemctl --user enable ${UNIT_NAME}`, { stdio: 'inherit' })
execSync(`systemctl --user start ${UNIT_NAME}`, { stdio: 'inherit' })
logger.info('systemd user service installed and started')
} catch (err) {
logger.error(`Failed to install systemd service: ${(err as Error).message}`)
}
}
/**
* Uninstalls the sftp-proxy systemd user service.
* Stops, disables, and removes the unit file.
*/
export function uninstall(): void {
try {
execSync(`systemctl --user stop ${UNIT_NAME}`, { stdio: 'inherit' })
execSync(`systemctl --user disable ${UNIT_NAME}`, { stdio: 'inherit' })
logger.info('systemd user service stopped and disabled')
} catch {
// may not be active
}
if (existsSync(UNIT_PATH)) {
unlinkSync(UNIT_PATH)
logger.info(`Removed unit file at ${UNIT_PATH}`)
}
try {
execSync(`systemctl --user daemon-reload`, { stdio: 'inherit' })
} catch {
// best effort
}
}
/**
* Returns the current status of the sftp-proxy systemd user service.
*/
export function status(): string {
try {
const output = execSync(`systemctl --user status ${UNIT_NAME}`, { encoding: 'utf-8' })
return output.trim()
} catch (err) {
const stderr = (err as any).stderr?.toString() ?? ''
if (stderr.includes('could not be found')) {
return 'Not installed'
}
// systemctl returns non-zero for inactive services too
return (err as any).stdout?.toString().trim() || 'Not running'
}
}

270
src/sync.ts Normal file
View File

@@ -0,0 +1,270 @@
import { SyncPriority } from './types.js'
import type { GenericNode } from './types.js'
import type { BaseCache } from './adapters/cache/base.js'
import { logger } from './logger.js'
/**
* Interface that the SyncWorker uses to fetch metadata from the remote.
* Deliberately narrow so we don't depend on the full BaseClient type (avoids circular deps).
*/
export interface SyncableClient {
list(path: string): Promise<GenericNode[]>
}
/** An entry in the priority queue. */
interface QueueEntry {
path: string
priority: SyncPriority
/** Resolvers for anyone awaiting forceSync on this path. */
waiters: Array<{ resolve: () => void; reject: (err: Error) => void }>
}
/** Minimum polling interval in milliseconds. */
const MIN_BACKOFF_MS = 10_000
/** Maximum polling interval in milliseconds (30 minutes). */
const MAX_BACKOFF_MS = 30 * 60 * 1000
/**
* Background sync worker that keeps the cache up to date with the remote.
*
* Maintains a priority queue of paths to fetch. HIGH-priority items (user-requested
* or post-write) are processed before LOW-priority (background crawl) items.
* Runs `concurrency` fetches in parallel.
*
* After the initial sync, begins polling with exponential backoff (10s → 30min).
* Any detected change resets the backoff to 10s.
*/
export class SyncWorker {
private queue: Map<string, QueueEntry> = new Map()
private activeWorkers = 0
private running = false
private backoffMs = MIN_BACKOFF_MS
private pollTimer: ReturnType<typeof setTimeout> | null = null
private drainResolvers: Array<() => void> = []
constructor(
private client: SyncableClient,
private cache: BaseCache,
private concurrency: number,
) {}
/**
* Starts the sync worker. Enqueues the root path at HIGH priority
* and begins processing the queue.
*/
start(): void {
if (this.running) return
this.running = true
this.enqueue('/', SyncPriority.HIGH)
this.pump()
}
/** Stops the sync worker and clears the poll timer. */
stop(): void {
this.running = false
if (this.pollTimer) {
clearTimeout(this.pollTimer)
this.pollTimer = null
}
}
/**
* Promotes a path to HIGH priority and waits until it has been synced.
* Called after every write operation to ensure the cache is consistent.
*/
async forceSync(path: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const existing = this.queue.get(path)
if (existing) {
existing.priority = SyncPriority.HIGH
existing.waiters.push({ resolve, reject })
} else {
this.queue.set(path, {
path,
priority: SyncPriority.HIGH,
waiters: [{ resolve, reject }],
})
}
this.pump()
})
}
/**
* Promotes a path to HIGH priority without waiting for completion.
* Called when a user browses an uncached directory.
*/
prioritise(path: string): void {
const existing = this.queue.get(path)
if (existing) {
existing.priority = SyncPriority.HIGH
} else {
this.enqueue(path, SyncPriority.HIGH)
}
this.pump()
}
/**
* Returns a promise that resolves when the queue is fully drained.
* Useful for testing.
*/
async waitForDrain(): Promise<void> {
if (this.queue.size === 0 && this.activeWorkers === 0) return
return new Promise<void>((resolve) => {
this.drainResolvers.push(resolve)
})
}
/** Adds a path to the queue if not already present. */
private enqueue(path: string, priority: SyncPriority): void {
if (this.queue.has(path)) return
this.queue.set(path, { path, priority, waiters: [] })
}
/**
* Main pump loop. Dequeues items and spawns workers up to the concurrency limit.
* HIGH-priority items are always dequeued first.
*/
private pump(): void {
if (!this.running) return
while (this.activeWorkers < this.concurrency && this.queue.size > 0) {
const entry = this.dequeueHighest()
if (!entry) break
this.activeWorkers++
this.processEntry(entry)
.catch((err) => {
logger.error(`Sync error for ${entry.path}: ${(err as Error).message}`)
for (const waiter of entry.waiters) {
waiter.reject(err as Error)
}
})
.finally(() => {
this.activeWorkers--
this.pump()
this.checkDrain()
})
}
}
/** Dequeues the highest-priority entry from the queue. */
private dequeueHighest(): QueueEntry | null {
let best: QueueEntry | null = null
for (const entry of this.queue.values()) {
if (!best || entry.priority < best.priority) {
best = entry
}
}
if (best) {
this.queue.delete(best.path)
}
return best
}
/**
* Processes a single queue entry:
* 1. Fetches remote listing for the path
* 2. Diffs against the cache
* 3. Applies changes (upsert new/changed, delete removed)
* 4. Enqueues child directories at LOW priority
* 5. Resets backoff if changes were detected
*/
private async processEntry(entry: QueueEntry): Promise<void> {
const { path } = entry
let remoteNodes: GenericNode[]
try {
remoteNodes = await this.client.list(path)
} catch (err) {
logger.error(`Failed to list remote path ${path}: ${(err as Error).message}`)
for (const waiter of entry.waiters) {
waiter.reject(err as Error)
}
return
}
// Safety net: drop any entry that refers to the directory itself.
// Some remote servers include the parent in directory listings.
const normalisedPath = path.replace(/\/+$/, '') || '/'
remoteNodes = remoteNodes.filter(n => {
const np = n.path.replace(/\/+$/, '') || '/'
return np !== normalisedPath
})
const cachedNodes = await this.cache.children(path)
let changesDetected = false
// Build a set of remote paths for quick lookup
const remotePaths = new Set(remoteNodes.map(n => n.path))
// Upsert new/changed nodes
for (const remoteNode of remoteNodes) {
const cached = await this.cache.get(remoteNode.path)
const isChanged = !cached
|| cached.etag !== remoteNode.etag
|| cached.modified.getTime() !== remoteNode.modified.getTime()
|| cached.size !== remoteNode.size
if (isChanged) {
await this.cache.set(remoteNode.path, remoteNode)
changesDetected = true
}
// Enqueue child directories for background crawl
if (remoteNode.isDir) {
this.enqueue(remoteNode.path, SyncPriority.LOW)
}
}
// Delete nodes that no longer exist on the remote
for (const cachedNode of cachedNodes) {
if (!remotePaths.has(cachedNode.path)) {
await this.cache.delete(cachedNode.path)
changesDetected = true
}
}
// Reset backoff if any changes were detected
if (changesDetected) {
this.backoffMs = MIN_BACKOFF_MS
logger.info(`Sync detected changes at ${path}`)
}
// Resolve all waiters for this entry
for (const waiter of entry.waiters) {
waiter.resolve()
}
}
/** Checks if the queue is drained and resolves any drain waiters. */
private checkDrain(): void {
if (this.queue.size === 0 && this.activeWorkers === 0) {
for (const resolve of this.drainResolvers) {
resolve()
}
this.drainResolvers = []
this.schedulePoll()
}
}
/**
* Schedules the next background poll with exponential backoff.
* Re-enqueues the root path at LOW priority.
*/
private schedulePoll(): void {
if (!this.running) return
if (this.pollTimer) return
this.pollTimer = setTimeout(() => {
this.pollTimer = null
if (!this.running) return
this.enqueue('/', SyncPriority.LOW)
this.pump()
// Increase backoff for next cycle (capped at MAX_BACKOFF_MS)
this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS)
}, this.backoffMs)
}
}

101
src/types.ts Normal file
View File

@@ -0,0 +1,101 @@
import { Readable } from 'node:stream'
import { z } from 'zod'
/**
* Represents a single filesystem node (file or directory) in the virtual tree.
* All paths are POSIX-normalised: leading slash, no trailing slash.
*/
export interface GenericNode {
/** Full absolute path relative to client basePath, e.g. /docs/file.txt */
path: string
/** Basename of the node */
name: string
/** Whether this node is a directory */
isDir: boolean
/** File size in bytes (0 for directories) */
size: number
/** Last modification timestamp */
modified: Date
/** Optional entity tag for change detection */
etag?: string
}
/**
* Union of all request types that servers dispatch to clients.
*/
export type GenericRequest =
| { type: 'stat'; path: string }
| { type: 'list'; path: string }
| { type: 'read'; path: string }
| { type: 'write'; path: string; stream: Readable }
| { type: 'mkdir'; path: string }
| { type: 'delete'; path: string }
| { type: 'rename'; from: string; to: string }
/**
* Configuration for a single remote storage client.
*/
export interface ClientConfig {
/** Remote server URL */
url: string
/** Root path on the remote server */
basePath: string
/** Auth username */
username: string
/** Auth password */
password: string
/** Where this client appears in the local virtual filesystem, e.g. /home */
mountPath: string
/** Number of parallel sync workers */
concurrency: number
}
/**
* Zod schema for validating the config file loaded from disk.
*/
export const ConfigSchema = z.object({
port: z.number().int().positive().default(2022),
credentials: z.object({
username: z.string().min(1),
password: z.string().min(1),
}),
servers: z.object({
sftp: z.object({ port: z.number().int().positive() }).optional(),
ftp: z.object({
port: z.number().int().positive(),
pasv_url: z.string().default('127.0.0.1'),
pasv_min: z.number().int().positive().default(1024),
pasv_max: z.number().int().positive().default(65535),
}).optional(),
webdav: z.object({ port: z.number().int().positive() }).optional(),
nfs: z.object({ port: z.number().int().positive() }).optional(),
smb: z.object({ port: z.number().int().positive() }).optional(),
}).optional(),
clients: z.array(
z.object({
type: z.enum(['webdav', 'ftp', 'sftp']),
url: z.string().min(1),
basePath: z.string().default('/'),
username: z.string().min(1),
password: z.string().min(1),
mountPath: z.string().min(1),
concurrency: z.number().int().positive().default(5),
cache: z.object({
type: z.enum(['sqlite', 'memory']).default('sqlite'),
}).default({ type: 'sqlite' }),
})
).min(1),
})
/**
* Fully validated config type inferred from the zod schema.
*/
export type Config = z.infer<typeof ConfigSchema>
/**
* Priority levels for the sync queue.
*/
export enum SyncPriority {
HIGH = 0,
LOW = 1,
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
testTimeout: 30000,
},
})