From f91c37e50f0d78c9f64b6ab1e5d81afa1842ff03 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Apr 2026 18:22:32 +0000 Subject: [PATCH] fix(security): enforce max-bytes cap on /api/file + MCP read_file (RAN-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a size guard, /api/file and the read_file MCP tool loaded entire files into the JVM heap before the optional line slice was applied. A multi-GB file in an indexed codebase trivially OOM'd the serving process, giving anyone with HTTP access a DoS lever against the read-only API and MCP endpoints. Adds a configurable cap (default 5 MiB, key: serving.max_file_bytes) and routes both entry points through a single SafeFileReader: - No line range → check on-disk size first; reject before reading. - With a line range → stream via BufferedReader, apply start/end filter, and track accumulated UTF-8 byte count against the cap. REST returns HTTP 413 (CONTENT_TOO_LARGE); MCP returns the usual JSON {\"error\": ...} payload. Both emit a message shaped like \"File exceeds max size: N bytes (max M bytes)\". Config is plumbed end-to-end through the unified config stack: ServingConfig.maxFileBytes, ConfigDefaults.builtIn() (5 MiB), ConfigMerger, EnvVarOverlay (CODEIQ_SERVING_MAXFILEBYTES), snake_case YAML key with camelCase alias, UnifiedConfigAdapter → legacy CodeIqConfig.maxFileBytes (with a >=1 clamp in the setter). Tests added: - SafeFileReaderTest: whole-file reject, line-range streaming, line-range reject mid-stream, negative startLine clamping. - GraphControllerTest: 413 on oversize whole-file read, 200 on narrow line range when the whole file exceeds cap. - McpToolsTest: \"exceeds max size\" error for oversize read, line-range passthrough, line-range reject. - UnifiedConfigAdapterTest: explicit maxFileBytes overrides default; absent value falls back to CodeIqConfig default (5 MiB). - ConfigResolverTest / ConfigValidatorTest / ConfigExplainSubcommandTest: updated for the new ServingConfig record shape. Default documented in docs/codeiq.yml.example. Co-Authored-By: Paperclip --- docs/codeiq.yml.example | 1 + .../iq/api/GraphController.java | 20 ++--- .../iq/api/SafeFileReader.java | 77 +++++++++++++++++++ .../iq/config/CodeIqConfig.java | 11 +++ .../iq/config/UnifiedConfigAdapter.java | 3 + .../iq/config/unified/ConfigDefaults.java | 1 + .../iq/config/unified/ConfigMerger.java | 7 +- .../iq/config/unified/EnvVarOverlay.java | 5 +- .../iq/config/unified/ServingConfig.java | 4 +- .../config/unified/UnifiedConfigLoader.java | 2 + .../randomcodespace/iq/mcp/McpTools.java | 20 +---- .../iq/api/GraphControllerTest.java | 49 ++++++++++++ .../iq/api/SafeFileReaderTest.java | 68 ++++++++++++++++ .../iq/cli/ConfigExplainSubcommandTest.java | 2 +- .../iq/config/CodeIqConfigTestSupport.java | 1 + .../iq/config/UnifiedConfigAdapterTest.java | 39 +++++++++- .../iq/config/unified/ConfigResolverTest.java | 2 +- .../config/unified/ConfigValidatorTest.java | 2 +- .../randomcodespace/iq/mcp/McpToolsTest.java | 39 ++++++++++ 19 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java create mode 100644 src/test/java/io/github/randomcodespace/iq/api/SafeFileReaderTest.java diff --git a/docs/codeiq.yml.example b/docs/codeiq.yml.example index 489628b4..d25dc914 100644 --- a/docs/codeiq.yml.example +++ b/docs/codeiq.yml.example @@ -54,6 +54,7 @@ serving: port: 8080 # HTTP port for REST + MCP + UI bind_address: 0.0.0.0 # interface to bind; 127.0.0.1 for localhost-only read_only: false # must be false in non-prod; CI gate enforces this + max_file_bytes: 5242880 # cap on /api/file + MCP read_file (bytes); 5 MiB default — rejects with HTTP 413 neo4j: dir: .codeiq/graph/graph.db # embedded Neo4j data directory page_cache_mb: 256 # Neo4j page cache (MB) diff --git a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java index 2cd8c6cc..fcb9e616 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -17,7 +17,6 @@ import io.github.randomcodespace.iq.model.NodeKind; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; @@ -291,23 +290,14 @@ public ResponseEntity readFile( return ResponseEntity.notFound().build(); } try { - String content = Files.readString(resolvedReal, StandardCharsets.UTF_8); - if (startLine != null || endLine != null) { - String[] lines = content.split("\n", -1); - int start = (startLine != null ? startLine : 1); - int end = (endLine != null ? endLine : lines.length); - start = Math.max(1, Math.min(start, lines.length)); - end = Math.max(start, Math.min(end, lines.length)); - StringBuilder sb = new StringBuilder(); - for (int i = start - 1; i < end; i++) { - if (i > start - 1) sb.append('\n'); - sb.append(lines[i]); - } - content = sb.toString(); - } + String content = SafeFileReader.read(resolvedReal, startLine, endLine, config.getMaxFileBytes()); return ResponseEntity.ok() .contentType(MediaType.TEXT_PLAIN) .body(content); + } catch (SafeFileReader.FileTooLargeException tooLarge) { + return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE) + .contentType(MediaType.TEXT_PLAIN) + .body(tooLarge.getMessage()); } catch (IOException e) { return ResponseEntity.status(500) .contentType(MediaType.TEXT_PLAIN) diff --git a/src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java b/src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java new file mode 100644 index 00000000..8579ea98 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/api/SafeFileReader.java @@ -0,0 +1,77 @@ +package io.github.randomcodespace.iq.api; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Reads files for the read-only serving layer with a hard byte cap. + * + *

The two entry points that surface repo content over HTTP — {@code GET /api/file} + * and the {@code read_file} MCP tool — must never load unbounded content into the JVM + * heap; a multi-GB file would OOM the serving process and become a trivial DoS vector. + * + *

Behaviour: + *

    + *
  • Without a line range, the file's on-disk size is checked first and the read + * is rejected if it exceeds the cap.
  • + *
  • With a {@code startLine}/{@code endLine} range, the file is read line-by-line + * via a {@link BufferedReader}; only lines in range are retained and the + * accumulated UTF-8 byte count is capped the same way.
  • + *
+ */ +public final class SafeFileReader { + + public static final class FileTooLargeException extends RuntimeException { + private final long size; + private final long max; + + public FileTooLargeException(long size, long max) { + super("File exceeds max size: " + size + " bytes (max " + max + " bytes)"); + this.size = size; + this.max = max; + } + + public long size() { return size; } + public long max() { return max; } + } + + private SafeFileReader() {} + + public static String read(Path path, Integer startLine, Integer endLine, long maxBytes) + throws IOException { + if (startLine == null && endLine == null) { + long size = Files.size(path); + if (size > maxBytes) { + throw new FileTooLargeException(size, maxBytes); + } + return Files.readString(path, StandardCharsets.UTF_8); + } + int start = Math.max(1, startLine != null ? startLine : 1); + int end = endLine != null ? Math.max(start, endLine) : Integer.MAX_VALUE; + StringBuilder sb = new StringBuilder(); + long accumulated = 0; + boolean first = true; + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + int idx = 0; + while ((line = reader.readLine()) != null) { + idx++; + if (idx < start) continue; + if (idx > end) break; + long lineBytes = line.getBytes(StandardCharsets.UTF_8).length; + long add = lineBytes + (first ? 0L : 1L); + if (accumulated + add > maxBytes) { + throw new FileTooLargeException(accumulated + add, maxBytes); + } + if (!first) sb.append('\n'); + sb.append(line); + accumulated += add; + first = false; + } + } + return sb.toString(); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java index 8582930a..13cfff7d 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java @@ -50,6 +50,9 @@ public class CodeIqConfig { /** Maximum lines per snippet returned in evidence packs (default 50). */ private int maxSnippetLines = 50; + /** Maximum bytes read by the serving layer's /api/file and MCP read_file (default 5 MiB). */ + private long maxFileBytes = 5L * 1024L * 1024L; + public static class Graph { private String path = ".codeiq/graph/graph.db"; @@ -152,4 +155,12 @@ public int getMaxSnippetLines() { void setMaxSnippetLines(int maxSnippetLines) { this.maxSnippetLines = Math.max(1, maxSnippetLines); } + + public long getMaxFileBytes() { + return maxFileBytes; + } + + void setMaxFileBytes(long maxFileBytes) { + this.maxFileBytes = Math.max(1L, maxFileBytes); + } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapter.java b/src/main/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapter.java index ba5812ba..a202cfba 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapter.java +++ b/src/main/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapter.java @@ -58,6 +58,9 @@ public static CodeIqConfig toCodeIqConfig(CodeIqUnifiedConfig u) { if (u.serving().readOnly() != null) { c.setReadOnly(u.serving().readOnly()); } + if (u.serving().maxFileBytes() != null) { + c.setMaxFileBytes(u.serving().maxFileBytes()); + } if (u.serving().neo4j() != null && u.serving().neo4j().dir() != null) { CodeIqConfig.Graph graph = new CodeIqConfig.Graph(); graph.setPath(u.serving().neo4j().dir()); diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java index 3efd4f70..26b4cd5e 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java @@ -30,6 +30,7 @@ public static CodeIqUnifiedConfig builtIn() { 8080, "0.0.0.0", false, + 5L * 1024L * 1024L, // maxFileBytes — 5 MiB cap on /api/file + read_file new Neo4jConfig( ".codeiq/graph/graph.db", 256, 256, 1024 diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java index 7a8f4398..82eb6832 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java @@ -66,9 +66,10 @@ private IndexingConfig mergeIndexing(IndexingConfig lo, IndexingConfig hi, Input private ServingConfig mergeServing(ServingConfig lo, ServingConfig hi, Input l, Map p) { return new ServingConfig( - take("serving.port", lo.port(), hi.port(), l, p), - take("serving.bind_address", lo.bindAddress(), hi.bindAddress(), l, p), - take("serving.read_only", lo.readOnly(), hi.readOnly(), l, p), + take("serving.port", lo.port(), hi.port(), l, p), + take("serving.bind_address", lo.bindAddress(), hi.bindAddress(), l, p), + take("serving.read_only", lo.readOnly(), hi.readOnly(), l, p), + take("serving.max_file_bytes", lo.maxFileBytes(), hi.maxFileBytes(), l, p), new Neo4jConfig( take("serving.neo4j.dir", lo.neo4j().dir(), hi.neo4j().dir(), l, p), take("serving.neo4j.page_cache_mb", lo.neo4j().pageCacheMb(), hi.neo4j().pageCacheMb(), l, p), diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java b/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java index 5b4b2dc0..fac1f9de 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java @@ -21,7 +21,7 @@ public static CodeIqUnifiedConfig from(Map env) { pageMb = null, heapInit = null, heapMax = null, maxDepth = null, maxRadius = null, maxFiles = null, maxSnippetLines = null, parallelism = null; - Long maxPayload = null; + Long maxPayload = null, servingMaxFileBytes = null; Boolean readOnly = null, incremental = null, metrics = null, tracing = null, mcpEnabled = null; String cacheDir = null, bindAddr = null, projectName = null, projectRoot = null, projectServiceName = null, @@ -56,6 +56,7 @@ public static CodeIqUnifiedConfig from(Map env) { case "SERVING_PORT" -> port = Integer.parseInt(v); case "SERVING_BINDADDRESS" -> bindAddr = v; case "SERVING_READONLY" -> readOnly = Boolean.parseBoolean(v); + case "SERVING_MAXFILEBYTES" -> servingMaxFileBytes = Long.parseLong(v); case "SERVING_NEO4J_DIR" -> neo4jDir = v; case "SERVING_NEO4J_PAGECACHEMB" -> pageMb = Integer.parseInt(v); case "SERVING_NEO4J_HEAPINITIALMB" -> heapInit = Integer.parseInt(v); @@ -90,7 +91,7 @@ public static CodeIqUnifiedConfig from(Map env) { new ProjectConfig(projectName, projectRoot, projectServiceName, List.of()), new IndexingConfig(languages, include, exclude, incremental, cacheDir, parallelism, batch, maxDepth, maxRadius, maxFiles, maxSnippetLines, parsers), - new ServingConfig(port, bindAddr, readOnly, + new ServingConfig(port, bindAddr, readOnly, servingMaxFileBytes, new Neo4jConfig(neo4jDir, pageMb, heapInit, heapMax)), new McpConfig(mcpEnabled, mcpTransport, mcpBasePath, new McpAuthConfig(mcpMode, mcpTokenEnv), diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/ServingConfig.java b/src/main/java/io/github/randomcodespace/iq/config/unified/ServingConfig.java index 8734794f..8f0f886c 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/ServingConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/ServingConfig.java @@ -1,4 +1,4 @@ package io.github.randomcodespace.iq.config.unified; -public record ServingConfig(Integer port, String bindAddress, Boolean readOnly, Neo4jConfig neo4j) { - public static ServingConfig empty() { return new ServingConfig(null, null, null, Neo4jConfig.empty()); } +public record ServingConfig(Integer port, String bindAddress, Boolean readOnly, Long maxFileBytes, Neo4jConfig neo4j) { + public static ServingConfig empty() { return new ServingConfig(null, null, null, null, Neo4jConfig.empty()); } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java b/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java index 3f1f2db5..7445379a 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java @@ -126,6 +126,8 @@ private static ServingConfig servingFrom(Map m, Path path, Set start - 1) sb.append('\n'); - sb.append(lines[i]); - } - return sb.toString(); - } - return content; + return SafeFileReader.read(resolved, startLine, endLine, config.getMaxFileBytes()); + } catch (SafeFileReader.FileTooLargeException tooLarge) { + return toJson(Map.of(PROP_ERROR, tooLarge.getMessage())); } catch (Exception e) { return toJson(Map.of(PROP_ERROR, "Failed to read file: " + e.getMessage())); } diff --git a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java index b126423d..8d1746d4 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java @@ -600,6 +600,55 @@ void readFileShouldAllowInRepoSymlink(@TempDir Path tempDir) throws Exception { .andExpect(content().string("in-repo")); } + @Test + void readFileShouldRejectWhenFileExceedsMaxBytes(@TempDir Path tempDir) throws Exception { + // 2 KiB file, cap at 1 KiB — expect 413 without line range. + byte[] payload = new byte[2048]; + java.util.Arrays.fill(payload, (byte) 'a'); + Files.write(tempDir.resolve("big.txt"), payload); + CodeIqConfigTestSupport.override(config) + .rootPath(tempDir.toAbsolutePath().toString()) + .maxFileBytes(1024L) + .done(); + var controller = new GraphController(queryService, config); + var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + fileMvc.perform(get("/api/file").param("path", "big.txt")) + .andExpect(status().is(413)) + .andExpect(content().string(containsString("exceeds max size"))); + } + + @Test + void readFileShouldServeLineRangeUnderCapEvenWhenWholeFileExceedsCap(@TempDir Path tempDir) throws Exception { + // Build a file where the whole is larger than the cap but a small line-range fits. + StringBuilder big = new StringBuilder(); + for (int i = 1; i <= 200; i++) big.append("line").append(i).append('\n'); + Files.writeString(tempDir.resolve("ranged.txt"), big.toString(), StandardCharsets.UTF_8); + long cap = 64L; // bytes — whole file is much bigger + CodeIqConfigTestSupport.override(config) + .rootPath(tempDir.toAbsolutePath().toString()) + .maxFileBytes(cap) + .done(); + var controller = new GraphController(queryService, config); + var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + // Small range fits under cap — streamed read should succeed. + fileMvc.perform(get("/api/file") + .param("path", "ranged.txt") + .param("startLine", "2") + .param("endLine", "4")) + .andExpect(status().isOk()) + .andExpect(content().string("line2\nline3\nline4")); + + // Large range would exceed cap — streamed read should 413 before buffering further. + fileMvc.perform(get("/api/file") + .param("path", "ranged.txt") + .param("startLine", "1") + .param("endLine", "200")) + .andExpect(status().is(413)) + .andExpect(content().string(containsString("exceeds max size"))); + } + // POST /api/analyze removed — API is read-only // --- /api/file-tree --- diff --git a/src/test/java/io/github/randomcodespace/iq/api/SafeFileReaderTest.java b/src/test/java/io/github/randomcodespace/iq/api/SafeFileReaderTest.java new file mode 100644 index 00000000..76c0fbba --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/api/SafeFileReaderTest.java @@ -0,0 +1,68 @@ +package io.github.randomcodespace.iq.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SafeFileReaderTest { + + @Test + void readsWholeFileUnderCap(@TempDir Path tempDir) throws IOException { + Path f = tempDir.resolve("small.txt"); + Files.writeString(f, "hello", StandardCharsets.UTF_8); + assertEquals("hello", SafeFileReader.read(f, null, null, 1024L)); + } + + @Test + void rejectsWholeFileExceedingCap(@TempDir Path tempDir) throws IOException { + Path f = tempDir.resolve("big.txt"); + byte[] payload = new byte[2048]; + java.util.Arrays.fill(payload, (byte) 'x'); + Files.write(f, payload); + SafeFileReader.FileTooLargeException e = assertThrows( + SafeFileReader.FileTooLargeException.class, + () -> SafeFileReader.read(f, null, null, 1024L)); + assertEquals(2048L, e.size()); + assertEquals(1024L, e.max()); + } + + @Test + void streamsLineRangeWithoutLoadingWholeFile(@TempDir Path tempDir) throws IOException { + Path f = tempDir.resolve("ranged.txt"); + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 100; i++) sb.append("line").append(i).append('\n'); + Files.writeString(f, sb.toString(), StandardCharsets.UTF_8); + + // Whole-file readString with cap=64 would fail, but the 3-line range fits. + assertEquals("line2\nline3\nline4", + SafeFileReader.read(f, 2, 4, 64L)); + } + + @Test + void rejectsLineRangeExceedingCap(@TempDir Path tempDir) throws IOException { + Path f = tempDir.resolve("ranged.txt"); + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 100; i++) sb.append("line").append(i).append('\n'); + Files.writeString(f, sb.toString(), StandardCharsets.UTF_8); + + SafeFileReader.FileTooLargeException e = assertThrows( + SafeFileReader.FileTooLargeException.class, + () -> SafeFileReader.read(f, 1, 100, 32L)); + assertTrue(e.max() == 32L); + } + + @Test + void clampsStartLineToOneWhenNegative(@TempDir Path tempDir) throws IOException { + Path f = tempDir.resolve("clamp.txt"); + Files.writeString(f, "a\nb\nc\n", StandardCharsets.UTF_8); + assertEquals("a\nb", SafeFileReader.read(f, -3, 2, 1024L)); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/cli/ConfigExplainSubcommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/ConfigExplainSubcommandTest.java index b0019bb9..cea208a4 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/ConfigExplainSubcommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/ConfigExplainSubcommandTest.java @@ -105,7 +105,7 @@ void cliOverlayWinsOverEnv(@TempDir Path tmp) throws Exception { // Build a CLI overlay that sets serving.port=7777 and leaves every other field null so // only the serving.port leaf is attributed to the CLI layer. CodeIqUnifiedConfig base = CodeIqUnifiedConfig.empty(); - ServingConfig cliServing = new ServingConfig(7777, null, null, base.serving().neo4j()); + ServingConfig cliServing = new ServingConfig(7777, null, null, null, base.serving().neo4j()); CodeIqUnifiedConfig overlay = new CodeIqUnifiedConfig( base.project(), diff --git a/src/test/java/io/github/randomcodespace/iq/config/CodeIqConfigTestSupport.java b/src/test/java/io/github/randomcodespace/iq/config/CodeIqConfigTestSupport.java index 4d0a7cce..676e5a0a 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/CodeIqConfigTestSupport.java +++ b/src/test/java/io/github/randomcodespace/iq/config/CodeIqConfigTestSupport.java @@ -39,6 +39,7 @@ public static CodeIqConfigTestSupport override(CodeIqConfig config) { public CodeIqConfigTestSupport serviceName(String v) { config.setServiceName(v); return this; } public CodeIqConfigTestSupport uiEnabled(boolean v){ config.setUiEnabled(v); return this; } public CodeIqConfigTestSupport maxSnippetLines(int v) { config.setMaxSnippetLines(v); return this; } + public CodeIqConfigTestSupport maxFileBytes(long v) { config.setMaxFileBytes(v); return this; } public CodeIqConfigTestSupport graph(CodeIqConfig.Graph g) { config.setGraph(g); return this; } public CodeIqConfigTestSupport graphPath(String v) { diff --git a/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java b/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java index 0047837c..1e590fa7 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java @@ -107,7 +107,7 @@ void nullNeo4jSectionDoesNotNpe() { CodeIqUnifiedConfig u = new CodeIqUnifiedConfig( ProjectConfig.empty(), IndexingConfig.empty(), - new ServingConfig(null, null, null, null), + new ServingConfig(null, null, null, null, null), new McpConfig(null, null, null, McpAuthConfig.empty(), McpLimitsConfig.empty(), @@ -150,4 +150,41 @@ void newFieldsProjectCorrectly() { assertEquals(12, legacy.getMaxSnippetLines()); assertEquals("billing", legacy.getServiceName()); } + + @Test + void maxFileBytesProjectsFromServingConfig() { + CodeIqUnifiedConfig u = new CodeIqUnifiedConfig( + ProjectConfig.empty(), + IndexingConfig.empty(), + new ServingConfig(null, null, null, 12345L, null), + new McpConfig(null, null, null, + McpAuthConfig.empty(), + McpLimitsConfig.empty(), + McpToolsConfig.empty()), + ObservabilityConfig.empty(), + DetectorsConfig.empty() + ); + + CodeIqConfig legacy = UnifiedConfigAdapter.toCodeIqConfig(u); + assertEquals(12345L, legacy.getMaxFileBytes()); + } + + @Test + void maxFileBytesFallsBackToCodeIqConfigDefault() { + CodeIqUnifiedConfig u = new CodeIqUnifiedConfig( + ProjectConfig.empty(), + IndexingConfig.empty(), + new ServingConfig(null, null, null, null, null), + new McpConfig(null, null, null, + McpAuthConfig.empty(), + McpLimitsConfig.empty(), + McpToolsConfig.empty()), + ObservabilityConfig.empty(), + DetectorsConfig.empty() + ); + + CodeIqConfig legacy = UnifiedConfigAdapter.toCodeIqConfig(u); + // Default from CodeIqConfig: 5 MiB. + assertEquals(5L * 1024L * 1024L, legacy.getMaxFileBytes()); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigResolverTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigResolverTest.java index 01473a7d..f38f45cd 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigResolverTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigResolverTest.java @@ -25,7 +25,7 @@ void layersResolveInDocumentedOrder(@TempDir Path tmp) throws Exception { // cli: read_only=true (only CLI sets it) CodeIqUnifiedConfig cli = new CodeIqUnifiedConfig( ProjectConfig.empty(), IndexingConfig.empty(), - new ServingConfig(null, null, true, Neo4jConfig.empty()), + new ServingConfig(null, null, true, null, Neo4jConfig.empty()), McpConfig.empty(), ObservabilityConfig.empty(), DetectorsConfig.empty()); MergedConfig merged = new ConfigResolver() diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java index ef35b983..0ae06d85 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java @@ -17,7 +17,7 @@ void portOutOfRangeIsRejected() { CodeIqUnifiedConfig bad = new CodeIqUnifiedConfig( ProjectConfig.empty(), IndexingConfig.empty(), - new ServingConfig(99999, "0.0.0.0", false, Neo4jConfig.empty()), + new ServingConfig(99999, "0.0.0.0", false, null, Neo4jConfig.empty()), McpConfig.empty(), ObservabilityConfig.empty(), DetectorsConfig.empty()); List errs = new ConfigValidator().validate(bad); assertEquals(1, errs.size()); diff --git a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java index c62a2cea..56f835bf 100644 --- a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java +++ b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java @@ -567,4 +567,43 @@ void readFileShouldAllowInRepoSymlink(@TempDir Path tempDir) throws IOException assertEquals("in-repo", result); } + + @Test + void readFileShouldRejectWhenFileExceedsMaxBytes(@TempDir Path tempDir) throws IOException { + CodeIqConfigTestSupport.override(config) + .rootPath(tempDir.toString()) + .maxFileBytes(1024L) + .done(); + byte[] payload = new byte[2048]; + java.util.Arrays.fill(payload, (byte) 'a'); + Files.write(tempDir.resolve("big.txt"), payload); + + String result = mcpTools.readFile("big.txt", null, null); + Map parsed = parseJson(result); + + assertNotNull(parsed.get("error"), "Expected error for oversize file"); + assertTrue(String.valueOf(parsed.get("error")).contains("exceeds max size"), + "Expected 'exceeds max size' in error, got: " + parsed.get("error")); + } + + @Test + void readFileShouldServeLineRangeUnderCapAndRejectOverCap(@TempDir Path tempDir) throws IOException { + CodeIqConfigTestSupport.override(config) + .rootPath(tempDir.toString()) + .maxFileBytes(64L) // bytes — whole file won't fit but a narrow range will + .done(); + StringBuilder big = new StringBuilder(); + for (int i = 1; i <= 200; i++) big.append("line").append(i).append('\n'); + Files.writeString(tempDir.resolve("ranged.txt"), big.toString()); + + // Under cap: narrow range streams through. + String ok = mcpTools.readFile("ranged.txt", 2, 4); + assertEquals("line2\nline3\nline4", ok); + + // Over cap: wide range is rejected mid-stream. + String over = mcpTools.readFile("ranged.txt", 1, 200); + Map parsed = parseJson(over); + assertNotNull(parsed.get("error")); + assertTrue(String.valueOf(parsed.get("error")).contains("exceeds max size")); + } }