Make URIs act as absolute paths, `root` field only hints to commands

pull/285/head
Kelvin Schoofs 3 years ago
parent f17dae8e46
commit 315c25568f

@ -376,6 +376,8 @@ function parseFlagList(list: string[] | undefined, origin: string): Record<strin
- Enables debug logging in the ssh2 library (set at the start of each connection)
WINDOWS_COMMAND_SEPARATOR (boolean) (default=false)
- Makes it that commands are joined together using ` && ` instead of `; `
CHECK_HOME (boolean) (default=true)
- Determines whether we check if the home directory exists during `createFileSystem` in the Manager
*/
export type FlagValue = string | boolean | null;
export type FlagCombo = [value: FlagValue, origin: string];

@ -1,15 +1,17 @@
import type { Client } from 'ssh2';
import type { Client, ClientChannel } from 'ssh2';
import * as vscode from 'vscode';
import { configMatches, getFlagBoolean, loadConfigs } from './config';
import type { EnvironmentVariable, FileSystemConfig } from './fileSystemConfig';
import { Logging } from './logging';
import type { SSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem';
import { toPromise } from './toPromise';
export interface Connection {
config: FileSystemConfig;
actualConfig: FileSystemConfig;
client: Client;
home: string;
environment: EnvironmentVariable[];
terminals: SSHPseudoTerminal[];
filesystems: SSHFileSystem[];
@ -52,6 +54,17 @@ export function joinCommands(commands: string | string[] | undefined, separator:
return commands.filter(c => c && c.trim()).join(separator);
}
async function tryGetHome(ssh: Client): Promise<string | null> {
const exec = await toPromise<ClientChannel>(cb => ssh.exec('echo Home: ~', cb));
let home = '';
exec.stdout.on('data', (chunk: any) => home += chunk);
await toPromise(cb => exec.on('close', cb));
if (!home) return null;
const mat = home.match(/^Home: (.*?)\r?\n?$/);
if (!mat) return null;
return mat[1];
}
export class ConnectionManager {
protected onConnectionAddedEmitter = new vscode.EventEmitter<Connection>();
protected onConnectionRemovedEmitter = new vscode.EventEmitter<Connection>();
@ -81,22 +94,32 @@ export class ConnectionManager {
const logging = Logging.scope(`createConnection(${name},${config ? 'config' : 'undefined'})`);
logging.info(`Creating a new connection for '${name}'`);
const { createSSH, calculateActualConfig } = await import('./connect');
// Query and calculate the actual config
config = config || (await loadConfigs()).find(c => c.name === name);
if (!config) throw new Error(`No configuration with name '${name}' found`);
const actualConfig = await calculateActualConfig(config);
if (!actualConfig) throw new Error('Connection cancelled');
// Start the actual SSH connection
const client = await createSSH(actualConfig);
if (!client) throw new Error(`Could not create SSH session for '${name}'`);
// Query home directory
const home = await tryGetHome(client);
if (!home) {
await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay');
throw new Error(`Could not detect home directory`);
}
// Calculate the environment
const environment: EnvironmentVariable[] = mergeEnvironment([], config.environment);
// Set up the Connection object
let timeoutCounter = 0;
const con: Connection = {
config, client, actualConfig, environment,
config, client, actualConfig, home, environment,
terminals: [],
filesystems: [],
pendingUserCount: 0,
idleTimer: setInterval(() => { // Automatically close connection when idle for a while
timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0;
if (con.pendingUserCount) return;
if (con.pendingUserCount) return; // Still got starting filesystems/terminals on this connection
con.filesystems = con.filesystems.filter(fs => !fs.closed && !fs.closing);
if (con.filesystems.length) return; // Still got active filesystems on this connection
if (con.terminals.length) return; // Still got active terminals on this connection

@ -88,7 +88,7 @@ export interface FileSystemConfig extends ConnectConfig {
group?: string;
/** Whether to merge this "lower" config (e.g. from workspace settings) into higher configs (e.g. from global settings) */
merge?: boolean;
/** Path on the remote server where the root path in vscode should point to. Defaults to / */
/** Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/` */
root?: string;
/** A name of a PuTTY session, or `true` to find the PuTTY session from the host address */
putty?: string | boolean;

@ -1,6 +1,4 @@
import * as path from 'path';
import * as vscode from 'vscode';
import type { FileSystemConfig } from './fileSystemConfig';
import { Logging } from './logging';
import type { Manager } from './manager';
@ -12,20 +10,6 @@ function isWorkspaceStale(uri: vscode.Uri) {
return true;
}
export function getRemotePath(config: FileSystemConfig, relativePath: string | vscode.Uri) {
if (relativePath instanceof vscode.Uri) {
if (relativePath.authority !== config.name)
throw new Error(`Uri authority for '${relativePath}' does not match config with name '${config.name}'`);
relativePath = relativePath.path;
}
if (relativePath.startsWith('/')) relativePath = relativePath.substr(1);
if (!config.root) return '/' + relativePath;
const result = path.posix.join(config.root, relativePath);
if (result.startsWith('~')) return result; // Home directory, leave the ~/
if (result.startsWith('/')) return result; // Already starts with /
return '/' + result; // Add the / to make sure it isn't seen as a relative path
}
export class FileSystemRouter implements vscode.FileSystemProvider {
public onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]>;
protected onDidChangeFileEmitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();

@ -1,28 +1,14 @@
import * as path from 'path';
import type { Client, ClientChannel } from 'ssh2';
import * as vscode from 'vscode';
import { getConfig, getFlagBoolean, loadConfigsRaw } from './config';
import { Connection, ConnectionManager, joinCommands } from './connection';
import type { FileSystemConfig } from './fileSystemConfig';
import { getRemotePath } from './fileSystemRouter';
import { Logging, LOGGING_NO_STACKTRACE } from './logging';
import { isSSHPseudoTerminal, replaceVariables, replaceVariablesRecursive } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem';
import { catchingPromise, toPromise } from './toPromise';
import { catchingPromise } from './toPromise';
import type { Navigation } from './webviewMessages';
async function tryGetHome(ssh: Client): Promise<string | null> {
const exec = await toPromise<ClientChannel>(cb => ssh.exec('echo Home: ~', cb));
let home = '';
exec.stdout.on('data', (chunk: any) => home += chunk);
await toPromise(cb => exec.on('close', cb));
if (!home) return null;
const mat = home.match(/^Home: (.*?)\r?\n?$/);
if (!mat) return null;
return mat[1];
}
function commandArgumentToName(arg?: string | FileSystemConfig | Connection): string {
if (!arg) return 'undefined';
if (typeof arg === 'string') return arg;
@ -71,37 +57,10 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
config = con.actualConfig;
const { getSFTP } = await import('./connect');
const { SSHFileSystem } = await import('./sshFileSystem');
// Query/calculate the root directory
let root = config!.root || '/';
if (root.startsWith('~')) {
const home = await tryGetHome(con.client);
if (!home) {
await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay');
return reject();
}
root = root.replace(/^~/, home.replace(/\/$/, ''));
}
// Create the actual SFTP session (using the connection's actualConfig, otherwise it'll reprompt for passwords etc)
const sftp = await getSFTP(con.client, con.actualConfig);
const fs = new SSHFileSystem(name, sftp, root, config!);
const fs = new SSHFileSystem(name, sftp, con.actualConfig);
Logging.info(`Created SSHFileSystem for ${name}, reading root directory...`);
// Sanity check that we can actually access the root directory (maybe it requires permissions we don't have)
try {
const rootUri = vscode.Uri.parse(`ssh://${name}/`);
const stat = await fs.stat(rootUri);
// tslint:disable-next-line:no-bitwise
if (!(stat.type & vscode.FileType.Directory)) {
throw vscode.FileSystemError.FileNotADirectory(rootUri);
}
} catch (e) {
let message = `Couldn't read the root directory '${fs.root}' on the server for SSH FS '${name}'`;
if (e instanceof vscode.FileSystemError) {
message = `Path '${fs.root}' in SSH FS '${name}' is not a directory`;
}
Logging.error(e);
await vscode.window.showErrorMessage(message, 'Okay');
return reject();
}
this.connectionManager.update(con, con => con.filesystems.push(fs));
this.fileSystems.push(fs);
delete this.creatingFileSystems[name];
@ -112,6 +71,23 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
// con.client.once('close', hadError => !fs.closing && this.promptReconnect(name));
this.connectionManager.update(con, con => con.pendingUserCount--);
// Sanity check that we can access the home directory
const [flagCH] = getFlagBoolean('CHECK_HOME', true, config.flags);
if (flagCH) try {
const homeUri = vscode.Uri.parse(`ssh://${name}/${con.home}`);
const stat = await fs.stat(homeUri);
if (!(stat.type & vscode.FileType.Directory)) {
throw vscode.FileSystemError.FileNotADirectory(homeUri);
}
} catch (e) {
let message = `Couldn't read the home directory '${con.home}' on the server for SSH FS '${name}', this might be a sign of bad permissions`;
if (e instanceof vscode.FileSystemError) {
message = `The home directory '${con.home}' in SSH FS '${name}' is not a directory, this might be a sign of bad permissions`;
}
Logging.error(e);
const answer = await vscode.window.showWarningMessage(message, 'Stop', 'Ignore');
if (answer === 'Okay') return reject(new Error('User stopped filesystem creation after unaccessible home directory error'));
}
return resolve(fs);
}).catch((e) => {
if (con) this.connectionManager.update(con, con => con.pendingUserCount--); // I highly doubt resolve(fs) will error
@ -139,11 +115,9 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
const { createTerminal } = await import('./pseudoTerminal');
// Create connection (early so we have .actualConfig.root)
const con = (config && 'client' in config) ? config : await this.connectionManager.createConnection(name, config);
// Calculate working directory if applicable
const workingDirectory = uri && getRemotePath(con.actualConfig, uri);
// Create pseudo terminal
this.connectionManager.update(con, con => con.pendingUserCount++);
const pty = await createTerminal({ connection: con, workingDirectory });
const pty = await createTerminal({ connection: con, workingDirectory: uri?.path || con.actualConfig.root });
pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty)));
this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--));
// Create and show the graphical representation
@ -221,22 +195,14 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
while (true) {
const match = PATH_REGEX.exec(line);
if (!match) break;
const [filepath] = match;
let relative: string | undefined;
for (const fs of conn.filesystems) {
const rel = path.posix.relative(fs.root, filepath);
if (!rel.startsWith('../') && !path.posix.isAbsolute(rel)) {
relative = rel;
break;
}
}
const uri = relative ? vscode.Uri.parse(`ssh://${conn.actualConfig.name}/${relative}`) : undefined;
// TODO: Support absolute path stuff, maybe `ssh://${conn.actualConfig.name}:root//${filepath}` or so?
let [filepath] = match;
if (filepath.startsWith('~')) filepath = conn.home + filepath.substring(1);
const uri = vscode.Uri.parse(`ssh://${conn.actualConfig.name}/${filepath}`);
links.push({
uri,
startIndex: match.index,
length: filepath.length,
tooltip: uri ? '[SSH FS] Open file' : '[SSH FS] Cannot open remote file outside configured root directory',
tooltip: '[SSH FS] Open file',
});
}
return links;
@ -246,13 +212,19 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
await vscode.window.showTextDocument(link.uri);
}
/* Commands (stuff for e.g. context menu for ssh-configs tree) */
public commandConnect(config: FileSystemConfig) {
public async commandConnect(config: FileSystemConfig) {
Logging.info(`Command received to connect ${config.name}`);
const folders = vscode.workspace.workspaceFolders!;
const folder = folders && folders.find(f => f.uri.scheme === 'ssh' && f.uri.authority === config.name);
if (folder) return vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
let { root = '/' } = config;
if (root.startsWith('~')) {
const con = this.connectionManager.getActiveConnection(config.name, config);
if (con) root = con.home + root.substring(1);
}
if (root.startsWith('/')) root = root.substring(1);
vscode.workspace.updateWorkspaceFolders(folders ? folders.length : 0, 0, {
uri: vscode.Uri.parse(`ssh://${config.name}/`),
uri: vscode.Uri.parse(`ssh://${config.name}/${root}`),
name: `SSH FS - ${config.label || config.name}`,
});
}
@ -286,8 +258,6 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
public async commandTerminal(target: FileSystemConfig | Connection, uri?: vscode.Uri) {
Logging.info(`Command received to open a terminal for ${commandArgumentToName(target)}${uri ? ` in ${uri}` : ''}`);
const config = 'client' in target ? target.actualConfig : target;
// If no Uri is given, default to ssh://<target>/ which should respect config.root
uri = uri || vscode.Uri.parse(`ssh://${config.name}/`, true);
try {
await this.createTerminal(config.label || config.name, target, uri);
} catch (e) {

@ -5,7 +5,6 @@ import * as vscode from "vscode";
import { getFlagBoolean } from './config';
import { Connection, environmentToExportString, joinCommands, mergeEnvironment } from './connection';
import type { EnvironmentVariable, FileSystemConfig } from "./fileSystemConfig";
import { getRemotePath } from './fileSystemRouter';
import { Logging, LOGGING_NO_STACKTRACE } from "./logging";
import { toPromise } from "./toPromise";
@ -77,14 +76,14 @@ export function replaceVariables(value: string, config: FileSystemConfig): strin
switch (key) {
case 'remoteWorkspaceRoot':
case 'remoteWorkspaceFolder':
return getRemotePath(config, getFolderUri());
return getFolderUri().path;
case 'remoteWorkspaceRootFolderName':
case 'remoteWorkspaceFolderBasename':
return path.basename(getFolderUri().path);
case 'remoteFile':
return getRemotePath(config, getFilePath());
return getFilePath().path;
case 'remoteFileWorkspaceFolder':
return getRemotePath(config, getFolderPathForFile());
return getFolderPathForFile().path;
case 'remoteRelativeFile':
if (sshFolder || argument)
return path.relative(getFolderUri().path, getFilePath().path);
@ -181,6 +180,7 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
let { workingDirectory } = options;
workingDirectory = workingDirectory || actualConfig.root;
if (workingDirectory) {
// TODO: Maybe replace with `connection.home`?
if (workingDirectory.startsWith('~')) {
// So `cd "~/a/b/..." apparently doesn't work, but `~/"a/b/..."` does
// `"~"` would also fail but `~/""` works fine it seems

@ -42,9 +42,8 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
public onClose = this.onCloseEmitter.event;
public onDidChangeFile = this.onDidChangeFileEmitter.event;
protected logging: Logger;
constructor(public readonly authority: string, protected sftp: ssh2.SFTPWrapper,
public readonly root: string, public readonly config: FileSystemConfig) {
this.logging = Logging.scope(`SSHFileSystem(${root})`, false);
constructor(public readonly authority: string, protected sftp: ssh2.SFTPWrapper, public readonly config: FileSystemConfig) {
this.logging = Logging.scope(`SSHFileSystem(${authority})`, false);
this.sftp.on('end', () => (this.closed = true, this.onCloseEmitter.fire()));
this.logging.info('SSHFileSystem created');
}
@ -52,14 +51,6 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
this.closing = true;
this.sftp.end();
}
public relative(relPath: string) {
// ssh://a/b/c.d should result in relPath being "/b/c.d"
// So // means absolute path, / means relative path
// NOTE: Apparently VSCode automatically replaces multiple slashes with a single /
// (so the // part is useless right now)
if (relPath.startsWith('//')) return relPath.substr(1);
return path.posix.resolve(this.root, relPath.substr(1));
}
public continuePromise<T>(func: (cb: (err: Error | null | undefined, res?: T) => void) => boolean): Promise<T> {
return new Promise<T>((resolve, reject) => {
const exec = () => {
@ -85,7 +76,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
return new vscode.Disposable(() => { });
}
public async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
const stat = await this.continuePromise<ssh2s.Stats>(cb => this.sftp.stat(this.relative(uri.path), cb))
const stat = await this.continuePromise<ssh2s.Stats>(cb => this.sftp.stat(uri.path, cb))
.catch(e => this.handleError(uri, e, true) as never);
const { mtime, size } = stat;
let type = vscode.FileType.Unknown;
@ -100,7 +91,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
};
}
public async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
const entries = await this.continuePromise<ssh2s.FileEntry[]>(cb => this.sftp.readdir(this.relative(uri.path), cb))
const entries = await this.continuePromise<ssh2s.FileEntry[]>(cb => this.sftp.readdir(uri.path, cb))
.catch((e) => this.handleError(uri, e, true) as never);
return Promise.all(entries.map(async (file) => {
const furi = uri.with({ path: `${uri.path}${uri.path.endsWith('/') ? '' : '/'}${file.filename}` });
@ -121,11 +112,11 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
}));
}
public createDirectory(uri: vscode.Uri): void | Promise<void> {
return this.continuePromise<void>(cb => this.sftp.mkdir(this.relative(uri.path), cb)).catch(e => this.handleError(uri, e, true));
return this.continuePromise<void>(cb => this.sftp.mkdir(uri.path, cb)).catch(e => this.handleError(uri, e, true));
}
public readFile(uri: vscode.Uri): Uint8Array | Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const stream = this.sftp.createReadStream(this.relative(uri.path), { autoClose: true });
const stream = this.sftp.createReadStream(uri.path, { autoClose: true });
const bufs = [];
stream.on('data', bufs.push.bind(bufs));
stream.on('error', e => this.handleError(uri, e, reject));
@ -139,7 +130,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
let mode: number | string | undefined;
let fileExists = false;
try {
const stat = await this.continuePromise<ssh2s.Stats>(cb => this.sftp.stat(this.relative(uri.path), cb));
const stat = await this.continuePromise<ssh2s.Stats>(cb => this.sftp.stat(uri.path, cb));
mode = stat.mode;
fileExists = true;
} catch (e) {
@ -147,11 +138,11 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
mode = this.config.newFileMode;
} else {
this.handleError(uri, e);
vscode.window.showWarningMessage(`Couldn't read the permissions for '${this.relative(uri.path)}', permissions might be overwritten`);
vscode.window.showWarningMessage(`Couldn't read the permissions for '${uri.path}', permissions might be overwritten`);
}
}
mode = mode as number | undefined; // ssh2-streams supports an octal number as string, but ssh2's typings don't reflect this
const stream = this.sftp.createWriteStream(this.relative(uri.path), { mode, flags: 'w' });
const stream = this.sftp.createWriteStream(uri.path, { mode, flags: 'w' });
stream.on('error', e => this.handleError(uri, e, reject));
stream.end(content, () => {
this.onDidChangeFileEmitter.fire([{ uri, type: fileExists ? vscode.FileChangeType.Changed : vscode.FileChangeType.Created }]);
@ -164,18 +155,18 @@ export class SSHFileSystem implements vscode.FileSystemProvider {
const fireEvent = () => this.onDidChangeFileEmitter.fire([{ uri, type: vscode.FileChangeType.Deleted }]);
// tslint:disable no-bitwise */
if (stats.type & (vscode.FileType.SymbolicLink | vscode.FileType.File)) {
return this.continuePromise(cb => this.sftp.unlink(this.relative(uri.path), cb))
return this.continuePromise(cb => this.sftp.unlink(uri.path, cb))
.then(fireEvent).catch(e => this.handleError(uri, e, true));
} else if ((stats.type & vscode.FileType.Directory) && options.recursive) {
return this.continuePromise(cb => this.sftp.rmdir(this.relative(uri.path), cb))
return this.continuePromise(cb => this.sftp.rmdir(uri.path, cb))
.then(fireEvent).catch(e => this.handleError(uri, e, true));
}
return this.continuePromise(cb => this.sftp.unlink(this.relative(uri.path), cb))
return this.continuePromise(cb => this.sftp.unlink(uri.path, cb))
.then(fireEvent).catch(e => this.handleError(uri, e, true));
// tslint:enable no-bitwise */
}
public rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }): void | Promise<void> {
return this.continuePromise<void>(cb => this.sftp.rename(this.relative(oldUri.path), this.relative(newUri.path), cb))
return this.continuePromise<void>(cb => this.sftp.rename(oldUri.path, newUri.path, cb))
.then(() => this.onDidChangeFileEmitter.fire([
{ uri: oldUri, type: vscode.FileChangeType.Deleted },
{ uri: newUri, type: vscode.FileChangeType.Created }

@ -65,8 +65,10 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem |
contextValue: 'connection',
};
} else if ('onDidChangeFile' in item) { // SSHFileSystem
const { label, name, group } = item.config;
const description = group ? `${group}.${name} ` : (label && name);
return {
item, description: item.root, contextValue: 'filesystem',
item, description, contextValue: 'filesystem',
label: `${iconInLabel ? '$(root-folder) ' : ''}ssh://${item.authority}/`,
iconPath: asAbsolutePath?.('resources/icon.svg'),
}

@ -70,7 +70,7 @@ export function port(config: FileSystemConfig, onChange: FSCChanged<'port'>): Re
export function root(config: FileSystemConfig, onChange: FSCChanged<'root'>): React.ReactElement {
const callback = (value: string) => onChange('root', value);
const description = 'Path on the remote server where the root path in vscode should point to. Defaults to /';
const description = 'Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/`';
return <FieldString key="root" label="Root" value={config.root} onChange={callback} optional validator={pathValidator} description={description} />
}

@ -88,7 +88,7 @@ export interface FileSystemConfig extends ConnectConfig {
group?: string;
/** Whether to merge this "lower" config (e.g. from workspace settings) into higher configs (e.g. from global settings) */
merge?: boolean;
/** Path on the remote server where the root path in vscode should point to. Defaults to / */
/** Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/` */
root?: string;
/** A name of a PuTTY session, or `true` to find the PuTTY session from the host address */
putty?: string | boolean;

Loading…
Cancel
Save