diff --git a/src/core/commands/status.js b/src/core/commands/status.js index ec847068..5247e56a 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -2,6 +2,7 @@ import { Attr, withSpan } from '../observability/index.js' import { collectHypAwareStatus } from '../daemon/status.js' +import { sanitizeLabel } from '../util/json_util.js' import { formatFirstSyncDeadline } from '../usage-policy/first_sync_hold.js' /** @@ -286,6 +287,42 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { } } +/** + * How much of the daemon line's `error=` a hostile status file may spend. + * Wider than a label's 120, because unlike a name this carries a real error + * message - typically an fs error naming a full path - and the clamp exists + * to stop the line being bloated, not to bound an identifier. Cutting a path + * short is the one way this cleaning could cost a reader an answer. + */ +const MAX_DAEMON_ERROR_CHARS = 400 + +/** + * A string on its way into the text surface, made safe to print. + * + * `renderStatusText` is the last point before a terminal, and several of the + * strings it interpolates were read back out of `status.json` + * (`daemon.state`, `daemon.mode`, and - with no runtime attached - every + * `sources[]` / `sinks[]` entry). That file is a *file*: core cannot assume + * the daemon that wrote it was this version, this build, or well behaved, so + * a raw value from it can carry an escape sequence that repaints the + * operator's screen or a newline that forges a plausible extra status line. + * + * Cleaning happens *here* rather than in the collector because these values + * are not display-only: `sources[].name` and `sinks[].instance` are identity + * keys and part of the `--json` contract, which a consumer escapes for + * itself. Cleaning at the interpolation closes the terminal path and leaves + * both intact - including the raw values the provenance lookups below match + * on. + * + * @param {string | undefined} value + * @param {number} [max] + * @returns {string} + * @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: what core reads back out of status.json is cleaned at the last point before render, whichever field it came from + */ +function printable(value, max) { + return sanitizeLabel(value, max) ?? '' +} + /** * Render the V1 status report as human-friendly text. Mirrors the * JSON shape but groups sections and surfaces diagnostics + repair @@ -324,7 +361,7 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std stdout.write(' (none)\n') } else { for (const s of report.sources) { - stdout.write(` - ${s.name} (${s.plugin}) [${s.state}]${provenanceTag(report.layered, isCentralPlugin(report.layered, s.plugin))}\n`) + stdout.write(` - ${printable(s.name)} (${printable(s.plugin)}) [${printable(s.state)}]${provenanceTag(report.layered, isCentralPlugin(report.layered, s.plugin))}\n`) } } @@ -333,7 +370,7 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std stdout.write(' (none - keeping captured data local only)\n') } else { for (const s of report.sinks) { - stdout.write(` - ${s.instance} (${s.plugin}, ${s.kind})${provenanceTag(report.layered, isCentralSink(report.layered, s.instance))}\n`) + stdout.write(` - ${printable(s.instance)} (${printable(s.plugin)}, ${printable(s.kind)})${provenanceTag(report.layered, isCentralSink(report.layered, s.instance))}\n`) } } @@ -564,6 +601,14 @@ function isCentralSink(layered, instance) { } /** + * The `daemon:` line. `state` and `mode` are read straight out of + * `status.json` (`collectHypAwareStatus` takes them from the snapshot when + * the pid file did not already supply them), and `error` can quote the file's + * own bytes back: a `status.json` that is not valid JSON surfaces here as + * `JSON.parse`'s message, which echoes an excerpt of the input verbatim. All + * three are cleaned on the way into the line. `pid` needs no cleaning - + * `readPidFile` rejects a non-number. + * * @param {ServiceState} daemon */ function describeDaemon(daemon) { @@ -571,10 +616,10 @@ function describeDaemon(daemon) { parts.push(daemon.installed ? 'installed' : 'not installed') if (daemon.installed) parts.push(daemon.loaded ? 'loaded' : 'not loaded') parts.push(daemon.running ? 'running' : 'not running') - if (daemon.state) parts.push(`state=${daemon.state}`) + if (daemon.state) parts.push(`state=${printable(daemon.state)}`) if (daemon.pid) parts.push(`pid=${daemon.pid}`) - if (daemon.mode) parts.push(`mode=${daemon.mode}`) - if (daemon.error) parts.push(`error=${daemon.error}`) + if (daemon.mode) parts.push(`mode=${printable(daemon.mode)}`) + if (daemon.error) parts.push(`error=${printable(daemon.error, MAX_DAEMON_ERROR_CHARS)}`) return parts.join(', ') } diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index faa2ec26..b7600a2d 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -119,10 +119,15 @@ export function gatewaySourceDetails(sources) { const host = typeof details.host === 'string' && details.host.length > 0 ? details.host : '127.0.0.1' // @ref LLP 0114#fallback-is-visible [implements]: the gateway records whether this bind came through the default-port fallback const listenFallback = details.listen_fallback === true - const listenFallbackFrom = - typeof details.listen_fallback_from === 'string' && details.listen_fallback_from.length > 0 - ? details.listen_fallback_from - : undefined + // Display-only, and read out of a file: `listen_fallback_from` is the + // configured listen address the gateway could not take, and it is printed + // verbatim into `gateway_port_fallback`'s message and its repair line. That + // makes it the same kind of value as an upstream `name` or an `entrypoint`, + // so it is cleaned at the same last point before render. `host` above is + // deliberately left alone: it is not display-only (it composes the endpoint + // attach writes into client settings), so bounding it is a separate change. + // @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: a string read back out of status.json is cleaned before it is printed, whichever detail it came from + const listenFallbackFrom = sanitizeLabel(details.listen_fallback_from) return { host, port, listenFallback, ...(listenFallbackFrom ? { listenFallbackFrom } : {}) } } @@ -145,6 +150,57 @@ function gatewaySourceRawDetails(sources) { return /** @type {Record} */ (rawDetails) } +/** + * How many upstream names a single warning line will spell out before it stops + * naming them and counts the remainder. + * + * `sanitizeLabel` bounds each name; nothing bounds how many of them the file + * holds. These names are read inside one sentence rather than down a block of + * lines, so the cap sits well under `recent clients`' 32: the count leads that + * sentence and is the number that actually matters, which leaves the list free + * to be a sample. + */ +const MAX_PRINTED_UPSTREAM_NAMES = 8 + +/** + * The upstream names a status file offers, made safe to print: each one + * cleaned through `sanitizeLabel`, the list capped, and the number of names + * being withheld returned alongside so the message can account for them. + * + * `total` counts the raw non-empty strings, before either filter, because that + * is what an older status file's missing `upstreams_configured` falls back to. + * Cleaning bounds what is *printed*; it must never revise how many upstreams + * the config is reported to have asked for. + * + * These names arrive in the same file as `recent_entrypoints`, so the reason + * that list is sanitized on read applies here unchanged: `status.json` is a + * *file*, and core cannot assume the daemon that wrote it was this version, + * this build, or well behaved, while everything read here is about to be + * printed to a terminal. An upstream `name` is config-authored rather than + * client-authored, which lowers the odds but not the reachability, and two + * paths reading one file should not disagree about whether it is trusted. + * + * @param {unknown} value + * @returns {{ names: string[], total: number, hidden: number }} + * @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: the sanitize-and-cap on read is a property of reading status.json, not of the entrypoint list that first needed it + */ +function printableUpstreamNames(value) { + const raw = Array.isArray(value) + ? /** @type {string[]} */ (value.filter((u) => typeof u === 'string' && u.length > 0)) + : [] + /** @type {string[]} */ + const names = [] + for (const name of raw) { + if (names.length === MAX_PRINTED_UPSTREAM_NAMES) break + const label = sanitizeLabel(name) + // A name that sanitizes away entirely is withheld rather than printed + // empty; `hidden` counts it with the ones the cap dropped, since from the + // reader's side both are names the file holds and the line does not show. + if (label !== undefined) names.push(label) + } + return { names, total: raw.length, hidden: raw.length - names.length } +} + /** * How many upstreams a *deliberately idle* gateway was nonetheless configured * with, and which of them it can name, or `undefined` when the gateway is @@ -171,24 +227,21 @@ function gatewaySourceRawDetails(sources) { * * A status file written before `upstreams_configured` existed carries names * only; those still count for themselves, so an older daemon's dropped - * `base_url` stays visible. + * `base_url` stays visible. The count comes off the raw list for that reason, + * while `printableUpstreamNames` bounds only the names handed back for + * display (`hidden` is how many of them it kept back). * * @param {SourceSnapshot[] | undefined} sources - * @returns {{ count: number, names: string[] } | undefined} + * @returns {{ count: number, names: string[], hidden: number } | undefined} */ function gatewayIdleWithConfiguredUpstreams(sources) { const details = gatewaySourceRawDetails(sources) if (!details || details.listening !== false) return undefined - const upstreams = details.upstreams - const names = Array.isArray(upstreams) - ? /** @type {string[]} */ (upstreams.filter((u) => typeof u === 'string' && u.length > 0)) - : [] + const { names, total, hidden } = printableUpstreamNames(details.upstreams) const rawCount = details.upstreams_configured const count = - typeof rawCount === 'number' && Number.isInteger(rawCount) && rawCount >= 0 - ? rawCount - : names.length - return count > 0 ? { count, names } : undefined + typeof rawCount === 'number' && Number.isInteger(rawCount) && rawCount >= 0 ? rawCount : total + return count > 0 ? { count, names, hidden } : undefined } /** @@ -671,11 +724,14 @@ export async function collectHypAwareStatus(opts = {}) { // *wanted* no upstream (hermes-only) reports no configured upstreams here // and never reaches this branch, so it stays healthy and silent. // @ref LLP 0114#fallback-is-visible [implements]: an exception to "the gateway is listening" is readable from status.json steadily, not only from a boot-time log line - const { count, names } = idleGatewayUpstreams + const { count, names, hidden } = idleGatewayUpstreams // Count first, names in parentheses when there are any: `name` is itself // one of the two keys that drops an entry, so the config that most needs - // this warning is exactly the one that can supply no name to print. - const named = names.length > 0 ? ` (${names.join(', ')})` : '' + // this warning is exactly the one that can supply no name to print. The + // names the reader held back are counted rather than dropped silently, so + // a truncated list never reads as a complete one. + const withheld = hidden > 0 ? `, +${hidden} more` : '' + const named = names.length > 0 ? ` (${names.join(', ')}${withheld})` : '' diagnostics.push({ severity: 'warning', kind: 'gateway_idle_no_upstreams', diff --git a/test/core/status-gateway-fallback.test.js b/test/core/status-gateway-fallback.test.js index 983e6f8c..989aeefd 100644 --- a/test/core/status-gateway-fallback.test.js +++ b/test/core/status-gateway-fallback.test.js @@ -94,3 +94,68 @@ test('a default-port boot emits no gateway_port_fallback diagnostic', async () = const report = await collectHypAwareStatus(collectOpts(hypHome)) assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_port_fallback'), undefined) }) + +// `listen_fallback_from` is read back out of `status.json` and printed +// verbatim, into this diagnostic's message and into its repair line. That is +// the same last point before render that `recent_entrypoints` and the idle +// gateway's upstream names are cleaned at (LLP 0164), and the value has the +// same provenance as an upstream `name`: config-authored, but reaching core +// through a file this build did not necessarily write. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: a display string read out of status.json is cleaned before it reaches the terminal +test('a hostile fallback address cannot drive the terminal from the warning', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { + host: '127.0.0.1', + port: 54321, + listen_fallback: true, + // An erase-line sequence and a newline, which together forge a plausible + // extra status line out of a value the operator never chose to trust. + listen_fallback_from: '127.0.0.1:18521\u001b[2K\nhyp: all good', + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_port_fallback') + assert.ok(diag) + assert.ok(!/[\u0000-\u001f\u007f-\u009f]/.test(diag.message), 'no control byte reaches the message') + assert.ok( + !diag.repair.some((r) => /[\u0000-\u001f\u007f-\u009f]/.test(r)), + 'and none reaches the repair line, which is printed too', + ) + assert.match(diag.message, /127\.0\.0\.1:18521/, 'the printable part still names the address') +}) + +test('an unbounded fallback address is clamped in the warning', async () => { + const { hypHome, stateRoot } = await makeHome() + const long = 'a'.repeat(5000) + writeRunningDaemon(stateRoot, { + host: '127.0.0.1', + port: 54321, + listen_fallback: true, + listen_fallback_from: long, + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_port_fallback') + assert.ok(diag) + assert.ok(!diag.message.includes(long), 'the raw value is not printed whole') + assert.ok(diag.message.includes('a'.repeat(117) + '...'), 'it is clamped, and marked truncated') +}) + +// A `listen_fallback_from` that sanitizes away entirely falls back to the +// generic phrasing, exactly as an absent one does: the warning is about the +// bind, and it stays readable with no address to name. +test('a fallback address that sanitizes away leaves the generic phrasing', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { + host: '127.0.0.1', + port: 54321, + listen_fallback: true, + listen_fallback_from: '\u200b\u200b', + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_port_fallback') + assert.ok(diag) + assert.match(diag.message, /its default listen address/, 'the generic antecedent stands in') + assert.ok(!diag.message.includes('\u200b'), 'and the empty run does not ride along') +}) diff --git a/test/core/status-gateway-idle.test.js b/test/core/status-gateway-idle.test.js index e912f38d..0c201382 100644 --- a/test/core/status-gateway-idle.test.js +++ b/test/core/status-gateway-idle.test.js @@ -228,3 +228,85 @@ test('a stopped daemon does not warn off a stale status snapshot', async () => { const report = await collectHypAwareStatus(collectOpts(hypHome)) assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams'), undefined) }) + +// The names in this warning are read out of `status.json`, which is a *file*: +// core cannot assume the daemon that wrote it was this version, this build, or +// well behaved, and the value is about to be printed to a terminal. The +// `recent clients` list is read back out of the same file through +// `sanitizeLabel` and a count cap for exactly that reason (LLP 0164); these +// names were going to the terminal raw. All three ways a name can be hostile +// are answered below - control and invisible bytes, unbounded length, and +// unbounded count. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: what core reads back out of status.json is cleaned at the last point before render, whichever list it came from +test('a hostile upstream name cannot drive the terminal from the warning', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { + listening: false, + // An escape sequence that erases the line and forges a plausible second + // status line, and a zero-width run that hides inside a name on screen. + upstreams: ['anthropic\u001b[2K\nhyp: all good', 'open\u200b\u200bai'], + upstreams_configured: 2, + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag) + assert.ok(!/[\u0000-\u001f\u007f-\u009f]/.test(diag.message), 'no control byte reaches the message') + assert.ok(!diag.message.includes('\u200b'), 'and no zero-width run does either') + assert.match(diag.message, /anthropic/, 'the printable part of a name still names it') + assert.match(diag.message, /openai/, 'a hidden run is closed up, not made to drop the name') +}) + +test('an unbounded upstream name is clamped in the warning', async () => { + const { hypHome, stateRoot } = await makeHome() + const long = 'a'.repeat(5000) + writeRunningDaemon(stateRoot, { listening: false, upstreams: [long], upstreams_configured: 1 }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag) + assert.ok(!diag.message.includes(long), 'the raw name is not printed whole') + // `sanitizeLabel`'s 120-character clamp, truncation marker included. + assert.ok(diag.message.includes('a'.repeat(117) + '...'), 'it is clamped, and marked truncated') +}) + +test('an unbounded number of upstream names is capped, and the rest counted', async () => { + const { hypHome, stateRoot } = await makeHome() + const many = Array.from({ length: 50 }, (_, i) => `up-${i}`) + writeRunningDaemon(stateRoot, { listening: false, upstreams: many, upstreams_configured: 50 }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag) + assert.match(diag.message, /nothing: 50 upstreams \(/, 'the count is still the true one') + assert.equal( + many.filter((name) => diag.message.includes(`${name},`) || diag.message.includes(`${name})`)).length, + 8, + 'only the capped number of names is spelled out', + ) + // A truncated list that reads as a complete one would be worse than no list. + assert.match(diag.message, /\+42 more/, 'and the names held back are counted, not dropped') +}) + +// The sanitizer and the cap bound what is *printed*. Neither may revise the +// count, which is the whole signal separating a dropped upstream from a +// legitimately upstream-less gateway - including on a status file too old to +// carry `upstreams_configured`, where the raw name list is the only count +// there is. +test('an older status file counts every name it holds, capped or not', async () => { + const { hypHome, stateRoot } = await makeHome() + // 20 names, two of which sanitize away to nothing: past the cap, so the cap + // cannot be what makes the count 20, and holding names the printer refuses, + // so the sanitizer cannot be either. Counting either filter's leavings would + // report 18 or 8 upstreams for a config that asked for 20. + const older = Array.from({ length: 20 }, (_, i) => (i === 3 || i === 11 ? '\u200b\u200b' : `up-${i}`)) + writeRunningDaemon(stateRoot, { listening: false, upstreams: older }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag) + assert.match(diag.message, /nothing: 20 upstreams \(/, 'the fallback count is the raw one') + // 20 held, 8 printed: the 12 the line does not show are all accounted for, + // whichever filter withheld them. + assert.match(diag.message, /\+12 more/, 'and every name it does not print is counted back') +}) diff --git a/test/core/status-text-status-file-labels.test.js b/test/core/status-text-status-file-labels.test.js new file mode 100644 index 00000000..e4614f98 --- /dev/null +++ b/test/core/status-text-status-file-labels.test.js @@ -0,0 +1,160 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { collectHypAwareStatus, statusFilePath, writeStatusFile } from '../../src/core/daemon/status.js' +import { renderStatusJson, renderStatusText } from '../../src/core/commands/status.js' +import { writePidFile } from '../../src/core/daemon/pid.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' + +/** @import { CollectStatusOptions, HypAwareStatusReport } from '../../src/core/daemon/types.js' */ + +// `hyp status` reads `status.json` and prints it. The gateway's own details +// (`recent_entrypoints`, the idle warning's upstream names, `listen_fallback_from`) +// are cleaned on the way out, for a reason that is a property of *reading the +// file* and not of any one detail: core cannot assume the daemon that wrote it +// was this version, this build, or well behaved, and everything read there is +// about to reach a terminal. +// +// The same file also carries `state`, `mode`, and - with no runtime attached - +// every `sources[]` and `sinks[]` entry, all of which land in the text surface +// too, and a file that does not parse at all reaches it as `JSON.parse`'s +// message, which quotes an excerpt of the input back verbatim. These pin that +// none of those four routes can drive the terminal either, while `--json` +// keeps carrying the values a consumer pins and escapes for itself. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: what core reads back out of status.json is cleaned at the last point before render, whichever field it came from + +const ESC = String.fromCharCode(27) +const NL = String.fromCharCode(10) +const ZERO_WIDTH = String.fromCharCode(0x200b) +// C0, DEL and C1 - the whole range `sanitizeLabel` strips, so the assertion +// does not pin only the one sequence each case happens to drive. +const CONTROL_CHARS = new RegExp( + '[' + String.fromCharCode(0) + '-' + String.fromCharCode(0x1f) + + String.fromCharCode(0x7f) + '-' + String.fromCharCode(0x9f) + ']' +) + +// An erase-line sequence plus a newline: together they forge a plausible extra +// status line out of a value the operator never chose to trust. +const FORGE = ESC + '[2K' + NL + 'hyp: all good' + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-text-labels-')) + const stateRoot = path.join(hypHome, 'hypaware') + await fs.mkdir(path.join(stateRoot, 'run'), { recursive: true }) + await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n') + return { hypHome, stateRoot } +} + +/** + * @param {string} hypHome + * @returns {CollectStatusOptions} + */ +function collectOpts(hypHome) { + // Stub the launch-agent probe so the machine's real daemon install cannot + // leak in; liveness then comes from the pid file alone. + return { + env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' }, + platform: 'darwin', + isLaunchAgentInstalled: () => false, + } +} + +function makeBuf() { + let value = '' + return { write(/** @type {string} */ chunk) { value += String(chunk); return true }, text() { return value } } +} + +/** @param {HypAwareStatusReport} report */ +function renderText(report) { + const buf = makeBuf() + renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/cache', stdout: buf }) + return buf.text() +} + +test('a hostile sources/sinks snapshot cannot drive the terminal from hyp status', async () => { + const { hypHome, stateRoot } = await makeHome() + writePidFile(stateRoot, /** @type {any} */ ({ pid: process.pid, runId: 'r', mode: 'foreground' })) + // No runtime and no configured sinks, so the report takes both lists + // straight off the status file. + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'running', + sources: [{ name: 'gw' + FORGE, plugin: 'p' + ESC + '[31m', state: 'started' + ESC + '[0m' }], + sinks: [{ instance: 'sink' + FORGE, plugin: 'q' + ZERO_WIDTH, kind: 'blob' + ESC + '[0m' }], + })) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const text = renderText(report) + assert.ok(!CONTROL_CHARS.test(text.replace(/\n/g, '')), 'no control byte reaches the text surface') + assert.ok(!text.includes(ZERO_WIDTH), 'and no zero-width run does either') + // The forged newline is the whole point: one snapshot entry must stay one + // rendered line, whatever the file put in its name. + const sourcesBlock = text.split(' sources:' + NL)[1].split(' sinks:' + NL)[0] + assert.equal(sourcesBlock.trimEnd().split(NL).length, 1, 'one source entry stays one line') + assert.match(sourcesBlock, /gw/, 'and the printable part still names it') + const sinksBlock = text.split(' sinks:' + NL)[1].split(' clients:' + NL)[0] + assert.equal(sinksBlock.trimEnd().split(NL).length, 1, 'one sink entry stays one line') + assert.match(sinksBlock, /sink/) + + // The machine surface is a contract a consumer escapes for itself, and + // `sources[].name` / `sinks[].instance` are identity keys there: cleaning is + // a property of the terminal, not of the report. + const json = renderStatusJson({ report, clientNames: [], datasets: [], cacheRoot: '/cache' }) + assert.equal(json.sources[0].name, 'gw' + FORGE, '--json still carries the raw identity key') + assert.equal(json.sinks[0].instance, 'sink' + FORGE) +}) + +test('a hostile daemon state and mode cannot drive the terminal from hyp status', async () => { + const { hypHome, stateRoot } = await makeHome() + // No pid file: liveness and `mode` then come from the status snapshot, which + // is the branch where a status-file `mode` reaches the line at all. + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'running' + FORGE, + mode: 'fore' + ESC + '[31mground', + sources: [], + sinks: [], + })) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.daemon.state, 'running' + FORGE, 'the collector keeps the raw value') + const text = renderText(report) + assert.ok(!CONTROL_CHARS.test(text.replace(/\n/g, '')), 'but no control byte reaches the daemon line') + const lines = text.split(NL) + const at = lines.findIndex((l) => l.startsWith(' daemon:')) + assert.ok(at >= 0) + assert.match(lines[at], /state=running/, 'the printable part still shows the state') + assert.match(lines[at], /mode=fore/, 'and the mode') + assert.equal(lines[at + 1], ' active plugins:', 'and the forged line never appears') +}) + +test('an unbounded daemon state is clamped on the daemon line', async () => { + const { hypHome, stateRoot } = await makeHome() + const long = 'a'.repeat(5000) + writeStatusFile(stateRoot, /** @type {any} */ ({ state: long, sources: [], sinks: [] })) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const text = renderText(report) + assert.ok(!text.includes(long), 'the raw value is not printed whole') + assert.ok(text.includes('a'.repeat(117) + '...'), 'it is clamped, and marked truncated') +}) + +test('a status file that is not JSON cannot drive the terminal through the parse error', async () => { + const { hypHome, stateRoot } = await makeHome() + writePidFile(stateRoot, /** @type {any} */ ({ pid: process.pid, runId: 'r', mode: 'foreground' })) + // `JSON.parse` quotes an excerpt of its input back in the message, so the + // file's own bytes reach `daemon.error` and from there the `error=` field. + const sp = statusFilePath(stateRoot) + await fs.mkdir(path.dirname(sp), { recursive: true }) + await fs.writeFile(sp, 'x' + FORGE) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.ok(report.daemon.error, 'the unparseable file surfaces as a daemon error') + assert.ok(CONTROL_CHARS.test(report.daemon.error), 'whose message quotes the raw bytes back') + const text = renderText(report) + assert.ok(!CONTROL_CHARS.test(text.replace(/\n/g, '')), 'and none of them reaches the terminal') + assert.match(text, /error=Unexpected token/, 'the error is still reported') +})