@linxin666/dsh-ssh 0.1.19 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -78,6 +78,8 @@ export interface SshRoutesDeps {
78
78
  engine: SshEngine
79
79
  /** Temp dir for upload/download staging (tests inject a sandbox). */
80
80
  stagingDir?: string
81
+ /** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
82
+ maxUploadBytes?: number
81
83
  }
82
84
 
83
85
  /**
@@ -88,6 +90,7 @@ export interface SshRoutesDeps {
88
90
  export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
89
91
  const { store, engine } = deps
90
92
  const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
93
+ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
91
94
  // The upload route stages request bodies here; it must exist before the
92
95
  // first request (a missing dir would hang the first upload forever).
93
96
  mkdirSync(staging, { recursive: true })
@@ -154,6 +157,13 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
154
157
  }
155
158
  try {
156
159
  const entry = store.update(alias, body as unknown as Partial<HostPayload>)
160
+ // Connection-relevant changes invalidate the pooled connection:
161
+ // without this the pool would keep running commands on the old
162
+ // host/credentials until the idle sweep (up to 30 min later).
163
+ const patch = body as Record<string, unknown>
164
+ if (['host', 'port', 'user', 'auth', 'proxyJump'].some(key => patch[key] !== undefined)) {
165
+ engine.dropAlias(alias)
166
+ }
157
167
  writeJson(res, 200, { host: store.summarize(entry) })
158
168
  } catch (error) {
159
169
  writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
@@ -162,7 +172,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
162
172
  }
163
173
  if (method === 'DELETE') {
164
174
  try {
165
- engine.stopAllTunnels(alias)
175
+ engine.dropAlias(alias)
166
176
  store.delete(alias)
167
177
  writeJson(res, 200, { ok: true })
168
178
  } catch (error) {
@@ -329,7 +339,7 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
329
339
  return
330
340
  }
331
341
  const declared = Number(req.headers['content-length'])
332
- if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
342
+ if (Number.isFinite(declared) && declared > maxUploadBytes) {
333
343
  writeJson(res, 413, { error: 'upload body too large' })
334
344
  return
335
345
  }
@@ -351,9 +361,22 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
351
361
  if (settled) return
352
362
  settled = true
353
363
  emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
354
- try { sink.destroy() } catch { /* closed */ }
355
- void unlink(tmp).catch(() => undefined)
356
- try { res.end() } catch { /* closed */ }
364
+ // End the response only after the tmp file is gone, and unlink only
365
+ // after the sink is fully closed: destroying a WriteStream whose
366
+ // fs.open is still pending lets the open RE-CREATE the file after
367
+ // an early unlink, leaving staging populated (this raced the
368
+ // response end and made the byte-cap test flaky).
369
+ const cleanup = (): void => {
370
+ void unlink(tmp).catch(() => undefined).finally(() => {
371
+ try { res.end() } catch { /* closed */ }
372
+ })
373
+ }
374
+ if (sink.destroyed) {
375
+ cleanup()
376
+ } else {
377
+ sink.once('close', cleanup)
378
+ try { sink.destroy() } catch { cleanup() }
379
+ }
357
380
  }
358
381
  const done = (): void => {
359
382
  if (settled) return
@@ -365,6 +388,24 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
365
388
  req.on('aborted', () => fail('upload aborted by the client'))
366
389
  res.on('error', () => fail('response stream closed'))
367
390
  res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
391
+ // The content-length pre-check above can be bypassed by chunked or
392
+ // header-less requests: count the bytes as they actually arrive and
393
+ // abort the moment the cap is exceeded.
394
+ let received = 0
395
+ let capped = false
396
+ req.on('data', (chunk: Buffer) => {
397
+ received += chunk.byteLength
398
+ if (received > maxUploadBytes && !capped) {
399
+ capped = true
400
+ fail('upload body too large')
401
+ // Keep the socket alive until the response flush finishes:
402
+ // destroying the request here races res.end() and the client
403
+ // sees a hang-up instead of the result frame. Drain the rest of
404
+ // the body so the socket can close cleanly afterwards.
405
+ res.on('finish', () => { try { req.destroy() } catch { /* closed */ } })
406
+ req.resume()
407
+ }
408
+ })
368
409
  req.pipe(sink)
369
410
  sink.on('finish', async () => {
370
411
  if (settled) return
package/src/store.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * as ssh-skill's annotated ssh-config comments; document it, never log it.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
10
10
  import { homedir } from 'node:os'
11
11
  import { dirname, join, resolve } from 'node:path'
12
12
  import type { HostPayload, ImportResult, SshHostEntry, SshHostSummary } from './protocol.ts'
@@ -306,18 +306,36 @@ export class HostStore {
306
306
 
307
307
  private skippedNames = new Set<string>()
308
308
 
309
+ /**
310
+ * Last parsed store keyed by file identity. list/find ride every acquire
311
+ * and GUI refresh; re-reading and re-parsing the whole file each call is
312
+ * wasted work when the file has not changed. Any save invalidates.
313
+ */
314
+ private cache: { mtimeMs: number; size: number; file: StoreFile } | undefined
315
+
309
316
  private load(): StoreFile {
310
- if (!existsSync(this.path)) return { version: FORMAT_VERSION, hosts: [] }
317
+ let stats: { mtimeMs: number; size: number }
318
+ try {
319
+ stats = statSync(this.path)
320
+ } catch {
321
+ this.cache = undefined
322
+ return { version: FORMAT_VERSION, hosts: [] }
323
+ }
324
+ if (this.cache !== undefined && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) {
325
+ return this.cache.file
326
+ }
311
327
  try {
312
328
  const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
313
329
  if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
314
330
  throw new Error('store file shape invalid')
315
331
  }
332
+ this.cache = { mtimeMs: stats.mtimeMs, size: stats.size, file: parsed }
316
333
  return parsed
317
334
  } catch {
318
335
  // A corrupt store must not brick the plugin — and must not be silently
319
336
  // overwritten by the next save: rename it aside for manual recovery
320
337
  // (the plugin then starts from an empty list).
338
+ this.cache = undefined
321
339
  try {
322
340
  renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
323
341
  } catch { /* best effort */ }
@@ -333,6 +351,7 @@ export class HostStore {
333
351
  // tmp file carries the 0600 mode through the rename.
334
352
  writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
335
353
  renameSync(tmp, this.path)
354
+ this.cache = undefined
336
355
  }
337
356
  }
338
357