initial commit
This commit is contained in:
80
.gitignore
vendored
Normal file
80
.gitignore
vendored
Normal 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
250
bin/sftp-proxy.ts
Normal 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
3168
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
247
src/__tests__/e2e-ftp.test.ts
Normal file
247
src/__tests__/e2e-ftp.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
329
src/__tests__/e2e-webdav.test.ts
Normal file
329
src/__tests__/e2e-webdav.test.ts
Normal 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
405
src/__tests__/e2e.test.ts
Normal 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
110
src/__tests__/helpers.ts
Normal 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
198
src/__tests__/sync.test.ts
Normal 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
103
src/__tests__/types.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
109
src/adapters/cache/__tests__/memory.test.ts
vendored
Normal file
109
src/adapters/cache/__tests__/memory.test.ts
vendored
Normal 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
32
src/adapters/cache/base.ts
vendored
Normal 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
53
src/adapters/cache/memory.ts
vendored
Normal 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
142
src/adapters/cache/sqlite.ts
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
62
src/adapters/client/base.ts
Normal file
62
src/adapters/client/base.ts
Normal 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
126
src/adapters/client/ftp.ts
Normal 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
198
src/adapters/client/sftp.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
127
src/adapters/client/webdav.ts
Normal file
127
src/adapters/client/webdav.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
205
src/adapters/server/__tests__/base.test.ts
Normal file
205
src/adapters/server/__tests__/base.test.ts
Normal 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
196
src/adapters/server/base.ts
Normal 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
301
src/adapters/server/ftp.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
48
src/adapters/server/nfs.ts
Normal file
48
src/adapters/server/nfs.ts
Normal 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
457
src/adapters/server/sftp.ts
Normal 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}`
|
||||
}
|
||||
54
src/adapters/server/smb.ts
Normal file
54
src/adapters/server/smb.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
497
src/adapters/server/webdav.ts
Normal file
497
src/adapters/server/webdav.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
56
src/logger.ts
Normal 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
121
src/service/launchd.ts
Normal 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
101
src/service/systemd.ts
Normal 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
270
src/sync.ts
Normal 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
101
src/types.ts
Normal 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
19
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
testTimeout: 30000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user