fix(net): bounded request time, curl size and time caps, lock waits, sane prices

From the full-repo Codex audit:
- MSN and Yahoo clients set connect and body timeouts but none for the
  response headers, so a server that stalled before answering hung the
  call; four such calls take every MCP call slot. Add a 30 s overall
  deadline. Every curl-impersonate run gets --max-time 600 and a 256 MiB
  --max-filesize, and output above that is rejected (curl cannot cap a
  response without a length); the Yahoo cookie fetch gets --max-time.
- Ownership commands blocked on the database lock without limit, so a
  long sync or import, or a stuck process, held MCP call slots
  indefinitely. Wait up to 30 s, then fail with a "busy, try again" error.
- A price of 1e20 saturated `as i64` and the change subtraction then
  overflowed. Prices that are not finite, negative, or above 1e12 IDR are
  treated as missing, and a quote's own price as an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-17 04:28:30 +00:00
commit 66207d24b6
6 changed files with 144 additions and 34 deletions

View file

@ -484,6 +484,14 @@ fn lock_path(db_path: &Path) -> PathBuf {
/// not need write access, so readers fall back to opening an existing lock
/// file read-only (e.g. one created by another user).
fn lock_at(db_path: &Path, exclusive: bool) -> Result<fs::File, IdxError> {
lock_at_within(db_path, exclusive, LOCK_WAIT)
}
fn lock_at_within(
db_path: &Path,
exclusive: bool,
wait: std::time::Duration,
) -> Result<fs::File, IdxError> {
let path = lock_path(db_path);
let opened = fs::OpenOptions::new()
.create(true)
@ -496,15 +504,38 @@ fn lock_at(db_path: &Path, exclusive: bool) -> Result<fs::File, IdxError> {
.map_err(|e| IdxError::Io(format!("failed to open ownership lock: {e}")))?,
Err(e) => return Err(IdxError::Io(format!("failed to open ownership lock: {e}"))),
};
if exclusive {
file.lock()
} else {
file.lock_shared()
// Wait a bounded time: a blocking lock behind a long sync or import (or
// a stuck process) would hold every MCP call slot indefinitely.
let deadline = std::time::Instant::now() + wait;
loop {
let attempt = if exclusive {
file.try_lock()
} else {
file.try_lock_shared()
};
match attempt {
Ok(()) => return Ok(file),
Err(fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(fs::TryLockError::WouldBlock) => {
return Err(IdxError::Io(format!(
"ownership database is busy (another idx is syncing or importing); try again in a moment ({})",
path.display()
)));
}
Err(fs::TryLockError::Error(e)) => {
return Err(IdxError::Io(format!(
"failed to lock ownership database: {e}"
)));
}
}
}
.map_err(|e| IdxError::Io(format!("failed to lock ownership database: {e}")))?;
Ok(file)
}
/// How long ownership commands wait for another process's lock.
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
/// True when the lock file is absent and cannot be created.
fn unlockable(db_path: &Path) -> bool {
let path = lock_path(db_path);
@ -780,3 +811,21 @@ mod tests {
assert!(parse_report_family("above10").is_err());
}
}
#[cfg(test)]
mod lock_tests {
use std::time::Duration;
use super::lock_at_within;
#[test]
fn a_held_lock_times_out_with_a_busy_error() {
let db = std::env::temp_dir().join(format!("idx-lock-{}.db", fastrand::u64(..)));
let _held = lock_at_within(&db, true, Duration::ZERO).unwrap();
let err = lock_at_within(&db, false, Duration::from_millis(200)).unwrap_err();
assert!(err.to_string().contains("busy"), "{err}");
let _ = std::fs::remove_file(super::lock_path(&db));
}
}