From ad0e2b86cc787a982c0eb059062e0944267a56f2 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:54:56 +0300 Subject: [PATCH 1/5] fix(engine): reject non-integral values in INTEGER columns The validator parsed INTEGER columns with strtod, which happily accepts "1.5", "1e-3", "NaN" and "Inf" as valid doubles. That meant an INTEGER schema column would silently pass fractional or non-finite values, defeating the point of choosing INTEGER over FLOAT. Enforce that the parsed value is finite and has no fractional part before accepting it. Co-Authored-By: Claude Opus 4.6 (1M context) --- engine/src/validator.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/engine/src/validator.cpp b/engine/src/validator.cpp index cc6dced..f6db605 100644 --- a/engine/src/validator.cpp +++ b/engine/src/validator.cpp @@ -119,6 +119,18 @@ ValidationReport validate(const ParseResult& parsed, const SchemaSpec& schema) { ", got: " + cell)); continue; } + if (col.type == ColumnType::INTEGER) { + // strtod happily accepts "1.5" / "1e-3" / NaN / Inf for + // an INTEGER column; reject any value that isn't a + // finite integral number. + double int_part; + if (!std::isfinite(v) || std::modf(v, &int_part) != 0.0) { + report.errors.push_back(make_err(line, col.name, + ErrorCode::TYPE_MISMATCH, Severity::ERROR_, + "expected integer, got: " + cell)); + continue; + } + } if (col.min_value && v < *col.min_value) { std::ostringstream m; m << "value " << v << " below min " << *col.min_value; From 499ef79ffc62f0ad6f5a120c4f8d43dfff443454 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:08 +0300 Subject: [PATCH 2/5] fix(cli): expand lowercase env vars in config interpolation ConfigLoader's ${VAR} regex only matched [A-Z0-9_], so lowercase or mixed-case env vars such as ${home} or ${my_token} were left untouched in the rendered TOML. POSIX env var names permit both cases; broaden the pattern to [A-Za-z_][A-Za-z0-9_]* so every valid name is expanded. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/main/java/com/labwatcher/config/ConfigLoader.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/src/main/java/com/labwatcher/config/ConfigLoader.java b/cli/src/main/java/com/labwatcher/config/ConfigLoader.java index 1ab3932..c1b350e 100644 --- a/cli/src/main/java/com/labwatcher/config/ConfigLoader.java +++ b/cli/src/main/java/com/labwatcher/config/ConfigLoader.java @@ -12,7 +12,11 @@ /** Loads {@link AppConfig} from a TOML file with {@code ${VAR}} interpolation. */ public final class ConfigLoader { - private static final Pattern VAR = Pattern.compile("\\$\\{([A-Z0-9_]+)}"); + // Match POSIX-style env var names: letters (either case), digits and + // underscore, not starting with a digit. The previous uppercase-only + // pattern silently skipped lowercase vars like ${home} or ${my_token}. + private static final Pattern VAR = + Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)}"); private final Function envLookup; From d96cd59b8ba775d7dd688463fcdda4c9e65f1c14 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:18 +0300 Subject: [PATCH 3/5] fix(cli): restore interrupt status when Slack dispatch is interrupted HttpClient.send can throw InterruptedException; the catch-all was swallowing it and clearing the thread's interrupt flag. During a shutdown-driven interrupt the FileProcessor executor would then fail to notice it should wind down. Catch InterruptedException explicitly and re-interrupt the current thread before returning. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/labwatcher/dispatch/SlackDispatcher.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java b/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java index f83e88a..7d7380c 100644 --- a/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java +++ b/cli/src/main/java/com/labwatcher/dispatch/SlackDispatcher.java @@ -54,6 +54,12 @@ public boolean dispatch(FileSummary summary) { } LOG.error("slack: HTTP {} body={}", resp.statusCode(), resp.body()); return false; + } catch (InterruptedException e) { + // Preserve interrupt status so the surrounding shutdown flow + // (FileProcessor's executor drain) can observe it and stop. + Thread.currentThread().interrupt(); + LOG.warn("slack: dispatch interrupted"); + return false; } catch (Exception e) { LOG.error("slack: dispatch failed: {}", e.getMessage()); return false; From e7e6292455f8d806b8460211237e21843fed9f5b Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:33 +0300 Subject: [PATCH 4/5] fix(cli): deterministic tiebreaker in SqliteStateRepository.recent Ordering solely by processed_at DESC leaves ties unresolved, so two rows sharing a timestamp (possible when clock resolution is coarse, or when tests insert with a shared Instant) may swap positions between calls. Break ties on the monotonic id column so recent() is stable and matches the ordering the dashboard uses for its id-based polling. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/labwatcher/state/SqliteStateRepository.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java b/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java index b0cd66a..e446490 100644 --- a/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java +++ b/cli/src/main/java/com/labwatcher/state/SqliteStateRepository.java @@ -112,8 +112,13 @@ public Optional findByHash(String sha256) { @Override public List recent(int limit) { + // processed_at is stored as second-precision ISO strings, so two + // files ingested in the same second would sort non-deterministically + // and could even be swapped between calls. Break ties on the + // monotonic primary key so recent() matches the dashboard's + // id-ordered `recent` query. final String sql = "SELECT * FROM processed_files " - + "ORDER BY processed_at DESC LIMIT ?"; + + "ORDER BY processed_at DESC, id DESC LIMIT ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, limit); try (ResultSet rs = ps.executeQuery()) { From f5a05814a97239387989be18b1b5546c2a5019e2 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:45 +0300 Subject: [PATCH 5/5] fix(dashboard): validate /files/:id as a positive integer Number.isFinite accepted fractional ids like 1.5 and negative values, which then flowed into better-sqlite3 where 1.5 gets coerced to 1 and negative ids always miss. Require a positive integer so the API fails loudly and consistently for malformed inputs. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/routes/api.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dashboard/src/routes/api.ts b/dashboard/src/routes/api.ts index 2e2db67..fcf5134 100644 --- a/dashboard/src/routes/api.ts +++ b/dashboard/src/routes/api.ts @@ -11,8 +11,12 @@ export function apiRouter(db: StateDb): Router { r.get("/files/:id", (req, res) => { const id = Number(req.params.id); - if (!Number.isFinite(id)) { - res.status(400).json({ error: "id must be a number" }); + // The SQLite primary key is a positive integer; reject fractional, + // negative, zero and non-numeric inputs up front instead of handing + // them to better-sqlite3 (which would coerce 1.5 -> 1 silently or + // throw for NaN). + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "id must be a positive integer" }); return; } const row = db.byId(id);