From cedcf9a7012f3abd52a71a6a2abf3b1e1170e2d0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Feb 2026 11:35:22 -0800 Subject: [PATCH] test: add 3 tests for TOCTOU catch paths and NaN date sort fallback (round 84) - getSessionById returns null for broken symlink (session-manager.js:307-310) - findFiles skips broken symlinks matching the pattern (utils.js:170-173) - listAliases sorts entries with invalid/missing dates via getTime() || 0 fallback --- tests/lib/session-aliases.test.js | 48 +++++++++++++++++++++++++++++++ tests/lib/session-manager.test.js | 36 +++++++++++++++++++++++ tests/lib/utils.test.js | 34 ++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js index 71ac2072..98e5060f 100644 --- a/tests/lib/session-aliases.test.js +++ b/tests/lib/session-aliases.test.js @@ -1154,6 +1154,54 @@ function runTests() { } })) passed++; else failed++; + // ── Round 84: listAliases sort NaN date fallback (getTime() || 0) ── + console.log('\nRound 84: listAliases (NaN date fallback in sort comparator):'); + + if (test('listAliases sorts entries with invalid/missing dates to the end via || 0 fallback', () => { + // session-aliases.js line 257: + // (new Date(b.updatedAt || b.createdAt || 0).getTime() || 0) - ... + // When updatedAt and createdAt are both invalid strings, getTime() returns NaN. + // The outer || 0 converts NaN to 0 (epoch time), pushing the entry to the end. + resetAliases(); + const data = aliases.loadAliases(); + + // Entry with valid dates — should sort first (newest) + data.aliases['valid-alias'] = { + sessionPath: '/sessions/valid', + createdAt: '2026-02-10T12:00:00.000Z', + updatedAt: '2026-02-10T12:00:00.000Z', + title: 'Valid' + }; + + // Entry with invalid date strings — getTime() → NaN → || 0 → epoch (oldest) + data.aliases['nan-alias'] = { + sessionPath: '/sessions/nan', + createdAt: 'not-a-date', + updatedAt: 'also-invalid', + title: 'NaN dates' + }; + + // Entry with missing date fields — undefined || undefined || 0 → new Date(0) → epoch + data.aliases['missing-alias'] = { + sessionPath: '/sessions/missing', + title: 'Missing dates' + // No createdAt or updatedAt + }; + + aliases.saveAliases(data); + const list = aliases.listAliases(); + + assert.strictEqual(list.length, 3, 'Should list all 3 aliases'); + // Valid-dated entry should be first (newest by updatedAt) + assert.strictEqual(list[0].name, 'valid-alias', + 'Entry with valid dates should sort first'); + // The two invalid-dated entries sort to epoch (0), so they come after + assert.ok( + (list[1].name === 'nan-alias' || list[1].name === 'missing-alias') && + (list[2].name === 'nan-alias' || list[2].name === 'missing-alias'), + 'Entries with invalid/missing dates should sort to the end'); + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/session-manager.test.js b/tests/lib/session-manager.test.js index 56f03281..d96c3dc9 100644 --- a/tests/lib/session-manager.test.js +++ b/tests/lib/session-manager.test.js @@ -1349,6 +1349,42 @@ src/main.ts } })) passed++; else failed++; + // ── Round 84: getSessionById TOCTOU — statSync catch returns null for broken symlink ── + console.log('\nRound 84: getSessionById (broken symlink — statSync catch):'); + + if (test('getSessionById returns null when matching session is a broken symlink', () => { + // getSessionById at line 307-310: statSync throws for broken symlinks, + // the catch returns null (file deleted between readdir and stat). + const isoHome = path.join(os.tmpdir(), `ecc-r84-getbyid-toctou-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Create a broken symlink that matches a session ID pattern + const brokenFile = '2026-02-11-deadbeef-session.tmp'; + fs.symlinkSync('/nonexistent/target/that/does/not/exist', path.join(sessionsDir, brokenFile)); + + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = isoHome; + process.env.USERPROFILE = isoHome; + delete require.cache[require.resolve('../../scripts/lib/session-manager')]; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const freshSM = require('../../scripts/lib/session-manager'); + + // Search by the short ID "deadbeef" — should match the broken symlink + const result = freshSM.getSessionById('deadbeef'); + assert.strictEqual(result, null, + 'Should return null when matching session file is a broken symlink'); + } finally { + process.env.HOME = origHome; + process.env.USERPROFILE = origUserProfile; + delete require.cache[require.resolve('../../scripts/lib/session-manager')]; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + fs.rmSync(isoHome, { recursive: true, force: true }); + } + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 45ac1e24..85209f12 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -1131,6 +1131,40 @@ function runTests() { } })) passed++; else failed++; + // ── Round 84: findFiles inner statSync catch (TOCTOU — broken symlink) ── + console.log('\nRound 84: findFiles (inner statSync catch — broken symlink):'); + + if (test('findFiles skips broken symlinks that match the pattern', () => { + // findFiles at utils.js:170-173: readdirSync returns entries including broken + // symlinks (entry.isFile() returns false for broken symlinks, but the test also + // verifies the overall robustness). On some systems, broken symlinks can be + // returned by readdirSync and pass through isFile() depending on the driver. + // More importantly: if statSync throws inside the inner loop, catch continues. + // + // To reliably trigger the statSync catch: create a real file, list it, then + // simulate the race. Since we can't truly race, we use a broken symlink which + // will at minimum verify the function doesn't crash on unusual dir entries. + const tmpDir = path.join(utils.getTempDir(), `ecc-r84-findfiles-toctou-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + // Create a real file and a broken symlink, both matching *.txt + const realFile = path.join(tmpDir, 'real.txt'); + fs.writeFileSync(realFile, 'content'); + const brokenLink = path.join(tmpDir, 'broken.txt'); + fs.symlinkSync('/nonexistent/path/does/not/exist', brokenLink); + + try { + const results = utils.findFiles(tmpDir, '*.txt'); + // The real file should be found; the broken symlink should be skipped + const paths = results.map(r => r.path); + assert.ok(paths.some(p => p.includes('real.txt')), 'Should find the real file'); + assert.ok(!paths.some(p => p.includes('broken.txt')), + 'Should not include broken symlink in results'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + // Summary console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`);