Skip to main content

cortex_llm/
replay.rs

1//! Deterministic LLM adapter that replays canned responses from on-disk
2//! fixtures.
3//!
4//! `ReplayAdapter` is the default adapter for CI and any other context where
5//! we refuse to spend tokens or leak prompts. It loads a directory of JSON
6//! fixtures plus a sibling `INDEX.toml` and:
7//!
8//! 1. Verifies every fixture path is inside the fixtures directory.
9//! 2. Verifies every fixture's on-disk BLAKE3 hash matches the value pinned
10//!    in `INDEX.toml` (Lane 1.D / threat row T-RM-1: a fixture-keyed
11//!    reflection is otherwise trivially forgeable by any actor with write
12//!    access to the fixtures directory).
13//! 3. On each `complete()` call, looks up `(model, prompt_hash)` and returns
14//!    the pinned response.
15//!
16//! Fixture format is documented in `crates/cortex-llm/fixtures/schema.json`.
17
18use std::collections::HashMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24
25use crate::adapter::{blake3_hex, LlmAdapter, LlmError, LlmRequest, LlmResponse, TokenUsage};
26
27/// One row of the on-disk `INDEX.toml`.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct IndexEntry {
30    /// Path to the fixture, relative to the fixtures directory and without
31    /// any `..` components. The path is rooted inside the fixtures directory
32    /// at load time.
33    pub path: String,
34    /// Lowercase hex BLAKE3 of the fixture's exact on-disk bytes.
35    pub blake3: String,
36}
37
38/// On-disk representation of the signed fixture manifest.
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct FixtureIndex {
41    /// Every fixture under the fixtures directory that the adapter trusts.
42    #[serde(default, rename = "fixture")]
43    pub fixtures: Vec<IndexEntry>,
44}
45
46/// Match criteria a fixture declares against incoming [`LlmRequest`]s.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct FixtureMatch {
49    /// `LlmRequest::model` to match exactly.
50    pub model: String,
51    /// `LlmRequest::prompt_hash()` to match exactly.
52    pub prompt_hash: String,
53}
54
55/// Response payload pinned by a fixture.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct FixtureResponse {
58    /// The reply text returned to the caller.
59    pub text: String,
60    /// Optional structured form when the original call asked for JSON.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub parsed_json: Option<serde_json::Value>,
63    /// Model the fixture claims emitted the reply (defaults to the request's
64    /// model on absence — most fixtures echo the requested model).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub model: Option<String>,
67    /// Token-usage echo, optional.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub usage: Option<TokenUsage>,
70}
71
72/// Parsed shape of a single fixture file.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct FixtureFile {
75    /// Match criteria.
76    pub request_match: FixtureMatch,
77    /// Pinned response.
78    pub response: FixtureResponse,
79}
80
81/// Deterministic adapter that returns fixture-pinned responses.
82///
83/// Thread-safe: holds an in-memory map keyed by `(model, prompt_hash)`.
84#[derive(Debug)]
85pub struct ReplayAdapter {
86    fixtures_dir: PathBuf,
87    by_key: HashMap<(String, String), FixtureFile>,
88}
89
90impl ReplayAdapter {
91    /// Construct a new replay adapter rooted at `fixtures_dir`.
92    ///
93    /// The directory MUST contain an `INDEX.toml` listing every trusted
94    /// fixture and its expected BLAKE3 hash. Any fixture in the directory
95    /// that is **not** listed in the index, or whose on-disk bytes hash to a
96    /// different value than the index records, causes
97    /// [`LlmError::FixtureIntegrityFailed`].
98    ///
99    /// The check is upfront — once `new()` returns, the in-memory map is
100    /// trusted for the lifetime of the adapter.
101    pub fn new<P: Into<PathBuf>>(fixtures_dir: P) -> Result<Self, LlmError> {
102        let fixtures_dir = fixtures_dir.into();
103        let canonical_root = fs::canonicalize(&fixtures_dir)
104            .map_err(|e| LlmError::Io(format!("fixtures dir {}: {e}", fixtures_dir.display())))?;
105
106        let index_path = canonical_root.join("INDEX.toml");
107        let index_text = fs::read_to_string(&index_path).map_err(|e| {
108            LlmError::FixtureIntegrityFailed(format!(
109                "INDEX.toml not readable at {}: {e}",
110                index_path.display()
111            ))
112        })?;
113        let index: FixtureIndex = toml::from_str(&index_text).map_err(|e| {
114            LlmError::FixtureIntegrityFailed(format!("INDEX.toml parse error: {e}"))
115        })?;
116
117        // Build the trusted fixture set first (path → expected hash). This
118        // also catches duplicate INDEX entries.
119        let mut trusted: HashMap<PathBuf, String> = HashMap::new();
120        for entry in &index.fixtures {
121            let resolved = resolve_under(&canonical_root, &entry.path)?;
122            if trusted
123                .insert(resolved.clone(), entry.blake3.clone())
124                .is_some()
125            {
126                return Err(LlmError::FixtureIntegrityFailed(format!(
127                    "duplicate INDEX entry: {}",
128                    entry.path
129                )));
130            }
131        }
132
133        // Reject any *.json fixture that is not in the trusted set
134        // (defence against an attacker dropping a fresh fixture into the
135        // directory after the index was committed). `INDEX.toml` itself is
136        // skipped, as is `schema.json`.
137        for dirent in fs::read_dir(&canonical_root)
138            .map_err(|e| LlmError::Io(format!("scanning {}: {e}", canonical_root.display())))?
139        {
140            let dirent = dirent.map_err(|e| LlmError::Io(format!("dirent: {e}")))?;
141            let path = dirent.path();
142            if !path.is_file() {
143                continue;
144            }
145            let name = path
146                .file_name()
147                .and_then(|s| s.to_str())
148                .unwrap_or_default();
149            if name == "INDEX.toml" || name == "schema.json" {
150                continue;
151            }
152            if !name.ends_with(".json") {
153                continue;
154            }
155            if !trusted.contains_key(&path) {
156                return Err(LlmError::FixtureIntegrityFailed(format!(
157                    "unsigned fixture present (not in INDEX.toml): {}",
158                    path.display()
159                )));
160            }
161        }
162
163        // Verify each trusted fixture's on-disk hash and load it.
164        let mut by_key: HashMap<(String, String), FixtureFile> = HashMap::new();
165        for (path, expected_hash) in trusted {
166            let bytes = fs::read(&path).map_err(|e| {
167                LlmError::FixtureIntegrityFailed(format!("read {}: {e}", path.display()))
168            })?;
169            let actual = blake3_hex(&bytes);
170            if !constant_time_eq(actual.as_bytes(), expected_hash.as_bytes()) {
171                return Err(LlmError::FixtureIntegrityFailed(format!(
172                    "hash mismatch for {} (expected {expected_hash}, got {actual})",
173                    path.display()
174                )));
175            }
176            let fixture: FixtureFile = serde_json::from_slice(&bytes).map_err(|e| {
177                LlmError::FixtureIntegrityFailed(format!("fixture {} parse: {e}", path.display()))
178            })?;
179            let key = (
180                fixture.request_match.model.clone(),
181                fixture.request_match.prompt_hash.clone(),
182            );
183            if by_key.insert(key, fixture).is_some() {
184                return Err(LlmError::FixtureIntegrityFailed(format!(
185                    "duplicate (model, prompt_hash) match in fixtures dir {}",
186                    canonical_root.display()
187                )));
188            }
189        }
190
191        Ok(Self {
192            fixtures_dir: canonical_root,
193            by_key,
194        })
195    }
196
197    /// Path the adapter is rooted at.
198    #[must_use]
199    pub fn fixtures_dir(&self) -> &Path {
200        &self.fixtures_dir
201    }
202
203    /// Number of trusted fixtures currently loaded.
204    #[must_use]
205    pub fn fixture_count(&self) -> usize {
206        self.by_key.len()
207    }
208}
209
210#[async_trait]
211impl LlmAdapter for ReplayAdapter {
212    fn adapter_id(&self) -> &'static str {
213        "replay"
214    }
215
216    async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
217        let prompt_hash = req.prompt_hash();
218        let key = (req.model.clone(), prompt_hash.clone());
219        let Some(fixture) = self.by_key.get(&key) else {
220            return Err(LlmError::NoFixture {
221                model: req.model,
222                prompt_hash,
223            });
224        };
225
226        let text = fixture.response.text.clone();
227        Ok(LlmResponse {
228            text: text.clone(),
229            parsed_json: fixture.response.parsed_json.clone(),
230            model: fixture
231                .response
232                .model
233                .clone()
234                .unwrap_or_else(|| req.model.clone()),
235            usage: fixture.response.usage.clone(),
236            raw_hash: blake3_hex(text.as_bytes()),
237        })
238    }
239}
240
241/// Resolve `relative` under `root`, refusing any value that escapes the root
242/// after canonicalization. Returns the absolute, canonical path on success.
243fn resolve_under(root: &Path, relative: &str) -> Result<PathBuf, LlmError> {
244    let candidate = root.join(relative);
245    if candidate
246        .components()
247        .any(|c| matches!(c, std::path::Component::ParentDir))
248    {
249        return Err(LlmError::FixtureIntegrityFailed(format!(
250            "fixture path escapes fixtures dir: {relative}"
251        )));
252    }
253    let canonical = fs::canonicalize(&candidate).map_err(|e| {
254        LlmError::FixtureIntegrityFailed(format!(
255            "fixture path {} not resolvable: {e}",
256            candidate.display()
257        ))
258    })?;
259    if !canonical.starts_with(root) {
260        return Err(LlmError::FixtureIntegrityFailed(format!(
261            "fixture path {} escapes fixtures dir {}",
262            canonical.display(),
263            root.display()
264        )));
265    }
266    Ok(canonical)
267}
268
269/// Constant-time byte comparison used for the hash check. Pure stdlib; we do
270/// not pull in `subtle` for one helper.
271fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
272    if a.len() != b.len() {
273        return false;
274    }
275    let mut diff: u8 = 0;
276    for (x, y) in a.iter().zip(b.iter()) {
277        diff |= x ^ y;
278    }
279    diff == 0
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::adapter::{LlmMessage, LlmRole};
286    use std::fs::File;
287    use std::io::Write;
288    use tempfile::TempDir;
289
290    fn sample_request(content: &str) -> LlmRequest {
291        LlmRequest {
292            model: "claude-3-5-sonnet-20240620".into(),
293            system: "you are a test".into(),
294            messages: vec![LlmMessage {
295                role: LlmRole::User,
296                content: content.to_string(),
297            }],
298            temperature: 0.0,
299            max_tokens: 256,
300            json_schema: None,
301            timeout_ms: 30_000,
302        }
303    }
304
305    /// Write a single fixture under `dir/name`, produce its expected match
306    /// criteria from `req`, and return both the resolved path and the
307    /// fixture-file struct that was written.
308    fn write_fixture(
309        dir: &Path,
310        name: &str,
311        req: &LlmRequest,
312        reply: &str,
313    ) -> (PathBuf, FixtureFile) {
314        let fixture = FixtureFile {
315            request_match: FixtureMatch {
316                model: req.model.clone(),
317                prompt_hash: req.prompt_hash(),
318            },
319            response: FixtureResponse {
320                text: reply.into(),
321                parsed_json: None,
322                model: None,
323                usage: None,
324            },
325        };
326        let path = dir.join(name);
327        let bytes = serde_json::to_vec_pretty(&fixture).unwrap();
328        let mut f = File::create(&path).unwrap();
329        f.write_all(&bytes).unwrap();
330        (path, fixture)
331    }
332
333    fn write_index(dir: &Path, entries: &[(&str, &str)]) {
334        let mut s = String::new();
335        for (path, hash) in entries {
336            s.push_str(&format!(
337                "[[fixture]]\npath = \"{path}\"\nblake3 = \"{hash}\"\n\n"
338            ));
339        }
340        fs::write(dir.join("INDEX.toml"), s).unwrap();
341    }
342
343    fn hash_file(p: &Path) -> String {
344        blake3_hex(&fs::read(p).unwrap())
345    }
346
347    #[tokio::test]
348    async fn replay_returns_matching_fixture() {
349        let tmp = TempDir::new().unwrap();
350        let dir = tmp.path();
351
352        let req_a = sample_request("hello");
353        let req_b = sample_request("world");
354        let (path_a, _) = write_fixture(dir, "a.json", &req_a, "hi from A");
355        let (path_b, _) = write_fixture(dir, "b.json", &req_b, "hi from B");
356        write_index(
357            dir,
358            &[
359                ("a.json", &hash_file(&path_a)),
360                ("b.json", &hash_file(&path_b)),
361            ],
362        );
363
364        let adapter = ReplayAdapter::new(dir).unwrap();
365        assert_eq!(adapter.fixture_count(), 2);
366        let resp = adapter.complete(req_b).await.unwrap();
367        assert_eq!(resp.text, "hi from B");
368        assert_eq!(resp.model, "claude-3-5-sonnet-20240620");
369    }
370
371    #[tokio::test]
372    async fn replay_rejects_unsigned_fixture() {
373        let tmp = TempDir::new().unwrap();
374        let dir = tmp.path();
375
376        let req = sample_request("hello");
377        let (path, _) = write_fixture(dir, "a.json", &req, "ok");
378        // Drop a SECOND fixture that is NOT listed in the index.
379        let (_path_b, _) = write_fixture(dir, "b-unsigned.json", &sample_request("world"), "boom");
380        // INDEX only signs the first one.
381        write_index(dir, &[("a.json", &hash_file(&path))]);
382
383        let err = ReplayAdapter::new(dir).unwrap_err();
384        match err {
385            LlmError::FixtureIntegrityFailed(msg) => {
386                assert!(
387                    msg.contains("unsigned fixture present"),
388                    "unexpected message: {msg}"
389                );
390            }
391            other => panic!("expected FixtureIntegrityFailed, got {other:?}"),
392        }
393    }
394
395    #[tokio::test]
396    async fn replay_rejects_hash_mismatch() {
397        let tmp = TempDir::new().unwrap();
398        let dir = tmp.path();
399
400        let req = sample_request("hello");
401        let (path, _) = write_fixture(dir, "a.json", &req, "ok");
402        let original_hash = hash_file(&path);
403        write_index(dir, &[("a.json", &original_hash)]);
404
405        // Mutate one byte AFTER the index is written.
406        let mut bytes = fs::read(&path).unwrap();
407        // flip the last byte (which is `\n` or `}` either way: still valid bytes)
408        let last = bytes.len() - 1;
409        bytes[last] = bytes[last].wrapping_add(1);
410        fs::write(&path, bytes).unwrap();
411
412        let err = ReplayAdapter::new(dir).unwrap_err();
413        match err {
414            LlmError::FixtureIntegrityFailed(msg) => {
415                assert!(msg.contains("hash mismatch"), "unexpected message: {msg}");
416            }
417            other => panic!("expected FixtureIntegrityFailed, got {other:?}"),
418        }
419    }
420
421    #[tokio::test]
422    async fn replay_returns_no_fixture_when_unmatched() {
423        let tmp = TempDir::new().unwrap();
424        let dir = tmp.path();
425
426        let req = sample_request("hello");
427        let (path, _) = write_fixture(dir, "a.json", &req, "ok");
428        write_index(dir, &[("a.json", &hash_file(&path))]);
429
430        let adapter = ReplayAdapter::new(dir).unwrap();
431        let other = sample_request("not in any fixture");
432        let err = adapter.complete(other).await.unwrap_err();
433        assert!(matches!(err, LlmError::NoFixture { .. }));
434    }
435
436    #[test]
437    fn missing_index_is_integrity_failure() {
438        let tmp = TempDir::new().unwrap();
439        let err = ReplayAdapter::new(tmp.path()).unwrap_err();
440        assert!(matches!(err, LlmError::FixtureIntegrityFailed(_)));
441    }
442}