W
File size: 8,495 Bytes
2b64d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// Audit-driven hardening (v2.0.42).
//
// Three classes of bugs surfaced by the codex audit after the LS
// file-busy + docker self-update work landed:
//
//   1. conversation-pool checkout deleted the entry BEFORE validating
//      callerKey, so a fingerprint collision from a different caller
//      would discard the rightful owner's cached cascade.
//   2. cacheKey hashed only the request body, no caller scope β€” two
//      tenants sending identical "hi" could read each other's cached
//      response.
//   3. runtime-config / proxy.json / model-access.json / stats.json
//      all wrote with bare writeFileSync(target, ...). A SIGTERM mid
//      docker stop could truncate the file; next startup parsed empty
//      JSON, logged a warning, fell back to defaults β€” silently losing
//      every saved setting. Factor an atomic-rename writer and use it
//      everywhere.

import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { writeJsonAtomic } from '../src/fs-atomic.js';
import { cacheKey } from '../src/cache.js';
import { checkout, checkin, poolClear } from '../src/conversation-pool.js';

describe('writeJsonAtomic (audit fix #1: durable config writes)', () => {
  test('writes JSON to the target via tmp + rename', () => {
    const dir = mkdtempSync(join(tmpdir(), 'wa-atomic-'));
    try {
      const target = join(dir, 'config.json');
      writeJsonAtomic(target, { hello: 'world', n: 1 });
      assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { hello: 'world', n: 1 });
      // The .tmp sibling must NOT exist after a successful write β€”
      // otherwise we'd leak garbage into DATA_DIR every save.
      assert.ok(!existsSync(`${target}.tmp`), '.tmp file should be removed after rename');
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test('cleans up the .tmp file when stringify throws', () => {
    const dir = mkdtempSync(join(tmpdir(), 'wa-atomic-'));
    try {
      const target = join(dir, 'config.json');
      // Pre-create the target so we can verify it stays untouched on
      // failure (the whole point of atomic-rename: a failed write
      // never corrupts existing data).
      writeJsonAtomic(target, { existing: true });
      // Circular ref forces JSON.stringify to throw.
      const circular = {}; circular.self = circular;
      assert.throws(() => writeJsonAtomic(target, circular));
      // Existing target must still be the previous payload.
      assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { existing: true });
      // No leaked .tmp after failure.
      const leftover = readdirSync(dir).filter(f => f.endsWith('.tmp'));
      assert.deepEqual(leftover, [], `expected no .tmp leftovers, got ${leftover.join(',')}`);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test('overwrites an existing target byte-for-byte', () => {
    const dir = mkdtempSync(join(tmpdir(), 'wa-atomic-'));
    try {
      const target = join(dir, 'config.json');
      writeFileSync(target, '{"old": "garbage", "extra": "padding to be longer"}');
      writeJsonAtomic(target, { v: 2 });
      assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { v: 2 });
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});

describe('cacheKey (audit fix #2: cross-tenant cache leak)', () => {
  test('two callers sending the same body get DIFFERENT cache keys', () => {
    const body = {
      model: 'claude-sonnet-4.6',
      messages: [{ role: 'user', content: 'hi' }],
    };
    const k1 = cacheKey(body, 'api:abc:user:alice');
    const k2 = cacheKey(body, 'api:def:user:bob');
    assert.notEqual(k1, k2,
      'identical body must produce different cache keys for different callers');
  });

  test('same caller + same body still produces a stable key (cache works)', () => {
    const body = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] };
    assert.equal(cacheKey(body, 'api:abc'), cacheKey(body, 'api:abc'));
  });

  test('empty callerKey is permitted (test path) but distinct from any real scope', () => {
    const body = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] };
    const anon = cacheKey(body, '');
    const real = cacheKey(body, 'api:abc');
    assert.notEqual(anon, real);
  });

  test('cannot be tricked by a body field that mimics the scope prefix', () => {
    // The internal serialization is `<callerKey>\0<json>`. A caller
    // crafting `model: 'api:victim'` must NOT collide with another
    // caller's cache key.
    const k1 = cacheKey({ model: 'api:victim:user:bob', messages: [] }, 'api:attacker');
    const k2 = cacheKey({ model: '', messages: [] }, 'api:victim:user:bob');
    assert.notEqual(k1, k2);
  });
});

describe('conversation-pool checkout (audit fix #3: validate-before-delete)', () => {
  test('callerKey mismatch leaves the entry in place for the rightful owner', () => {
    poolClear();
    const fp = 'fp-shared-by-accident';
    const entry = {
      cascadeId: 'c-1', sessionId: 's-1', lsPort: 42100,
      apiKey: 'k-alice', stepOffset: 0, generatorOffset: 0,
      historyCoverage: null, createdAt: Date.now(), lastAccess: Date.now(),
    };
    checkin(fp, entry, 'caller-alice');

    // Bob walks in with the same fingerprint (rare but possible).
    const wrong = checkout(fp, 'caller-bob');
    assert.equal(wrong, null, 'wrong caller must miss');

    // Alice comes back. Her entry must STILL be there β€” the previous
    // bug was deleting on caller mismatch and stranding her.
    const right = checkout(fp, 'caller-alice');
    assert.ok(right, 'rightful owner must still find their entry after a wrong-caller miss');
    assert.equal(right.cascadeId, 'c-1');

    poolClear();
  });

  test('successful checkout still removes the entry (one-shot semantics intact)', () => {
    poolClear();
    const fp = 'fp-x';
    checkin(fp, {
      cascadeId: 'c-2', sessionId: 's-2', lsPort: 42100,
      apiKey: 'k', stepOffset: 0, generatorOffset: 0,
      historyCoverage: null, createdAt: Date.now(), lastAccess: Date.now(),
    }, 'caller-x');

    const first = checkout(fp, 'caller-x');
    assert.ok(first);
    const second = checkout(fp, 'caller-x');
    assert.equal(second, null, 'second checkout for same fp must miss β€” entry was consumed');

    poolClear();
  });

  test('expected.apiKey mismatch also leaves entry in place', () => {
    poolClear();
    const fp = 'fp-y';
    checkin(fp, {
      cascadeId: 'c-3', sessionId: 's-3', lsPort: 42100,
      apiKey: 'k-A', stepOffset: 0, generatorOffset: 0,
      historyCoverage: null, createdAt: Date.now(), lastAccess: Date.now(),
    }, 'caller-y');

    const wrongKey = checkout(fp, 'caller-y', { apiKey: 'k-B' });
    assert.equal(wrongKey, null);

    const rightKey = checkout(fp, 'caller-y', { apiKey: 'k-A' });
    assert.ok(rightKey, 'matching apiKey must succeed');
    assert.equal(rightKey.apiKey, 'k-A');

    poolClear();
  });
});

describe('atomic write call sites use writeJsonAtomic', () => {
  // Static check β€” the four files we ported must now import the
  // helper instead of writeFileSync. A future refactor that drops
  // the import without re-introducing tmp+rename should fail this
  // test rather than silently regress to the truncated-JSON bug.
  const __dirname = new URL('.', import.meta.url).pathname.replace(/^\//, '');
  const ROOT = join(__dirname, '..');
  const FILES = [
    'src/runtime-config.js',
    'src/dashboard/stats.js',
    'src/dashboard/proxy-config.js',
    'src/dashboard/model-access.js',
  ];
  for (const rel of FILES) {
    test(`${rel} uses writeJsonAtomic (no bare writeFileSync to its config target)`, () => {
      const src = readFileSync(join(ROOT, rel), 'utf8');
      assert.match(src, /writeJsonAtomic/,
        `${rel} should import and use writeJsonAtomic`);
      // Bare writeFileSync calls are still allowed for non-config
      // paths (e.g. an export endpoint streaming a file), but the
      // simple `writeFileSync(FILE, ...)` shape we just removed must
      // not creep back in.
      assert.ok(!/writeFileSync\((?:STATS_FILE|FILE|PROXY_FILE|ACCESS_FILE)\b/.test(src),
        `${rel} still has a bare writeFileSync to its config constant`);
    });
  }
});