var assert = require("assert"); var Problems = require("./system-problems.js"); var BOOT_ID = "0123456789abcdef0123456789abcdef"; var MICROS = "1781526896123456"; function record(cursor, fields) { var entry = { __CURSOR: cursor, _BOOT_ID: BOOT_ID, __REALTIME_TIMESTAMP: MICROS }; var names = Object.keys(fields || {}); for (var i = 0; i < names.length; i++) entry[names[i]] = fields[names[i]]; if (entry._SYSTEMD_UNIT === undefined) { if (entry.MESSAGE_ID === 'fc2e22bc6ee647b6b90729ab34a250b1') entry._SYSTEMD_UNIT = 'systemd-coredump@0-42-0.service'; if (entry._COMM === 'systemd-oomd') entry._SYSTEMD_UNIT = 'systemd-oomd.service'; } return JSON.stringify(entry); } // The query is read-only, bounded, metadata-only, and ORs trusted sources. var command = Problems.journalCommand(); assert.strictEqual(command[0], "journalctl"); assert.ok(command.indexOf("--boot=0") >= 0); assert.ok(command.indexOf("--since=-24h") >= 0); assert.ok(command.indexOf("--lines=100") >= 0); assert.ok(command.indexOf("--output=json") >= 0); assert.ok(command.indexOf("--no-pager") >= 0); assert.ok(command.indexOf("--truncate-newline") >= 0); assert.ok(command.some(function (arg) { return arg.indexOf("--grep=^") === 0; })); assert.ok(command.indexOf("MESSAGE_ID=fc2e22bc6ee647b6b90729ab34a250b1") >= 0); assert.ok(command.indexOf("_TRANSPORT=kernel") >= 0); assert.ok(command.indexOf("_COMM=systemd-oomd") >= 0); assert.strictEqual(command.filter(function (arg) { return arg === "+"; }).length, 2); var fieldsArg = command.filter(function (arg) { return arg.indexOf("--output-fields=") === 0; })[0]; assert.ok(fieldsArg.indexOf("COREDUMP_EXE") >= 0); assert.ok(fieldsArg.indexOf("COREDUMP_COMM") >= 0); assert.ok(fieldsArg.indexOf("COREDUMP_PID") >= 0); assert.ok(fieldsArg.indexOf("COREDUMP_SIGNAL_NAME") >= 0); assert.ok(fieldsArg.indexOf("COREDUMP_UNIT") >= 0); assert.ok(fieldsArg.indexOf("COREDUMP_USER_UNIT") >= 0); assert.ok(fieldsArg.indexOf("COREDUMP_UID") >= 0); assert.strictEqual(fieldsArg.indexOf("COREDUMP_CMDLINE"), -1); assert.strictEqual(fieldsArg.indexOf("COREDUMP_ENVIRON"), -1); assert.strictEqual(fieldsArg.indexOf("COREDUMP="), -1); assert.strictEqual(fieldsArg.indexOf("COREDUMP_PACKAGE_JSON"), -1); // Recorded crash metadata is preferred over systemd-coredump's own process metadata. var crash = Problems.parseJournal(record("s=crash", { MESSAGE_ID: "fc2e22bc6ee647b6b90729ab34a250b1", MESSAGE: "Process 4242 (browser) of user 1000 dumped core.\nStack trace follows", COREDUMP_EXE: "/usr/bin/browser", COREDUMP_COMM: "browser", COREDUMP_PID: "4242", COREDUMP_SIGNAL_NAME: "SIGSEGV", COREDUMP_UNIT: "browser.service", COREDUMP_USER_UNIT: "app-browser.scope", COREDUMP_UID: "1000", COREDUMP_CMDLINE: "browser --secret", COREDUMP_ENVIRON: "TOKEN=secret", COREDUMP: "core bytes" })); assert.strictEqual(crash.ok, true); assert.strictEqual(crash.issues.length, 1); var crashIssue = crash.issues[0]; assert.strictEqual(crashIssue.id, "journal:s=crash"); assert.strictEqual(crashIssue.severity, "warning"); assert.strictEqual(crashIssue.icon, ""); assert.strictEqual(crashIssue.label, "process crashed"); assert.strictEqual(crashIssue.context.source, "crash"); assert.strictEqual(crashIssue.context.metadata.executable, "/usr/bin/browser"); assert.strictEqual(crashIssue.context.metadata.comm, "browser"); assert.strictEqual(crashIssue.context.metadata.pid, "4242"); assert.strictEqual(crashIssue.context.metadata.signal, "SIGSEGV"); assert.strictEqual(crashIssue.context.metadata.unit, "browser.service"); assert.strictEqual(crashIssue.context.metadata.userUnit, "app-browser.scope"); assert.strictEqual(crashIssue.context.metadata.uid, "1000"); assert.strictEqual(crashIssue.action.kind, "journal-event"); assert.strictEqual(crashIssue.action.bootId, BOOT_ID); assert.strictEqual(crashIssue.action.timestamp, crashIssue.context.timestamp); assert.ok(/^\d{4}-\d\d-\d\dT/.test(crashIssue.detail)); assert.strictEqual(JSON.stringify(crashIssue).indexOf("secret"), -1); assert.strictEqual(JSON.stringify(crashIssue).indexOf("Stack trace"), -1); // Kernel OOM classification requires both the trusted transport and exact killed-process form. var kernel = Problems.parseJournal(record("s=kernel", { _TRANSPORT: "kernel", MESSAGE: "Memory cgroup out of memory: Killed process 987 (render worker) total-vm:1234kB" })); assert.strictEqual(kernel.ok, true); assert.strictEqual(kernel.issues[0].context.source, "kernel-oom"); assert.strictEqual(kernel.issues[0].context.metadata.comm, "render worker"); assert.strictEqual(kernel.issues[0].context.metadata.pid, "987"); assert.strictEqual(kernel.issues[0].context.metadata.signal, "SIGKILL"); assert.strictEqual(Problems.parseJournal(record("s=fake-kernel", { _TRANSPORT: "stdout", MESSAGE: "Out of memory: Killed process 1 (fake) total-vm:1kB" })).issues.length, 0); assert.strictEqual(Problems.parseJournal(record("s=kernel-noise", { _TRANSPORT: "kernel", MESSAGE: "error: unrelated kernel failure" })).issues.length, 0); // systemd-oomd messages require its trusted comm and an actual kill message. var oomd = Problems.parseJournal(record("s=oomd", { _COMM: "systemd-oomd", MESSAGE: "Killed /user.slice/user-1000.slice/app.slice/browser.scope due to memory pressure for /user.slice" })); assert.strictEqual(oomd.ok, true); assert.strictEqual(oomd.issues[0].context.source, "oomd"); assert.strictEqual(oomd.issues[0].context.metadata.target, "/user.slice/user-1000.slice/app.slice/browser.scope"); assert.strictEqual(Problems.parseJournal(record("s=fake-oomd", { _COMM: "other", MESSAGE: "Killed /user.slice/fake due to memory pressure" })).issues.length, 0); assert.strictEqual(Problems.parseJournal(record("s=oomd-error", { _COMM: "systemd-oomd", MESSAGE: "Failed to update monitored memory pressure candidates" })).issues.length, 0); assert.strictEqual(Problems.parseJournal(record("s=oomd-marked", { _COMM: "systemd-oomd", MESSAGE: "Marked /user.slice/example.scope for killing due to memory pressure" })).issues.length, 0, 'a candidate is not a confirmed kill'); assert.strictEqual(Problems.parseJournal(record('s=forged-crash', { MESSAGE_ID: 'fc2e22bc6ee647b6b90729ab34a250b1', _SYSTEMD_UNIT: 'user@1000.service' })).issues.length, 0, 'client-supplied MESSAGE_ID is insufficient'); assert.strictEqual(Problems.parseJournal(record('s=forged-oomd', { _COMM: 'systemd-oomd', _SYSTEMD_UNIT: 'user@1000.service', MESSAGE: 'Killed /fake.scope due to memory pressure' })).issues.length, 0, 'an unprivileged process name is insufficient'); assert.strictEqual(crashIssue.action.cursor, crashIssue.context.cursor); // Newest incidents lead regardless of journal output order. var older = JSON.parse(record('s=older', {MESSAGE_ID: 'fc2e22bc6ee647b6b90729ab34a250b1'})); var newer = Object.assign({}, older, {__CURSOR: 's=newer', __REALTIME_TIMESTAMP: String(Number(MICROS) + 1000000)}); assert.deepStrictEqual(Problems.parseJournal(JSON.stringify(older) + '\n' + JSON.stringify(newer)).issues.map(issue => issue.id), ['journal:s=newer', 'journal:s=older']); // Empty input is a valid all-clear; malformed input or required event metadata is not. assert.deepStrictEqual(Problems.parseJournal("\n\r\n"), { ok: true, issues: [] }); assert.strictEqual(Problems.parseJournal("{not json").ok, false); assert.strictEqual(Problems.parseJournal(JSON.stringify({ MESSAGE_ID: "fc2e22bc6ee647b6b90729ab34a250b1", _SYSTEMD_UNIT: 'systemd-coredump@0-42-0.service' })).ok, false); assert.deepStrictEqual(Problems.parseJournal(JSON.stringify({ MESSAGE: "unrelated error" })), { ok: true, issues: [] }); // A cursor identifies one event even if duplicate journal rows are supplied. var duplicate = record("s=same", { MESSAGE_ID: "fc2e22bc6ee647b6b90729ab34a250b1", COREDUMP_COMM: "one" }); var deduped = Problems.parseJournal(duplicate + "\n" + duplicate); assert.strictEqual(deduped.ok, true); assert.strictEqual(deduped.issues.length, 1); // Untrusted display metadata is control-free and bounded; omitted sensitive fields stay omitted. var hostile = Problems.parseJournal(record("s=hostile", { MESSAGE_ID: "fc2e22bc6ee647b6b90729ab34a250b1", COREDUMP_COMM: "bad\u0000name\n" + new Array(500).join("x"), COREDUMP_EXE: "/tmp/evil\u001b[31m" + new Array(500).join("y"), COREDUMP_UNIT: "evil\tunit.service" + new Array(500).join("z"), COREDUMP_PID: "12\n34", COREDUMP_UID: "not-a-uid", COREDUMP_SIGNAL_NAME: "SIGSEGV\u0007", COREDUMP_CMDLINE: "must-not-survive", COREDUMP_ENVIRON: "PASSWORD=must-not-survive" })); assert.strictEqual(hostile.ok, true); var hostileIssue = hostile.issues[0]; var hostileJson = JSON.stringify(hostileIssue); assert.strictEqual(/[\x00-\x1f\x7f-\x9f]/.test(hostileIssue.detail), false); assert.strictEqual(/[\x00-\x1f\x7f-\x9f]/.test(hostileIssue.context.metadata.comm), false); assert.strictEqual(/[\x00-\x1f\x7f-\x9f]/.test(hostileIssue.context.metadata.executable), false); assert.strictEqual(/[\x00-\x1f\x7f-\x9f]/.test(hostileIssue.context.metadata.unit), false); assert.ok(hostileIssue.detail.length <= 320); assert.ok(hostileIssue.context.metadata.comm.length <= 160); assert.ok(hostileIssue.context.metadata.executable.length <= 240); assert.strictEqual(hostile.issues[0].context.metadata.pid, ""); assert.strictEqual(hostile.issues[0].context.metadata.uid, ""); assert.strictEqual(hostile.issues[0].context.metadata.signal, ""); assert.strictEqual(hostileJson.indexOf("must-not-survive"), -1); // Parser output remains bounded even if fed more than the command's maximum. var many = []; for (var n = 0; n < 105; n++) { many.push(record("s=many-" + n, { MESSAGE_ID: "fc2e22bc6ee647b6b90729ab34a250b1", COREDUMP_COMM: "app" })); } assert.strictEqual(Problems.parseJournal(many.join("\n")).issues.length, 100); assert.strictEqual(Problems.parseJournal(many.join("\n") + "\n{broken").ok, false); // Grouping is derived, lossless, newest-first, and stable as the window changes. function appCrash(cursor, overrides) { return record(cursor, Object.assign({ MESSAGE_ID: 'fc2e22bc6ee647b6b90729ab34a250b1', COREDUMP_EXE: '/usr/bin/editor', COREDUMP_UID: '1000', COREDUMP_COMM: 'editor', COREDUMP_PID: '10', COREDUMP_SIGNAL_NAME: 'SIGSEGV' }, overrides)); } const groupInput = Problems.parseJournal([ appCrash('s=old', {COREDUMP_SIGNAL_NAME: 'SIGABRT'}), appCrash('s=new', {__REALTIME_TIMESTAMP: String(Number(MICROS) + 2000000), COREDUMP_PID: '20'}), appCrash('s=other-app', {COREDUMP_EXE: '/opt/editor'}), appCrash('s=other-user', {COREDUMP_UID: '1001'}), appCrash('s=missing-exe', {COREDUMP_EXE: ''}), appCrash('s=missing-exe-2', {COREDUMP_EXE: ''}), appCrash('s=missing-uid', {COREDUMP_UID: ''}), appCrash('s=missing-uid-2', {COREDUMP_UID: ''}), record('s=oom-same-name', {_TRANSPORT: 'kernel', MESSAGE: 'Out of memory: Killed process 10 (editor) total-vm:1kB'}) ].join('\n')).issues; const originalInput = JSON.stringify(groupInput); const grouped = Problems.groupIncidents(groupInput); assert.strictEqual(grouped.length, 8); assert.strictEqual(grouped[0].id, 'crashes:["1000","/usr/bin/editor"]'); assert.deepStrictEqual(grouped[0].events.map(event => event.cursor), ['s=new', 's=old']); assert.deepStrictEqual(grouped[0].events.map(event => event.metadata.signal), ['SIGSEGV', 'SIGABRT']); assert.deepStrictEqual(grouped[0].context, groupInput[0].context); assert.deepStrictEqual(grouped[0].action, groupInput[0].action, 'open inspects the latest exact event'); assert.strictEqual(JSON.stringify(groupInput), originalInput, 'never mutate journal-owned records'); assert.strictEqual(Problems.groupIncidents(groupInput.filter(issue => issue.context.cursor !== 's=new'))[0].id, grouped[0].id); assert.strictEqual(Problems.groupIncidents(groupInput.filter(issue => issue.context.cursor !== 's=old'))[0].id, grouped[0].id); assert.deepStrictEqual(Problems.groupIncidents([]), []); for (const executable of ['/tmp/editor\tname', '/tmp/' + 'a'.repeat(240), 'relative/editor']) { const ambiguous = Problems.parseJournal([appCrash('s=a', {COREDUMP_EXE: executable}), appCrash('s=b', {COREDUMP_EXE: executable})].join('\n')).issues; assert.strictEqual(Problems.groupIncidents(ambiguous).length, 2, 'lossy identity never merges applications'); } const duplicateCrash = appCrash('s=duplicate'); assert.strictEqual(Problems.groupIncidents(Problems.parseJournal(duplicateCrash + '\n' + duplicateCrash).issues)[0].events.length, 1); const capped = Problems.groupIncidents(Problems.parseJournal(Array.from({length: 105}, (_, i) => appCrash('s=bounded-' + i)).join('\n')).issues); assert.strictEqual(capped.length, 1); assert.strictEqual(capped[0].events.length, 100, 'every retained occurrence, without a second per-group truncation'); console.log("system-problems.test.js: ALL PASS");