mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
352 lines
7.9 KiB
TypeScript
352 lines
7.9 KiB
TypeScript
import { existsSync, globSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
import { homedir, hostname, userInfo } from 'node:os'
|
|
import { posix, win32 } from 'node:path'
|
|
|
|
type PathApi = typeof posix | typeof win32
|
|
|
|
type IncludeExpansionContext = {
|
|
cache: Map<string, string>
|
|
home: string
|
|
pathApi: PathApi
|
|
rootDir: string
|
|
shortHostname: string
|
|
uid?: string
|
|
username: string
|
|
}
|
|
|
|
const MAX_INCLUDE_GLOB_MATCHES = 256
|
|
const MAX_INCLUDE_FILE_BYTES = 1024 * 1024
|
|
const TARGET_DEPENDENT_INCLUDE_TOKENS = new Set(['h', 'n', 'p', 'r', 'j', 'k', 'C'])
|
|
|
|
export function expandSshConfigIncludes(configPath: string): string {
|
|
const home = homedir()
|
|
const pathApi = getPathApi(configPath)
|
|
const currentUser = getCurrentUser()
|
|
const localHostname = hostname()
|
|
|
|
const context: IncludeExpansionContext = {
|
|
cache: new Map(),
|
|
home,
|
|
pathApi,
|
|
rootDir: pathApi.dirname(configPath),
|
|
shortHostname: localHostname.split('.')[0] || localHostname,
|
|
uid: getCurrentUid(),
|
|
username: currentUser
|
|
}
|
|
|
|
return expandSshConfigFile(configPath, context, []).join('\n')
|
|
}
|
|
|
|
function expandSshConfigFile(
|
|
filePath: string,
|
|
context: IncludeExpansionContext,
|
|
activeStack: string[]
|
|
): string[] {
|
|
const canonicalPath = getCanonicalPath(filePath)
|
|
if (!canonicalPath || activeStack.includes(canonicalPath)) {
|
|
return []
|
|
}
|
|
|
|
const rawContent = readCachedFile(canonicalPath, context)
|
|
if (rawContent === null) {
|
|
return []
|
|
}
|
|
|
|
const expandedLines: string[] = []
|
|
const nextStack = [...activeStack, canonicalPath]
|
|
|
|
for (const line of rawContent.split(/\r?\n/)) {
|
|
const includeArgs = parseIncludeDirective(line)
|
|
if (!includeArgs) {
|
|
expandedLines.push(line)
|
|
continue
|
|
}
|
|
|
|
for (const includeArg of includeArgs) {
|
|
for (const matchedPath of resolveIncludePaths(includeArg, context)) {
|
|
appendExpandedLines(expandedLines, expandSshConfigFile(matchedPath, context, nextStack))
|
|
}
|
|
}
|
|
}
|
|
|
|
return expandedLines
|
|
}
|
|
|
|
function appendExpandedLines(target: string[], lines: readonly string[]): void {
|
|
// Why: SSH config includes are user-controlled files, and a large included
|
|
// file can exceed the JavaScript call argument limit when spread into push.
|
|
for (const line of lines) {
|
|
target.push(line)
|
|
}
|
|
}
|
|
|
|
function readCachedFile(filePath: string, context: IncludeExpansionContext): string | null {
|
|
const cached = context.cache.get(filePath)
|
|
if (cached !== undefined) {
|
|
return cached
|
|
}
|
|
|
|
if (!isReadableRegularFile(filePath)) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(filePath, 'utf-8')
|
|
context.cache.set(filePath, content)
|
|
return content
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function parseIncludeDirective(line: string): string[] | null {
|
|
const trimmed = line.trimStart()
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
return null
|
|
}
|
|
|
|
const match = trimmed.match(/^([^=\s]+)(?:\s*=\s*|\s+)(.*)$/)
|
|
if (!match || match[1].toLowerCase() !== 'include') {
|
|
return null
|
|
}
|
|
|
|
const args = splitQuotedArguments(match[2])
|
|
return args.length > 0 ? args : null
|
|
}
|
|
|
|
function splitQuotedArguments(input: string): string[] {
|
|
const args: string[] = []
|
|
let current = ''
|
|
let inQuotes = false
|
|
|
|
for (let i = 0; i < input.length; i += 1) {
|
|
const char = input[i]
|
|
|
|
if (inQuotes && char === '\\' && input[i + 1] === '"') {
|
|
current += '"'
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes
|
|
continue
|
|
}
|
|
|
|
if (!inQuotes && char === '#') {
|
|
break
|
|
}
|
|
|
|
if (!inQuotes && /\s/.test(char)) {
|
|
if (current) {
|
|
args.push(current)
|
|
current = ''
|
|
}
|
|
continue
|
|
}
|
|
|
|
current += char
|
|
}
|
|
|
|
if (current) {
|
|
args.push(current)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
function resolveIncludePaths(pattern: string, context: IncludeExpansionContext): string[] {
|
|
const withEnv = expandEnvironmentVariables(pattern)
|
|
if (withEnv === null) {
|
|
return []
|
|
}
|
|
|
|
const withTokens = expandIncludeTokens(withEnv, context)
|
|
if (withTokens === null) {
|
|
return []
|
|
}
|
|
|
|
const absolutePattern = resolveIncludePatternPath(withTokens, context)
|
|
if (hasGlobPattern(absolutePattern)) {
|
|
try {
|
|
const matches = globSync(absolutePattern).sort((left, right) => left.localeCompare(right))
|
|
if (matches.length > MAX_INCLUDE_GLOB_MATCHES) {
|
|
console.warn(
|
|
`[ssh] Include pattern "${absolutePattern}" matched ${matches.length} files; processing first ${MAX_INCLUDE_GLOB_MATCHES}`
|
|
)
|
|
return matches.slice(0, MAX_INCLUDE_GLOB_MATCHES)
|
|
}
|
|
return matches
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
return existsSync(absolutePattern) ? [absolutePattern] : []
|
|
}
|
|
|
|
function expandEnvironmentVariables(input: string): string | null {
|
|
let missing = false
|
|
const expanded = input.replaceAll(/\$\{([^}]+)\}/g, (_, name: string) => {
|
|
const value = process.env[name]
|
|
if (value === undefined) {
|
|
missing = true
|
|
return ''
|
|
}
|
|
return value
|
|
})
|
|
|
|
return missing ? null : expanded
|
|
}
|
|
|
|
function expandIncludeTokens(input: string, context: IncludeExpansionContext): string | null {
|
|
let output = ''
|
|
|
|
for (let i = 0; i < input.length; i += 1) {
|
|
const char = input[i]
|
|
if (char !== '%') {
|
|
output += char
|
|
continue
|
|
}
|
|
|
|
const token = input[i + 1]
|
|
if (!token) {
|
|
output += char
|
|
continue
|
|
}
|
|
|
|
if (token === '%') {
|
|
output += '%'
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
if (TARGET_DEPENDENT_INCLUDE_TOKENS.has(token)) {
|
|
return null
|
|
}
|
|
|
|
if (token === 'd') {
|
|
output += context.home
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
if (token === 'u') {
|
|
output += context.username
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
if (token === 'i') {
|
|
if (!context.uid) {
|
|
return null
|
|
}
|
|
output += context.uid
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
if (token === 'l') {
|
|
output += hostname()
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
if (token === 'L') {
|
|
output += context.shortHostname
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
output += `%${token}`
|
|
i += 1
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
function resolveIncludePatternPath(input: string, context: IncludeExpansionContext): string {
|
|
const pathApi = context.pathApi
|
|
if (input === '~') {
|
|
return context.home
|
|
}
|
|
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
|
return pathApi.join(context.home, input.slice(2))
|
|
}
|
|
|
|
if (pathApi.isAbsolute(input)) {
|
|
return pathApi.normalize(input)
|
|
}
|
|
|
|
return pathApi.normalize(pathApi.join(context.rootDir, input))
|
|
}
|
|
|
|
function hasGlobPattern(input: string): boolean {
|
|
return /[*?[]/.test(input)
|
|
}
|
|
|
|
function getCanonicalPath(filePath: string): string | null {
|
|
try {
|
|
return realpathSync.native(filePath)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isReadableRegularFile(filePath: string): boolean {
|
|
try {
|
|
const stats = statSync(filePath)
|
|
if (!stats.isFile()) {
|
|
console.warn(`[ssh] Skipping SSH config include "${filePath}": not a regular file`)
|
|
return false
|
|
}
|
|
if (stats.size > MAX_INCLUDE_FILE_BYTES) {
|
|
console.warn(
|
|
`[ssh] Skipping SSH config include "${filePath}": size ${stats.size} exceeds ${MAX_INCLUDE_FILE_BYTES} bytes`
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function getCurrentUid(): string | undefined {
|
|
try {
|
|
const info = userInfo()
|
|
if (typeof info.uid === 'number' && info.uid >= 0) {
|
|
return String(info.uid)
|
|
}
|
|
} catch {
|
|
return undefined
|
|
}
|
|
|
|
if (typeof process.getuid === 'function') {
|
|
try {
|
|
return String(process.getuid())
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function getCurrentUser(): string {
|
|
try {
|
|
const info = userInfo()
|
|
if (info.username) {
|
|
return info.username
|
|
}
|
|
} catch {
|
|
// Fall back to environment variables below.
|
|
}
|
|
|
|
return process.env.USER ?? process.env.USERNAME ?? ''
|
|
}
|
|
|
|
function getPathApi(filePath: string): PathApi {
|
|
return /^[a-zA-Z]:[\\/]/.test(filePath) || filePath.startsWith('\\\\') ? win32 : posix
|
|
}
|