Skip to content
This repository was archived by the owner on Jul 30, 2025. It is now read-only.

Commit fb35ffc

Browse files
committed
fix: cd and ls don't always work against vfs
Fixes #6997
1 parent b66a2fb commit fb35ffc

File tree

5 files changed

+186
-43
lines changed

5 files changed

+186
-43
lines changed

plugins/plugin-bash-like/fs/src/lib/cd.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,11 @@
2020
*
2121
*/
2222

23-
import Debug from 'debug'
24-
import { join } from 'path'
25-
2623
import { cwd, inBrowser, Arguments, Registrar, i18n, expandHomeDir } from '@kui-shell/core'
2724
import { localFilepath } from './usage-helpers'
28-
import { findMount } from '../vfs'
25+
import { absolute, findMount } from '../vfs'
2926

3027
const strings = i18n('plugin-bash-like')
31-
const debug = Debug('plugins/bash-like/cmds/general')
3228

3329
const usage = {
3430
cd: {
@@ -52,28 +48,28 @@ const cd = async (args: Arguments) => {
5248

5349
const dir = !dirAsProvided ? expandHomeDir('~') : dirAsProvided === '-' ? process.env.OLDPWD : dirAsProvided
5450

55-
const mount = findMount(dir)
51+
const mount = findMount(dir, undefined, true)
5652
try {
57-
const resolveDir =
58-
mount.isLocal || dirAsProvided === '-' ? dir : process.env.VIRTUAL_CWD ? join(process.env.VIRTUAL_CWD, dir) : dir
59-
debug('cd dir', resolveDir)
60-
const stat = await mount.fstat(args, resolveDir)
53+
const { isDirectory, fullpath } = !mount
54+
? { isDirectory: true, fullpath: absolute(dir) }
55+
: await mount.fstat(args, absolute(dir))
56+
const isLocal = mount && mount.isLocal
6157

62-
if (stat.isDirectory) {
58+
if (isDirectory) {
6359
if (process.env.OLDPWD === undefined) {
6460
process.env.OLDPWD = ''
6561
}
6662

6763
const OLDPWD = cwd() // remember it for when we're done
68-
const newDir = expandHomeDir(stat.fullpath)
64+
const newDir = expandHomeDir(fullpath)
6965

70-
if (mount.isLocal && !inBrowser()) {
66+
if (isLocal && !inBrowser()) {
7167
process.chdir(newDir)
7268
}
7369

7470
process.env.OLDPWD = OLDPWD
7571
process.env.PWD = newDir
76-
if (mount.isLocal) {
72+
if (isLocal) {
7773
delete process.env.VIRTUAL_CWD
7874
} else {
7975
process.env.VIRTUAL_CWD = newDir

plugins/plugin-bash-like/fs/src/vfs/delegates.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { basename } from 'path'
1818
import { Arguments, CodedError, Table, flatten } from '@kui-shell/core'
19-
import { DirEntry, VFS, findMount, multiFindMount, findMatchingMounts } from '.'
19+
import { DirEntry, VFS, absolute, findMount, multiFindMount, findMatchingMounts } from '.'
2020

2121
/** '/a/b/c' -> 3 */
2222
function countSlashes(path: string) {
@@ -95,24 +95,46 @@ async function lsMounts(path: string): Promise<DirEntry[]> {
9595
*
9696
*/
9797
export async function ls(...parameters: Parameters<VFS['ls']>): Promise<DirEntry[]> {
98-
const mounts = multiFindMount(parameters[1], true)
99-
if (mounts.length === 0) {
100-
const err: CodedError = new Error(`VFS not mounted: ${parameters[1]}`)
98+
const filepaths = parameters[1].length === 0 ? [process.env.PWD] : parameters[1].map(absolute)
99+
100+
// the first maintains the mapping from input to mountContent: DirEntry[][]
101+
// the second flattens this down to a DirEntry[]
102+
const mountContentPerInput = await Promise.all(filepaths.map(lsMounts))
103+
const mountContent = flatten(mountContentPerInput)
104+
105+
const mounts = multiFindMount(filepaths, true)
106+
if (mounts.length === 0 && mountContent.length === 0) {
107+
const err: CodedError = new Error(`VFS not mounted: ${filepaths}`)
101108
err.code = 404
102109
throw err
103110
}
104111

105-
const [mountContent, vfsContent] = await Promise.all([
106-
Promise.all((parameters[1].length === 0 ? [process.env.PWD] : parameters[1]).map(lsMounts)).then(flatten),
107-
Promise.all(mounts.map(({ filepaths, mount }) => mount.ls(parameters[0], filepaths))).then(flatten)
108-
])
112+
const vfsContent = (
113+
await Promise.all(
114+
mounts.map(async ({ filepaths, mount }) => {
115+
try {
116+
return await mount.ls(parameters[0], filepaths)
117+
} catch (err) {
118+
if (err.code !== 404) {
119+
console.error(err)
120+
throw err
121+
}
122+
}
123+
})
124+
).then(flatten)
125+
).filter(_ => _)
109126

110-
if (mountContent.length > 0) {
127+
if (mountContent.length === 0) {
128+
if (vfsContent.length === 0) {
129+
// TODO: if no matches, we need to check whether the filepaths are
130+
// all directories (and no -d); only then is an empty response valid.
131+
// Otherwise, we need to report a 404.
132+
}
133+
return vfsContent
134+
} else {
111135
return mountContent.concat(vfsContent).sort((a, b) => {
112136
return a.name.localeCompare(b.name)
113137
})
114-
} else {
115-
return vfsContent
116138
}
117139
}
118140

plugins/plugin-bash-like/fs/src/vfs/index.ts

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export async function mount(vfs: VFS | VFSProducingFunction) {
182182
}
183183

184184
/** @return the absolute path to `filepath` */
185-
function absolute(filepath: string): string {
185+
export function absolute(filepath: string): string {
186186
return isAbsolute(filepath) ? filepath : join(cwd(), filepath)
187187
}
188188

@@ -198,17 +198,75 @@ export function findMatchingMounts(filepath: string, checkClient = false): VFS[]
198198
return mounts
199199
}
200200

201-
/** Lookup compiatible mount */
202-
export function findMount(filepath: string, checkClient = false): VFS {
201+
/**
202+
* Lookup compiatible mount. Returns one of:
203+
* - a VFS mount, if it encloses the given filepath
204+
*
205+
* - true if `allowInner` and there exists a mount s.t. filepath
206+
* encloses it (i.e. filepath is a parent of some mount)
207+
*
208+
* - otherwise, the local VFS mount
209+
*/
210+
export function findMount(filepath: string, checkClient = false, allowInner = false): VFS {
203211
const isClient = inBrowser()
204212
filepath = absolute(filepath)
205-
const mount =
206-
_currentMounts.find(
207-
mount => filepath.startsWith(mount.mountPath) && (!checkClient || !isClient || mount.isVirtual)
208-
) || _currentMounts.find(mount => mount.isLocal) // local fallback; see https://github.com/IBM/kui/issues/5898
209213

210-
debug(`findMount ${filepath}->${mount.mountPath}`)
211-
return mount
214+
// filepath: /kuifake Possibilities limited to [/kuifake]
215+
// mounts: ['/kuifake/fake1', '/tmpo', '/kuifake/fake2', '/kui', '/']
216+
// first loop should find nothing
217+
// if allowInner, second loop should return true; done!
218+
219+
// filepath: /kuifake/fake1 Possibilities limited to [/kuifake, /kuifake/fake1]
220+
// mounts: ['/kuifake/fake1', '/tmpo', '/kuifake/fake2', '/kui', '/']
221+
// first loop should find /kuifake/fake1; done!
222+
223+
// filepath: /kuifake/fake1/E1/f1 Possibilities limited to [/kuifake, /kuifake/fake1, /kuifake/fake1/E1, /kuifake/fake1/E1/f1]
224+
// mounts: ['/kuifake/fake1', '/tmpo', '/kuifake/fake2', '/kui', '/']
225+
// first loop should find /kuifake/fake1; done!
226+
227+
// filepath: /a/b/c/d
228+
// mounts: ['/kuifake/fake1', '/tmpo', '/kuifake/fake2', '/kui', '/']
229+
// first loop should find '/'; done!
230+
231+
// This loop checks if any mount **encloses** the given filepath (hence startsWith())
232+
// Important: search from longest to shortest!! e.g. we don't want
233+
// /aaa/b/c to match a mount /a when we also have a mount /aaa/b
234+
const splitPath = filepath.split(/\//)
235+
const possibilities: string[] = []
236+
for (let idx = 1; idx < splitPath.length; idx++) {
237+
if (splitPath[idx]) {
238+
possibilities.push('/' + splitPath.slice(1, idx + 1).join('/'))
239+
}
240+
}
241+
242+
let foundMount: VFS
243+
for (let idx = 0; idx < _currentMounts.length; idx++) {
244+
const mount = _currentMounts[idx]
245+
const { mountPath } = mount
246+
247+
if (possibilities.includes(mountPath) && (!checkClient || !isClient || mount.isVirtual)) {
248+
foundMount = mount
249+
break
250+
}
251+
}
252+
253+
if (!foundMount) {
254+
if (allowInner) {
255+
// ok, then look for a mount where the given filepath encloses the mount
256+
for (let idx = 0; idx < _currentMounts.length; idx++) {
257+
const mount = _currentMounts[idx]
258+
if (mount.mountPath.startsWith(filepath) && (!checkClient || !isClient || mount.isVirtual)) {
259+
return
260+
}
261+
}
262+
}
263+
264+
// local fallback; see https://github.com/IBM/kui/issues/5898
265+
foundMount = _currentMounts.find(mount => mount.isLocal)
266+
}
267+
268+
debug(`findMount ${filepath}->${foundMount.mountPath}`)
269+
return foundMount
212270
}
213271

214272
/** Lookup compatible mounts */

plugins/plugin-client-test/src/test/api2/vfs.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ describe('ls versus vfs', function(this: Common.ISuite) {
7575
CLI.command('ls -l', this.app)
7676
.then(ReplExpect.okWith('kuifake'))
7777
.catch(Common.oops(this, true)))
78+
it('should ls kuifake from / and show fake1', () =>
79+
CLI.command('ls -l kuifake', this.app)
80+
.then(ReplExpect.okWith('fake1'))
81+
.catch(Common.oops(this, true)))
82+
it('should ls kuifake from / and show fake2', () =>
83+
CLI.command('ls -l kuifake', this.app)
84+
.then(ReplExpect.okWith('fake2'))
85+
.catch(Common.oops(this, true)))
86+
it('should ls kuifake/ from / and show fake1', () =>
87+
CLI.command('ls -l kuifake/', this.app)
88+
.then(ReplExpect.okWith('fake1'))
89+
.catch(Common.oops(this, true)))
90+
it('should ls kuifake/ from / and show fake2', () =>
91+
CLI.command('ls -l kuifake/', this.app)
92+
.then(ReplExpect.okWith('fake2'))
93+
.catch(Common.oops(this, true)))
7894

7995
// now try cd /tmpo and make sure ls works against PWD
8096
it('should cd /tmpo', () =>
@@ -89,4 +105,46 @@ describe('ls versus vfs', function(this: Common.ISuite) {
89105
CLI.command('ls -l', this.app)
90106
.then(ReplExpect.okWith('D2'))
91107
.catch(Common.oops(this, true)))
108+
109+
// now try cd /kuifake and make sure ls works against PWD
110+
it('should cd /kuifake', () =>
111+
CLI.command('cd /kuifake', this.app)
112+
.then(ReplExpect.okWithString('/kuifake'))
113+
.catch(Common.oops(this, true)))
114+
it('should ls and show fake1', () =>
115+
CLI.command('ls -l', this.app)
116+
.then(ReplExpect.okWith('fake1'))
117+
.catch(Common.oops(this, true)))
118+
it('should ls and show fake2', () =>
119+
CLI.command('ls -l', this.app)
120+
.then(ReplExpect.okWith('fake2'))
121+
.catch(Common.oops(this, true)))
122+
123+
// now try cd fake1 (relative cd!)
124+
it('should cd fake1', () =>
125+
CLI.command('cd fake1', this.app)
126+
.then(ReplExpect.okWithString('fake1'))
127+
.catch(Common.oops(this, true)))
128+
it('should ls show E1', () =>
129+
CLI.command('ls -l E1', this.app)
130+
.then(ReplExpect.ok)
131+
.catch(Common.oops(this, true)))
132+
it('should ls show E2', () =>
133+
CLI.command('ls -l E2', this.app)
134+
.then(ReplExpect.ok)
135+
.catch(Common.oops(this, true)))
136+
137+
// now try cd ../fake2 (relative cd!)
138+
it('should cd ../fake2', () =>
139+
CLI.command('cd ../fake2', this.app)
140+
.then(ReplExpect.okWithString('fake2'))
141+
.catch(Common.oops(this, true)))
142+
it('should ls show F1', () =>
143+
CLI.command('ls -l F1', this.app)
144+
.then(ReplExpect.ok)
145+
.catch(Common.oops(this, true)))
146+
it('should ls show F2', () =>
147+
CLI.command('ls -l F2', this.app)
148+
.then(ReplExpect.ok)
149+
.catch(Common.oops(this, true)))
92150
})

plugins/plugin-core-support/src/notebooks/vfs/index.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface Tutorial {
3030

3131
interface BaseEntry {
3232
mountPath: string
33+
isDirectory?: boolean
3334
}
3435

3536
type Directory = BaseEntry
@@ -74,24 +75,32 @@ export class NotebookVFS implements VFS {
7475
}
7576

7677
/** Looks in the trie for any matches for the given filepath, handling the "contents of directory" case */
77-
private find(filepath: string): Entry[] {
78+
private find(filepath: string, dashD = false, exact = false): Entry[] {
7879
const dirPattern = this.dirPattern(filepath)
79-
80-
return this.trie
80+
const flexMatches = this.trie
8181
.get(filepath.replace(/\*.*$/, ''))
82-
.filter(_ => micromatch.isMatch(_.mountPath, filepath) || dirPattern.test(_.mountPath))
82+
.filter(_ =>
83+
exact ? _.mountPath === filepath : micromatch.isMatch(_.mountPath, filepath) || dirPattern.test(_.mountPath)
84+
)
85+
if (dashD) {
86+
return flexMatches.filter(_ => _.isDirectory)
87+
} else if (exact) {
88+
return flexMatches
89+
} else {
90+
return flexMatches.filter(_ => _.mountPath !== filepath)
91+
}
8392
}
8493

8594
/** Looks in the trie for a single precise match */
8695
private findExact(filepath: string, withData: boolean): FStat {
87-
const flexMatches = this.find(filepath) // all glob and/or directory matches
88-
const possibleMatches = flexMatches.filter(_ => _.mountPath === filepath)
96+
const possibleMatches = this.find(filepath, false, true)
8997

9098
if (possibleMatches.length > 1) {
9199
const msg = 'Multiple matches'
92100
console.error(msg, possibleMatches)
93101
throw new Error(msg)
94102
} else if (possibleMatches.length === 0) {
103+
const flexMatches = this.find(filepath, false, false)
95104
if (filepath === this.mountPath || flexMatches.find(_ => _.mountPath.startsWith(filepath))) {
96105
// then this is either a match against the mount position or an interior directory
97106
return {
@@ -146,10 +155,10 @@ export class NotebookVFS implements VFS {
146155
})
147156
}
148157

149-
public async ls(_, filepaths: string[]) {
158+
public async ls({ parsedOptions }: Parameters<VFS['ls']>[0], filepaths: string[]) {
150159
return flatten(
151160
filepaths
152-
.map(filepath => ({ filepath, entries: this.find(filepath) }))
161+
.map(filepath => ({ filepath, entries: this.find(filepath, parsedOptions.d) }))
153162
.filter(_ => _.entries.length > 0)
154163
.map(_ => this.enumerate(_))
155164
)
@@ -219,7 +228,7 @@ export class NotebookVFS implements VFS {
219228
/** Create a directory/bucket */
220229
public async mkdir(opts: Pick<Arguments, 'argvNoOptions'>): Promise<void> {
221230
const mountPath = opts.argvNoOptions[opts.argvNoOptions.indexOf('mkdir') + 1]
222-
this.trie.map(mountPath, { mountPath })
231+
this.trie.map(mountPath, { mountPath, isDirectory: true })
223232
}
224233

225234
/** Remove a directory/bucket */

0 commit comments

Comments
 (0)