feat(core): add SSH data access proxy CLI and core managers
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user