Files
dap/ts/classes.sshconfig.ts
T

306 lines
8.6 KiB
TypeScript
Raw Normal View History

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);
}
}