#!/usr/bin/env node
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { constants as fsConstants } from 'node:fs'
import { lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const CHANNELS_PATH = 'fleet/channels.json'
const STATE_SCHEMA = 'hiverelay-fleet-control-state-v1'
const MAX_CHANNELS_BYTES = 256 * 1024
const MAX_ALLOWED_SIGNERS_BYTES = 256 * 1024
const MAX_STATE_BYTES = 8 * 1024
const MAX_PROCESS_OUTPUT_BYTES = 2 * 1024 * 1024
const RELEASE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
const OBJECT_ID_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/
const REMOTE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
const BRANCH_PATTERN = /^(?!\.)(?!.*(?:\.\.|\/\.|\.lock(?:\/|$)))[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/
const CHANNEL_PATTERN = /^[A-Za-z0-9._-]{1,32}$/
const GOOD_SIGNATURE_PATTERN = /GOODSIG|TRUST_(?:FULLY|ULTIMATE)|Good[^\r\n]*signature/i
const SSH_KEYGEN_PROGRAM = '/usr/bin/ssh-keygen'
const DISABLED_HOOKS_PATH = '/dev/null'
const UNSAFE_GIT_ENV = new Set([
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_CONFIG',
'GIT_CONFIG_COUNT', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_PARAMETERS',
'GIT_CONFIG_SYSTEM', 'GIT_DIR', 'GIT_EXEC_PATH', 'GIT_GRAFT_FILE',
'GIT_INDEX_FILE', 'GIT_NAMESPACE', 'GIT_OBJECT_DIRECTORY', 'GIT_PREFIX',
'GIT_PROXY_COMMAND', 'GIT_QUARANTINE_PATH', 'GIT_REPLACE_REF_BASE',
'GIT_SHALLOW_FILE', 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_SSL_CAINFO',
'GIT_SSL_CAPATH', 'GIT_SSL_NO_VERIFY', 'GIT_WORK_TREE'
])
try {
await main()
} catch (err) {
console.error(`signed fleet channel resolver: ${safeError(err)}`)
process.exitCode = 1
}
async function main () {
const args = parseArgs(process.argv.slice(2))
const repo = requireAbsolutePath(args.repo, '--repo')
const allowedSigners = requireAbsolutePath(args.allowedSigners, '--allowed-signers')
const statePath = requireAbsolutePath(args.state, '--state')
const gitBin = requireAbsolutePath(args.gitBin, '--git-bin')
const remote = args.remote || 'origin'
const branch = args.branch || 'main'
const channel = args.channel
const installedHead = normalizeObjectId(args.installedHead, '--installed-head')
if (!REMOTE_PATTERN.test(remote)) throw new Error('--remote is invalid')
if (!BRANCH_PATTERN.test(branch) || branch.startsWith('-') || branch.endsWith('/') || branch.includes('//')) {
throw new Error('--branch is invalid')
}
if (!CHANNEL_PATTERN.test(channel || '')) throw new Error('--channel is invalid')
await requireSafeExecutable(gitBin, 'Git executable')
await readBoundedSingleLinkFile(allowedSigners, 'allowed signers', MAX_ALLOWED_SIGNERS_BYTES, false)
const repoStat = await lstat(repo).catch(() => null)
if (!repoStat?.isDirectory() || repoStat.isSymbolicLink()) {
throw new Error('--repo must be a real, non-symlink directory')
}
const git = createGit(gitBin, repo)
const topLevel = (await git(['rev-parse', '--show-toplevel'], 'cannot resolve Git worktree')).stdout.trim()
if (await realpath(path.resolve(topLevel)) !== await realpath(repo)) {
throw new Error('--repo must be the exact Git worktree root')
}
const shallow = (await git(['rev-parse', '--is-shallow-repository'], 'cannot inspect Git history depth')).stdout.trim()
if (shallow !== 'false') throw new Error('fleet control replay protection requires a complete Git history')
const controlRef = `refs/remotes/${remote}/${branch}`
await git([
'fetch', '--quiet', '--force', '--no-tags', remote,
`refs/heads/${branch}:${controlRef}`
], `cannot fetch signed fleet control branch ${remote}/${branch}`)
const controlTip = normalizeObjectId((await git([
'rev-parse', '--verify', controlRef
], 'cannot resolve fetched fleet control head')).stdout.trim(), 'control head')
const controlCommit = normalizeObjectId((await git([
'rev-list', '-1', controlTip, '--', CHANNELS_PATH
], `cannot locate ${CHANNELS_PATH} authority`)).stdout.trim(), 'channel control commit')
await assertSignedControlCommit(git, controlCommit, allowedSigners)
const channels = await readChannelsFromCommit(git, controlCommit)
const target = channels[channel]
if (!RELEASE_TAG_PATTERN.test(target || '')) {
throw new Error(`signed fleet channels do not contain a valid target for ${channel}`)
}
await git([
'fetch', '--quiet', '--no-tags', remote,
`refs/tags/${target}:refs/tags/${target}`
], `cannot fetch immutable release tag ${target}`)
const targetSha = await resolveReleaseTag(git, target, allowedSigners, args.allowUnsignedRelease === true)
const stateDirectory = await requirePrivateStateDirectory(path.dirname(statePath))
const prior = await readControlState(statePath, { optional: true })
if (prior) {
requireExactState(prior, { remote, branch, channel })
const monotonic = await gitStatus(git, [
'merge-base', '--is-ancestor', prior.channelCommit, controlCommit
], 'cannot compare accepted and fetched control heads')
if (monotonic !== 0) {
throw new Error('signed fleet control replay or non-monotonic downgrade rejected')
}
const targetMonotonic = await gitStatus(git, [
'merge-base', '--is-ancestor', prior.targetSha, targetSha
], 'cannot compare accepted and selected release targets')
if (targetMonotonic !== 0) {
throw new Error('signed fleet target downgrade or divergent release rejected')
}
if (prior.channelCommit === controlCommit &&
(prior.target !== target || prior.targetSha !== targetSha)) {
throw new Error('accepted control state disagrees with the same signed control commit')
}
} else {
const forwardFromInstalled = await gitStatus(git, [
'merge-base', '--is-ancestor', installedHead, controlCommit
], 'cannot compare installed release and signed control head')
if (forwardFromInstalled !== 0) {
throw new Error('initial signed fleet control head predates or diverges from the installed release')
}
const targetForwardFromInstalled = await gitStatus(git, [
'merge-base', '--is-ancestor', installedHead, targetSha
], 'cannot compare installed and selected release targets')
if (targetForwardFromInstalled !== 0) {
throw new Error('initial signed fleet target is a downgrade or divergent from the installed release')
}
}
const state = {
schema: STATE_SCHEMA,
remote,
branch,
channel,
channelCommit: controlCommit,
target,
targetSha
}
if (!args.dryRun) await writeControlStateAtomic(statePath, stateDirectory, state, prior)
process.stdout.write(`resolved\t${target}\t${targetSha}\t${controlCommit}\t${controlTip}\n`)
}
function parseArgs (argv) {
const out = {}
const values = new Set([
'repo', 'remote', 'branch', 'channel', 'allowed-signers', 'state', 'git-bin', 'installed-head'
])
const booleans = new Set(['dry-run', 'allow-unsigned-release'])
const seen = new Set()
for (let i = 0; i < argv.length; i++) {
const raw = argv[i]
if (!raw.startsWith('--')) throw new Error(`unexpected positional argument ${JSON.stringify(raw)}`)
const name = raw.slice(2)
if (!values.has(name) && !booleans.has(name)) throw new Error(`unknown option --${name}`)
if (seen.has(name)) throw new Error(`duplicate option --${name}`)
seen.add(name)
const key = name.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
if (booleans.has(name)) {
out[key] = true
continue
}
const value = argv[++i]
if (!value || value.startsWith('--')) throw new Error(`missing value for --${name}`)
out[key] = value
}
for (const name of ['repo', 'channel', 'allowedSigners', 'state', 'gitBin', 'installedHead']) {
if (!out[name]) throw new Error(`--${name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)} is required`)
}
return out
}
async function assertSignedControlCommit (git, commit, allowedSigners) {
const parents = (await git(['rev-list', '--parents', '-n', '1', commit],
'cannot inspect signed fleet control commit')).stdout.trim().split(/\s+/)
if (parents.length !== 2 || parents[0] !== commit) {
throw new Error('fleet channel authority must be a single-parent control commit')
}
const names = (await git([
'diff-tree', '--no-commit-id', '--name-only', '-r', '-z', commit
], 'cannot inspect signed fleet control commit paths')).stdout
if (names !== `${CHANNELS_PATH}\0`) {
throw new Error(`signed fleet control commit must change only ${CHANNELS_PATH}`)
}
const verified = await git([
'-c', 'gpg.format=ssh',
'-c', `gpg.ssh.allowedSignersFile=${allowedSigners}`,
'-c', `gpg.ssh.program=${SSH_KEYGEN_PROGRAM}`,
'verify-commit', '--raw', commit
], 'fleet channel control commit is not signed by an allowed signer')
if (!GOOD_SIGNATURE_PATTERN.test(`${verified.stdout}\n${verified.stderr}`)) {
throw new Error('fleet channel control commit did not report a trusted signature')
}
}
async function readChannelsFromCommit (git, commit) {
const entry = (await git(['ls-tree', '-z', commit, '--', CHANNELS_PATH],
`cannot inspect signed ${CHANNELS_PATH}`)).stdout
const match = /^(100644) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([^\0]+)\0$/.exec(entry)
if (!match || match[3] !== CHANNELS_PATH) {
throw new Error(`signed ${CHANNELS_PATH} must be one non-executable regular blob`)
}
const contents = (await git(['cat-file', 'blob', match[2]],
`cannot read signed ${CHANNELS_PATH}`)).stdout
const bytes = Buffer.from(contents)
if (bytes.byteLength < 2 || bytes.byteLength > MAX_CHANNELS_BYTES || bytes.includes(0)) {
throw new Error(`signed ${CHANNELS_PATH} exceeds its content bounds`)
}
let value
try { value = JSON.parse(bytes.toString('utf8')) } catch { throw new Error(`signed ${CHANNELS_PATH} is not valid JSON`) }
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`signed ${CHANNELS_PATH} must contain a JSON object`)
}
return value
}
async function resolveReleaseTag (git, target, allowedSigners, allowUnsigned) {
const ref = `refs/tags/${target}`
const type = (await git(['cat-file', '-t', ref], `release tag ${target} is missing`)).stdout.trim()
if (type !== 'tag') throw new Error(`release tag ${target} must be annotated`)
if (!allowUnsigned) {
const verified = await git([
'-c', 'gpg.format=ssh',
'-c', `gpg.ssh.allowedSignersFile=${allowedSigners}`,
'-c', `gpg.ssh.program=${SSH_KEYGEN_PROGRAM}`,
'verify-tag', '--raw', target
], `release tag ${target} is not signed by an allowed signer`)
if (!GOOD_SIGNATURE_PATTERN.test(`${verified.stdout}\n${verified.stderr}`)) {
throw new Error(`release tag ${target} did not report a trusted signature`)
}
}
return normalizeObjectId((await git([
'rev-parse', '--verify', `${ref}^{commit}`
], `cannot resolve release tag ${target}`)).stdout.trim(), `${target} release commit`)
}
function createGit (gitBin, repo) {
return async function git (args, failure) {
try {
return await execFileAsync(gitBin, [
'--no-replace-objects', '-C', repo,
'-c', `core.hooksPath=${DISABLED_HOOKS_PATH}`,
'-c', 'core.fsmonitor=false',
'-c', 'http.sslVerify=true',
'-c', 'protocol.ext.allow=never',
...args
], {
encoding: 'utf8',
env: hardenedGitEnv(),
maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
timeout: 30_000
})
} catch (err) {
const wrapped = new Error(failure)
wrapped.exitCode = Number.isInteger(err?.code) ? err.code : null
throw wrapped
}
}
}
async function gitStatus (git, args, failure) {
try {
await git(args, failure)
return 0
} catch (err) {
if (err.exitCode === 1) return 1
throw err
}
}
function hardenedGitEnv () {
const env = { ...process.env }
for (const name of UNSAFE_GIT_ENV) delete env[name]
env.GIT_CONFIG_NOSYSTEM = '1'
env.GIT_NO_REPLACE_OBJECTS = '1'
env.GIT_GRAFT_FILE = '/dev/null/hiverelay-disabled'
env.GIT_OPTIONAL_LOCKS = '0'
env.LC_ALL = 'C'
return env
}
async function requirePrivateStateDirectory (directory) {
const resolved = path.resolve(directory)
let stat = await lstat(resolved).catch(err => {
if (err?.code === 'ENOENT') return null
throw err
})
if (!stat) {
const parent = path.dirname(resolved)
const parentStat = await lstat(parent).catch(() => null)
if (!parentStat?.isDirectory() || parentStat.isSymbolicLink()) {
throw new Error('control state parent directory is unsafe')
}
await mkdir(resolved, { mode: 0o700 })
stat = await lstat(resolved)
}
if (!stat.isDirectory() || stat.isSymbolicLink() ||
(stat.mode & 0o077) !== 0 || !ownedByCurrentUser(stat)) {
throw new Error('control state directory must be owned, non-symlink, and mode 0700')
}
return resolved
}
async function readControlState (file, opts = {}) {
let buffer
try {
buffer = await readBoundedSingleLinkFile(file, 'control state', MAX_STATE_BYTES, true)
} catch (err) {
if (opts.optional && err?.code === 'ENOENT') return null
throw err
}
let value
try { value = JSON.parse(buffer.toString('utf8')) } catch { throw new Error('control state is not valid JSON') }
requireOnlyKeys(value, [
'schema', 'remote', 'branch', 'channel', 'channelCommit', 'target', 'targetSha'
], 'control state')
if (value.schema !== STATE_SCHEMA) throw new Error('control state schema is invalid')
normalizeObjectId(value.channelCommit, 'control state channelCommit')
normalizeObjectId(value.targetSha, 'control state targetSha')
if (!RELEASE_TAG_PATTERN.test(value.target || '')) throw new Error('control state target is invalid')
return value
}
function requireExactState (state, expected) {
for (const [name, value] of Object.entries(expected)) {
if (state[name] !== value) throw new Error(`control state ${name} does not match this updater`)
}
}
async function writeControlStateAtomic (file, directory, value, prior) {
const bytes = Buffer.from(JSON.stringify(value, null, 2) + '\n')
if (bytes.byteLength > MAX_STATE_BYTES) throw new Error('control state exceeds its write bound')
if (prior && JSON.stringify(prior) === JSON.stringify(value)) return
if (path.dirname(file) !== directory) throw new Error('control state path changed during validation')
const temporary = path.join(directory,
`.${path.basename(file)}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`)
let handle
try {
if (typeof fsConstants.O_NOFOLLOW !== 'number') throw new Error('platform lacks O_NOFOLLOW')
handle = await open(temporary,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
0o600)
await handle.writeFile(bytes)
await handle.sync()
const stat = await handle.stat()
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !ownedByCurrentUser(stat)) {
throw new Error('temporary control state permissions are unsafe')
}
await handle.close()
handle = null
await rename(temporary, file)
const directoryHandle = await open(directory,
fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | fsConstants.O_NOFOLLOW)
try { await directoryHandle.sync() } finally { await directoryHandle.close() }
} catch (err) {
await handle?.close().catch(() => {})
await unlink(temporary).catch(() => {})
throw err
}
}
async function readBoundedSingleLinkFile (file, label, maxBytes, privateMode) {
let handle
try {
if (typeof fsConstants.O_NOFOLLOW !== 'number') throw new Error('platform lacks O_NOFOLLOW')
handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
const before = await handle.stat({ bigint: true })
const mode = Number(before.mode & 0o777n)
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maxBytes) ||
(privateMode ? mode !== 0o600 : (mode & 0o022) !== 0) || !ownedByCurrentUser(before)) {
throw new Error(`${label} must be a safe bounded single-link regular file`)
}
const buffer = Buffer.allocUnsafe(maxBytes + 1)
let length = 0
while (length < buffer.length) {
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length)
if (bytesRead === 0) break
length += bytesRead
}
if (length > maxBytes) throw new Error(`${label} exceeds its read bound`)
const after = await handle.stat({ bigint: true })
if (!sameFileSnapshot(before, after) || BigInt(length) !== after.size) {
throw new Error(`${label} changed while being read`)
}
return buffer.subarray(0, length)
} catch (err) {
if (err?.code === 'ENOENT') throw err
if (err?.message?.startsWith(label) || err?.message === 'platform lacks O_NOFOLLOW') throw err
throw new Error(`${label} must be a readable non-symlink file`)
} finally {
await handle?.close().catch(() => {})
}
}
async function requireSafeExecutable (file, label) {
const stat = await lstat(file).catch(() => null)
if (!stat?.isFile() || stat.isSymbolicLink() || (stat.mode & 0o111) === 0 ||
(stat.mode & 0o022) !== 0 || !ownedByRootOrCurrentUser(stat)) {
throw new Error(`${label} is missing or unsafe`)
}
}
function requireOnlyKeys (value, names, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
const allowed = new Set(names)
const unknown = Object.keys(value).filter(name => !allowed.has(name))
const missing = names.filter(name => !Object.hasOwn(value, name))
if (unknown.length || missing.length) throw new Error(`${label} fields are invalid`)
}
function sameFileSnapshot (left, right) {
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs
}
function ownedByCurrentUser (stat) {
return typeof process.geteuid !== 'function' || Number(stat.uid) === process.geteuid()
}
function ownedByRootOrCurrentUser (stat) {
return Number(stat.uid) === 0 || ownedByCurrentUser(stat)
}
function normalizeObjectId (value, label) {
const normalized = String(value || '').trim().toLowerCase()
if (!OBJECT_ID_PATTERN.test(normalized)) throw new Error(`${label} is not a valid Git object ID`)
return normalized
}
function requireAbsolutePath (value, label) {
const candidate = String(value || '')
if (!path.isAbsolute(candidate) || candidate !== path.resolve(candidate) || candidate.length > 4096 ||
hasControlChars(candidate)) {
throw new Error(`${label} must be a bounded canonical absolute path`)
}
return candidate
}
function hasControlChars (value) {
for (const char of value) {
const code = char.codePointAt(0)
if (code <= 31 || code === 127) return true
}
return false
}
function safeError (err) {
const value = err instanceof Error ? err.message : String(err)
return value.replace(/[\r\n\0]/g, ' ').slice(0, 1000)
}