306 lines
8.6 KiB
TypeScript
306 lines
8.6 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import type {
|
|
ISshConfigFile,
|
|
ISshConfigHost,
|
|
ISshConfigOptions,
|
|
ISshConfigReadResult,
|
|
} from './types.js';
|
|
|
|
export class SshConfig {
|
|
public readonly homeDir: string;
|
|
public readonly mainConfigPath: string;
|
|
|
|
constructor(options: ISshConfigOptions = {}) {
|
|
this.homeDir = options.homeDir ?? plugins.os.homedir();
|
|
this.mainConfigPath = this.expandHome(options.mainConfigPath ?? plugins.path.join(this.homeDir, '.ssh', 'config'));
|
|
}
|
|
|
|
public async read(): Promise<ISshConfigReadResult> {
|
|
const files: ISshConfigFile[] = [];
|
|
const hosts: ISshConfigHost[] = [];
|
|
const visitedFiles = new Set<string>();
|
|
await this.readFileRecursive(this.mainConfigPath, visitedFiles, files, hosts, true);
|
|
return {
|
|
mainConfigPath: this.mainConfigPath,
|
|
files,
|
|
hosts,
|
|
};
|
|
}
|
|
|
|
public async getHost(alias: string): Promise<ISshConfigHost | undefined> {
|
|
const result = await this.read();
|
|
return result.hosts.find((host) => host.patterns.includes(alias));
|
|
}
|
|
|
|
public getDisplayHosts(result: ISshConfigReadResult): ISshConfigHost[] {
|
|
return result.hosts.filter((host) => host.patterns.some((pattern) => !this.patternHasWildcard(pattern)));
|
|
}
|
|
|
|
public getFirstOption(host: ISshConfigHost, optionName: string): string | undefined {
|
|
return host.options[optionName.toLowerCase()]?.[0];
|
|
}
|
|
|
|
public expandHome(filePath: string): string {
|
|
if (filePath === '~') {
|
|
return this.homeDir;
|
|
}
|
|
if (filePath.startsWith('~/')) {
|
|
return plugins.path.join(this.homeDir, filePath.slice(2));
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
private async readFileRecursive(
|
|
filePath: string,
|
|
visitedFiles: Set<string>,
|
|
files: ISshConfigFile[],
|
|
hosts: ISshConfigHost[],
|
|
required: boolean
|
|
): Promise<void> {
|
|
const resolvedPath = plugins.path.resolve(this.expandHome(filePath));
|
|
if (visitedFiles.has(resolvedPath)) {
|
|
return;
|
|
}
|
|
visitedFiles.add(resolvedPath);
|
|
|
|
let content = '';
|
|
try {
|
|
content = await plugins.fs.readFile(resolvedPath, 'utf8');
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code === 'ENOENT' && !required) {
|
|
return;
|
|
}
|
|
if (nodeError.code === 'ENOENT' && required) {
|
|
files.push({ filePath: resolvedPath, lines: [] });
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const lines = content.split(/\r?\n/);
|
|
files.push({ filePath: resolvedPath, lines });
|
|
const includePatterns: string[] = [];
|
|
this.parseHostsInFile(resolvedPath, lines, hosts, includePatterns);
|
|
|
|
for (const includePattern of includePatterns) {
|
|
const includeFiles = await this.expandIncludePattern(includePattern, resolvedPath);
|
|
for (const includeFile of includeFiles) {
|
|
await this.readFileRecursive(includeFile, visitedFiles, files, hosts, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private parseHostsInFile(
|
|
filePath: string,
|
|
lines: string[],
|
|
hosts: ISshConfigHost[],
|
|
includePatterns: string[]
|
|
): void {
|
|
let currentHost: ISshConfigHost | undefined;
|
|
|
|
const closeCurrentHost = (endLine: number) => {
|
|
if (!currentHost) {
|
|
return;
|
|
}
|
|
currentHost.endLine = endLine;
|
|
currentHost.rawLines = lines.slice(currentHost.startLine - 1, endLine);
|
|
hosts.push(currentHost);
|
|
currentHost = undefined;
|
|
};
|
|
|
|
lines.forEach((rawLine, index) => {
|
|
const lineNumber = index + 1;
|
|
const withoutComment = this.stripInlineComment(rawLine).trim();
|
|
if (!withoutComment) {
|
|
return;
|
|
}
|
|
|
|
const directiveMatch = withoutComment.match(/^([^\s]+)\s+(.*)$/);
|
|
if (!directiveMatch) {
|
|
return;
|
|
}
|
|
|
|
const directive = directiveMatch[1].toLowerCase();
|
|
const value = directiveMatch[2].trim();
|
|
|
|
if (directive === 'include') {
|
|
includePatterns.push(...this.shellSplit(value));
|
|
return;
|
|
}
|
|
|
|
if (directive === 'host' || directive === 'match') {
|
|
closeCurrentHost(lineNumber - 1);
|
|
}
|
|
|
|
if (directive === 'host') {
|
|
const previousLine = lines[index - 1] ?? '';
|
|
const dapBeginMatch = previousLine.match(/^\s*#\s*dap:begin\s+(.+)\s*$/i);
|
|
currentHost = {
|
|
patterns: this.shellSplit(value),
|
|
filePath,
|
|
startLine: lineNumber,
|
|
endLine: lineNumber,
|
|
options: {},
|
|
rawLines: [],
|
|
dapManaged: Boolean(dapBeginMatch),
|
|
dapManagedName: dapBeginMatch?.[1]?.trim(),
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (currentHost) {
|
|
const normalizedDirective = directive.toLowerCase();
|
|
currentHost.options[normalizedDirective] = currentHost.options[normalizedDirective] ?? [];
|
|
currentHost.options[normalizedDirective].push(value);
|
|
}
|
|
});
|
|
|
|
closeCurrentHost(lines.length);
|
|
}
|
|
|
|
private stripInlineComment(line: string): string {
|
|
let quote: string | undefined;
|
|
let escaped = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if ((char === '"' || char === "'") && !quote) {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === quote) {
|
|
quote = undefined;
|
|
continue;
|
|
}
|
|
if (char === '#' && !quote && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
return line.slice(0, i);
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
|
|
public shellSplit(input: string): string[] {
|
|
const tokens: string[] = [];
|
|
let current = '';
|
|
let quote: string | undefined;
|
|
let escaped = false;
|
|
|
|
for (const char of input) {
|
|
if (escaped) {
|
|
current += char;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if ((char === '"' || char === "'") && !quote) {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === quote) {
|
|
quote = undefined;
|
|
continue;
|
|
}
|
|
if (/\s/.test(char) && !quote) {
|
|
if (current) {
|
|
tokens.push(current);
|
|
current = '';
|
|
}
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
|
|
if (current) {
|
|
tokens.push(current);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
private async expandIncludePattern(pattern: string, includingFilePath: string): Promise<string[]> {
|
|
const expandedPattern = this.expandHome(pattern);
|
|
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
|
|
? expandedPattern
|
|
: plugins.path.join(plugins.path.dirname(includingFilePath), expandedPattern);
|
|
return this.expandGlob(absolutePattern);
|
|
}
|
|
|
|
private async expandGlob(pattern: string): Promise<string[]> {
|
|
const absolutePattern = plugins.path.resolve(pattern);
|
|
if (!this.pathHasWildcard(absolutePattern)) {
|
|
return plugins.fsSync.existsSync(absolutePattern) ? [absolutePattern] : [];
|
|
}
|
|
|
|
const parsedPath = plugins.path.parse(absolutePattern);
|
|
const segments = absolutePattern.slice(parsedPath.root.length).split(plugins.path.sep).filter(Boolean);
|
|
const matches: string[] = [];
|
|
|
|
const walk = async (currentPath: string, segmentIndex: number): Promise<void> => {
|
|
if (segmentIndex >= segments.length) {
|
|
try {
|
|
const stat = await plugins.fs.stat(currentPath);
|
|
if (stat.isFile()) {
|
|
matches.push(currentPath);
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const segment = segments[segmentIndex];
|
|
if (!this.pathHasWildcard(segment)) {
|
|
await walk(plugins.path.join(currentPath, segment), segmentIndex + 1);
|
|
return;
|
|
}
|
|
|
|
let entries: plugins.fsSync.Dirent[];
|
|
try {
|
|
entries = await plugins.fs.readdir(currentPath, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const matcher = this.globSegmentToRegExp(segment);
|
|
for (const entry of entries) {
|
|
if (!matcher.test(entry.name)) {
|
|
continue;
|
|
}
|
|
const nextPath = plugins.path.join(currentPath, entry.name);
|
|
if (segmentIndex === segments.length - 1 || entry.isDirectory()) {
|
|
await walk(nextPath, segmentIndex + 1);
|
|
}
|
|
}
|
|
};
|
|
|
|
await walk(parsedPath.root, 0);
|
|
return matches.sort();
|
|
}
|
|
|
|
private globSegmentToRegExp(segment: string): RegExp {
|
|
const source = segment
|
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
.replace(/\*/g, '[^/]*')
|
|
.replace(/\?/g, '[^/]');
|
|
return new RegExp(`^${source}$`);
|
|
}
|
|
|
|
private patternHasWildcard(pattern: string): boolean {
|
|
return /[*?]/.test(pattern);
|
|
}
|
|
|
|
private pathHasWildcard(filePath: string): boolean {
|
|
return /[*?]/.test(filePath);
|
|
}
|
|
}
|