fix(host,client): require fully qualified browse paths; clear the picker kind on close
ds-review-bot round 4. On Windows, isAbsolute admits rooted drive-less forms (\foo, /foo) that resolve() then rebases onto the process's current drive; both browse primitives now gate on a fullyQualified check (drive letter or UNC on win32, POSIX-absolute elsewhere) with a platform test seam, per-platform unit cases, and the contract wording updated on the seam, the backend README pair, and the error messages. The picker-kind effect also kept a resolved 'dialog' across close, so a backend swapped while the menu was closed could paint the stale entry for one frame on reopen; the close arm now clears the state, pinned by a reopen-under-pending-read race test.
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
|
||||
import { mkdir, readdir, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
|
||||
import {
|
||||
DirectoryPicker, DirectoryPickerError,
|
||||
} from '@deepseek-ai/dsh-host-directory-picker'
|
||||
@@ -35,6 +35,22 @@ function ancestryCrumbs(target: string): DirectoryEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the path names one fixed filesystem location regardless of
|
||||
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
|
||||
* (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`,
|
||||
* `/foo`) pass `isAbsolute` yet still resolve against the process's current
|
||||
* drive.
|
||||
* @param path - candidate path.
|
||||
* @param platform - replaces `process.platform` for deterministic tests.
|
||||
* @returns whether the path is fully qualified on the platform.
|
||||
*/
|
||||
export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
return platform === 'win32'
|
||||
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path)
|
||||
: posix.isAbsolute(path)
|
||||
}
|
||||
|
||||
/** Message text of an unknown thrown value. */
|
||||
function messageOf(error: unknown): string {
|
||||
/* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
|
||||
@@ -81,10 +97,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
|
||||
|
||||
private async list(path?: string): Promise<DirectoryListing> {
|
||||
const home = homedir()
|
||||
// The seam contract takes absolute paths only; resolve() would silently
|
||||
// rebase a relative or empty wire value under the host process cwd.
|
||||
if (path !== undefined && !isAbsolute(path)) {
|
||||
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`)
|
||||
// The seam contract takes fully qualified paths only; resolve() would
|
||||
// silently rebase a relative or empty wire value under the host process
|
||||
// cwd (or, for rooted drive-less Windows forms, its current drive).
|
||||
if (path !== undefined && !fullyQualified(path)) {
|
||||
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
|
||||
}
|
||||
const target = resolve(path ?? home)
|
||||
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
|
||||
@@ -105,9 +122,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
|
||||
}
|
||||
|
||||
private async createDirectory(path: string, name: string): Promise<string> {
|
||||
// Same absolute-path fence as list: never rebase a parent under the cwd.
|
||||
if (!isAbsolute(path)) {
|
||||
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`)
|
||||
// Same fully-qualified fence as list: never rebase a parent under the
|
||||
// cwd or the current drive.
|
||||
if (!fullyQualified(path)) {
|
||||
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
|
||||
}
|
||||
const parent = resolve(path)
|
||||
// The backend owns segment validation (the wire schema also refuses these,
|
||||
|
||||
Reference in New Issue
Block a user