mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { lstat, mkdir, readdir, readFile, readlink, rename, rmdir, stat } from 'node:fs/promises'
|
|
import { basename, dirname, join } from 'node:path'
|
|
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
|
import { isPermissionError } from '../filesystem/permission-errors'
|
|
import { isMissingError } from './cli-install-errors'
|
|
import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell/launch/shell-argument-quoting'
|
|
|
|
export type EntryIdentity = {
|
|
dev: bigint
|
|
ino: bigint
|
|
mode: bigint
|
|
size: bigint
|
|
ctimeNs: bigint
|
|
}
|
|
|
|
export type EntrySnapshot = { identity: EntryIdentity; isSymbolicLink: boolean }
|
|
export type CommandQuarantine = {
|
|
directoryPath: string
|
|
heldPath: string
|
|
snapshot: EntrySnapshot | null
|
|
}
|
|
export type StableCommandInspection = {
|
|
fileSha256: string | null
|
|
rawSymlinkTarget: string | null
|
|
snapshot: EntrySnapshot | null
|
|
status: CliInstallStatus
|
|
}
|
|
|
|
const STABLE_INSPECTION_ATTEMPTS = 3
|
|
|
|
export async function readEntrySnapshot(path: string): Promise<EntrySnapshot | null> {
|
|
try {
|
|
const stats = await lstat(path, { bigint: true })
|
|
return {
|
|
identity: {
|
|
dev: stats.dev,
|
|
ino: stats.ino,
|
|
mode: stats.mode,
|
|
size: stats.size,
|
|
ctimeNs: stats.ctimeNs
|
|
},
|
|
isSymbolicLink: stats.isSymbolicLink()
|
|
}
|
|
} catch (error) {
|
|
if (isMissingError(error)) {
|
|
return null
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function hasSameIdentity(left: EntryIdentity | null, right: EntryIdentity | null): boolean {
|
|
return (
|
|
left === right ||
|
|
(left !== null && right !== null && left.dev === right.dev && left.ino === right.ino)
|
|
)
|
|
}
|
|
|
|
/** Whether both paths name one directory entry, even through aliased or differently cased parents. */
|
|
export async function isSameDirectoryEntry(left: string, right: string): Promise<boolean> {
|
|
const [leftEntry, rightEntry] = await Promise.all([
|
|
readEntrySnapshot(left),
|
|
readEntrySnapshot(right)
|
|
])
|
|
if (!leftEntry || !rightEntry || !hasSameIdentity(leftEntry.identity, rightEntry.identity)) {
|
|
return false
|
|
}
|
|
// Parent identity keeps a hard link to the launcher in another directory replaceable.
|
|
const [leftParent, rightParent] = await Promise.all([
|
|
stat(dirname(left), { bigint: true }),
|
|
stat(dirname(right), { bigint: true })
|
|
])
|
|
if (leftParent.dev !== rightParent.dev || leftParent.ino !== rightParent.ino) {
|
|
return false
|
|
}
|
|
const leftName = basename(left)
|
|
const rightName = basename(right)
|
|
if (leftName === rightName) {
|
|
return true
|
|
}
|
|
// Names differing only by case alias one entry unless both are listed (separate hard links).
|
|
const entries = await readdir(dirname(left))
|
|
return (
|
|
leftName.toLowerCase() === rightName.toLowerCase() &&
|
|
!(entries.includes(leftName) && entries.includes(rightName))
|
|
)
|
|
}
|
|
|
|
export function hasSameSnapshot(left: EntrySnapshot | null, right: EntrySnapshot | null): boolean {
|
|
return (
|
|
left === right ||
|
|
(left !== null &&
|
|
right !== null &&
|
|
hasSameIdentity(left.identity, right.identity) &&
|
|
left.identity.mode === right.identity.mode &&
|
|
left.identity.size === right.identity.size &&
|
|
left.identity.ctimeNs === right.identity.ctimeNs)
|
|
)
|
|
}
|
|
|
|
export async function hashCommandFile(path: string): Promise<string> {
|
|
return createHash('sha256')
|
|
.update(await readFile(path))
|
|
.digest('hex')
|
|
}
|
|
|
|
export async function inspectStableCommand(
|
|
commandPath: string,
|
|
inspect: () => Promise<CliInstallStatus>
|
|
): Promise<StableCommandInspection> {
|
|
for (let attempt = 0; attempt < STABLE_INSPECTION_ATTEMPTS; attempt += 1) {
|
|
const before = await readEntrySnapshot(commandPath)
|
|
const status = await inspect()
|
|
const afterInspection = await readEntrySnapshot(commandPath)
|
|
if (
|
|
!hasSameSnapshot(before, afterInspection) ||
|
|
(afterInspection === null) !== (status.state === 'not_installed')
|
|
) {
|
|
continue
|
|
}
|
|
let fileSha256: string | null = null
|
|
let rawSymlinkTarget: string | null = null
|
|
try {
|
|
if (afterInspection?.isSymbolicLink) {
|
|
rawSymlinkTarget = await readlink(commandPath)
|
|
} else if (afterInspection && status.state !== 'conflict') {
|
|
fileSha256 = await hashCommandFile(commandPath)
|
|
}
|
|
} catch (error) {
|
|
// Why: macOS enforces a symlink's own mode on readlink, so a 0700 root-owned link is stable but unreadable.
|
|
if (!afterInspection?.isSymbolicLink || !isPermissionError(error)) {
|
|
continue
|
|
}
|
|
}
|
|
const afterEvidence = await readEntrySnapshot(commandPath)
|
|
if (hasSameSnapshot(afterInspection, afterEvidence)) {
|
|
return { fileSha256, rawSymlinkTarget, snapshot: afterEvidence, status }
|
|
}
|
|
}
|
|
throw new Error(`The command at ${commandPath} changed while Orca inspected it.`)
|
|
}
|
|
|
|
export async function quarantineCommandPath(commandPath: string): Promise<CommandQuarantine> {
|
|
const commandDirectory = dirname(commandPath)
|
|
const directoryPath = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`)
|
|
const heldPath = join(directoryPath, basename(commandPath))
|
|
await mkdir(commandDirectory, { recursive: true })
|
|
await mkdir(directoryPath, { mode: 0o700 })
|
|
try {
|
|
await rename(commandPath, heldPath)
|
|
} catch (error) {
|
|
if (!isMissingError(error)) {
|
|
await rmdir(directoryPath).catch(() => undefined)
|
|
throw error
|
|
}
|
|
}
|
|
return { directoryPath, heldPath, snapshot: await readEntrySnapshot(heldPath) }
|
|
}
|
|
|
|
export async function capturedExpectedEntry(
|
|
quarantine: CommandQuarantine,
|
|
inspected: Pick<StableCommandInspection, 'fileSha256' | 'rawSymlinkTarget' | 'snapshot'>
|
|
): Promise<boolean> {
|
|
if (!quarantine.snapshot) {
|
|
return true
|
|
}
|
|
if (
|
|
!inspected.snapshot ||
|
|
quarantine.snapshot.isSymbolicLink !== inspected.snapshot.isSymbolicLink ||
|
|
!hasSameIdentity(quarantine.snapshot.identity, inspected.snapshot.identity)
|
|
) {
|
|
return false
|
|
}
|
|
if (inspected.rawSymlinkTarget !== null) {
|
|
try {
|
|
return (await readlink(quarantine.heldPath)) === inspected.rawSymlinkTarget
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
if (!inspected.fileSha256) {
|
|
return true
|
|
}
|
|
try {
|
|
return (await hashCommandFile(quarantine.heldPath)) === inspected.fileSha256
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function isUnreadableSymlink<
|
|
T extends Pick<StableCommandInspection, 'rawSymlinkTarget' | 'snapshot'>
|
|
>(inspected: T): inspected is T & { snapshot: EntrySnapshot } {
|
|
return inspected.snapshot?.isSymbolicLink === true && inspected.rawSymlinkTarget === null
|
|
}
|
|
|
|
/** Make the inspected link readable for ownership checks, only if its identity still matches. */
|
|
export function buildMacPrivilegedSymlinkReadableCommand(args: {
|
|
commandPath: string
|
|
expected: EntryIdentity
|
|
}): string {
|
|
const commandPath = quotePosixShellDoubleQuotedApostrophe(args.commandPath)
|
|
const changed = quotePosixShellDoubleQuotedApostrophe(
|
|
`The command at ${args.commandPath} changed while Orca inspected it.`
|
|
)
|
|
return (
|
|
`if [ -L ${commandPath} ] && [ "$(/usr/bin/stat -f '%d:%i' ${commandPath})" = ${quotePosixShellDoubleQuotedApostrophe(`${args.expected.dev}:${args.expected.ino}`)} ]; ` +
|
|
`then /bin/chmod -h 755 ${commandPath}; else echo ${changed} >&2; exit 73; fi`
|
|
)
|
|
}
|
|
|
|
type MacPrivilegedSymlinkTransaction = {
|
|
action: 'install' | 'remove'
|
|
commandPath: string
|
|
launcherPath: string
|
|
inspected: Pick<StableCommandInspection, 'fileSha256' | 'rawSymlinkTarget' | 'snapshot'>
|
|
}
|
|
|
|
export function buildMacPrivilegedSymlinkTransaction(
|
|
args: MacPrivilegedSymlinkTransaction
|
|
): string {
|
|
const commandDirectory = dirname(args.commandPath)
|
|
const transactionDirectory = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`)
|
|
const heldPath = join(transactionDirectory, basename(args.commandPath))
|
|
const publishDirectory = join(transactionDirectory, 'publish')
|
|
const publishPath = join(publishDirectory, basename(args.commandPath))
|
|
const recoveryMessage = quotePosixShellDoubleQuotedApostrophe(
|
|
`The displaced entry is preserved at ${heldPath}.`
|
|
)
|
|
const restore =
|
|
`/bin/ln -P ${quotePosixShellDoubleQuotedApostrophe(heldPath)} ${quotePosixShellDoubleQuotedApostrophe(commandDirectory)} && ` +
|
|
`/bin/rm ${quotePosixShellDoubleQuotedApostrophe(heldPath)} && /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}`
|
|
const restoreOrPreserve = `if ${restore}; then :; else echo ${recoveryMessage} >&2; exit 74; fi`
|
|
const { fileSha256, rawSymlinkTarget, snapshot } = args.inspected
|
|
const fileMismatch = fileSha256
|
|
? ` || [ "$(/usr/bin/shasum -a 256 ${quotePosixShellDoubleQuotedApostrophe(heldPath)} | /usr/bin/awk '{print $1}')" != ${quotePosixShellDoubleQuotedApostrophe(fileSha256)} ]`
|
|
: ''
|
|
const symlinkMismatch = rawSymlinkTarget
|
|
? ` || [ "$(/usr/bin/readlink -n ${quotePosixShellDoubleQuotedApostrophe(heldPath)}; /usr/bin/printf x)" != ${quotePosixShellDoubleQuotedApostrophe(`${rawSymlinkTarget}x`)} ]`
|
|
: ''
|
|
const rejectCaptured = snapshot
|
|
? `if [ "$captured" -eq 1 ] && { [ "$(/usr/bin/stat -f '%d:%i' ${quotePosixShellDoubleQuotedApostrophe(heldPath)})" != ${quotePosixShellDoubleQuotedApostrophe(`${snapshot.identity.dev}:${snapshot.identity.ino}`)} ]${fileMismatch}${symlinkMismatch}; }; then ${restoreOrPreserve}; exit 73; fi`
|
|
: `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; exit 73; fi`
|
|
// macOS applies umask to symlinks; publish under 022 but keep transaction directories private.
|
|
const capture =
|
|
`umask 022; /bin/mkdir -p ${quotePosixShellDoubleQuotedApostrophe(commandDirectory)} || exit $?; ` +
|
|
`/bin/mkdir -m 700 ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)} || exit $?; captured=0; ` +
|
|
`if [ -e ${quotePosixShellDoubleQuotedApostrophe(args.commandPath)} ] || [ -L ${quotePosixShellDoubleQuotedApostrophe(args.commandPath)} ]; then ` +
|
|
`/bin/mv ${quotePosixShellDoubleQuotedApostrophe(args.commandPath)} ${quotePosixShellDoubleQuotedApostrophe(heldPath)} && captured=1 || exit $?; fi; ` +
|
|
`${rejectCaptured}; `
|
|
|
|
if (args.action === 'remove') {
|
|
return `${capture}if [ "$captured" -eq 1 ]; then /bin/rm ${quotePosixShellDoubleQuotedApostrophe(heldPath)}; fi; /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}`
|
|
}
|
|
|
|
const rollback =
|
|
`/bin/rm -f ${quotePosixShellDoubleQuotedApostrophe(publishPath)}; /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(publishDirectory)} 2>/dev/null || :; ` +
|
|
`if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}; fi; exit 73`
|
|
return (
|
|
`${capture}if /bin/mkdir -m 700 ${quotePosixShellDoubleQuotedApostrophe(publishDirectory)} && ` +
|
|
`/bin/ln -s ${quotePosixShellDoubleQuotedApostrophe(args.launcherPath)} ${quotePosixShellDoubleQuotedApostrophe(publishPath)} && ` +
|
|
`/bin/ln -P ${quotePosixShellDoubleQuotedApostrophe(publishPath)} ${quotePosixShellDoubleQuotedApostrophe(commandDirectory)}; then ` +
|
|
`/bin/rm ${quotePosixShellDoubleQuotedApostrophe(publishPath)}; /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(publishDirectory)}; ` +
|
|
`if [ "$captured" -eq 1 ]; then /bin/rm ${quotePosixShellDoubleQuotedApostrophe(heldPath)}; fi; ` +
|
|
`/bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}; else ${rollback}; fi`
|
|
)
|
|
}
|