idx-cli/crates/idx-core/src/ownership/archive.rs
hermes 60634d3aef fix: address final review (path traversal, sync races, MCP limits)
Findings from a Codex (gpt-6.1-sol, xhigh) review of v0.2.4..HEAD, each
verified in the code before fixing:

- high: ticker symbols reached cache file paths unchecked, so a symbol
  like `../../REPORT` read (and deleted, if unparseable) .json files
  outside the cache, reachable through MCP get_quotes. resolve_symbol now
  accepts only ticker characters, and cache keys are encoded as single
  safe path components.
- high: sync decided before downloading and replaced the database
  without rechecking; an import that landed meanwhile was erased. It now
  rechecks the local state before installing, and idx-mcp runs
  ownership_sync exclusively with ownership reads.
- replaces_sqlite_sha256 never downgrades history: the client requires
  the rebuild to keep at least the local release count, and the
  publisher lists a replaced build only then.
- cache writes are atomic (temp file + rename), so concurrent readers
  never see, and delete, a partial entry.
- idx-mcp caps symbols (20, deduplicated) and limits, and runs at most 4
  tool calls at once.
- each Yahoo client gets its own cookie jar, paired with its crumb.
- ZIP entries (XLSX, KSEI archives) are read with a 256 MiB bound;
  non-ASCII archive dates and huge percentages are parse errors instead
  of panics or overflow.
- agent-smoke --keep deletes every copied agent config, not only auth
  files.

CLI goldens unchanged; the MCP tool golden changes only to state the
new caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:58:37 +00:00

367 lines
11 KiB
Rust

use std::fs;
use std::io::Cursor;
use std::path::Path;
use chrono::NaiveDate;
use crate::error::IdxError;
use crate::ownership::types::{InvestorTypeCode, KseiHoldingDraft, Locality};
const EXPECTED_HEADER: &[&str] = &[
"Date",
"Code",
"Type",
"Sec. Num",
"Price",
"Local IS",
"Local CP",
"Local PF",
"Local IB",
"Local ID",
"Local MF",
"Local SC",
"Local FD",
"Local OT",
"Total",
"Foreign IS",
"Foreign CP",
"Foreign PF",
"Foreign IB",
"Foreign ID",
"Foreign MF",
"Foreign SC",
"Foreign FD",
"Foreign OT",
"Total",
];
const LOCAL_BUCKETS: &[(&str, usize)] = &[
("IS", 5),
("CP", 6),
("PF", 7),
("IB", 8),
("ID", 9),
("MF", 10),
("SC", 11),
("FD", 12),
("OT", 13),
];
const FOREIGN_BUCKETS: &[(&str, usize)] = &[
("IS", 15),
("CP", 16),
("PF", 17),
("IB", 18),
("ID", 19),
("MF", 20),
("SC", 21),
("FD", 22),
("OT", 23),
];
pub fn supports_local_archive_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.map(|value| {
let ext = value.trim().to_ascii_lowercase();
ext == "txt" || ext == "zip"
})
.unwrap_or(false)
}
pub fn parse_balancepos_file(path: &Path) -> Result<Vec<KseiHoldingDraft>, IdxError> {
let raw = match path
.extension()
.and_then(|value| value.to_str())
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("txt") => fs::read_to_string(path).map_err(|e| {
IdxError::Io(format!(
"failed to read KSEI archive TXT {}: {e}",
path.display()
))
})?,
Some("zip") => extract_txt_from_zip(path)?,
_ => {
return Err(IdxError::InvalidInput(format!(
"unsupported local ownership archive file {}; expected .txt or .zip",
path.display()
)));
}
};
parse_balancepos_text(&raw)
}
pub fn parse_balancepos_text(raw: &str) -> Result<Vec<KseiHoldingDraft>, IdxError> {
let mut lines = raw.lines().filter(|line| !line.trim().is_empty());
let header = lines
.next()
.ok_or_else(|| IdxError::ParseError("empty KSEI archive TXT input".to_string()))?;
validate_header(header)?;
let mut drafts = Vec::new();
for (line_number, line) in lines.enumerate() {
let columns: Vec<&str> = line.split('|').map(str::trim).collect();
if columns.len() != EXPECTED_HEADER.len() {
return Err(IdxError::ParseError(format!(
"invalid KSEI archive TXT row {}: expected {} columns, got {}",
line_number + 2,
EXPECTED_HEADER.len(),
columns.len()
)));
}
if !columns[2].eq_ignore_ascii_case("EQUITY") {
continue;
}
let report_date = parse_archive_date(columns[0])?;
let ticker_code = columns[1].trim().to_uppercase();
let sec_num = parse_archive_number(columns[3], line_number + 2, "Sec. Num")?;
if sec_num <= 0 {
continue;
}
append_bucket_drafts(
&mut drafts,
&ticker_code,
report_date,
sec_num,
columns.as_slice(),
Locality::Local,
LOCAL_BUCKETS,
)?;
append_bucket_drafts(
&mut drafts,
&ticker_code,
report_date,
sec_num,
columns.as_slice(),
Locality::Foreign,
FOREIGN_BUCKETS,
)?;
}
if drafts.is_empty() {
return Err(IdxError::ParseError(
"no importable EQUITY rows found in KSEI archive TXT".to_string(),
));
}
Ok(drafts)
}
fn extract_txt_from_zip(path: &Path) -> Result<String, IdxError> {
let bytes = fs::read(path).map_err(|e| {
IdxError::Io(format!(
"failed to read KSEI archive ZIP {}: {e}",
path.display()
))
})?;
let cursor = Cursor::new(bytes);
let mut zip = zip::ZipArchive::new(cursor)
.map_err(|e| IdxError::ParseError(format!("failed to open KSEI archive ZIP: {e}")))?;
for index in 0..zip.len() {
let mut file = zip.by_index(index).map_err(|e| {
IdxError::ParseError(format!("failed to read KSEI archive ZIP entry: {e}"))
})?;
if file.is_dir() {
continue;
}
let name = file.name().to_ascii_lowercase();
if !name.ends_with(".txt") {
continue;
}
return super::read_zip_entry_bounded(&mut file, "KSEI archive TXT entry");
}
Err(IdxError::ParseError(
"KSEI archive ZIP did not contain a TXT payload".to_string(),
))
}
fn validate_header(header: &str) -> Result<(), IdxError> {
let columns: Vec<&str> = header.split('|').map(str::trim).collect();
if columns != EXPECTED_HEADER {
return Err(IdxError::ParseError(
"KSEI archive TXT header did not match the expected balancepos layout".to_string(),
));
}
Ok(())
}
fn parse_archive_date(raw: &str) -> Result<NaiveDate, IdxError> {
let trimmed = raw.trim();
// Fixed byte offsets below; non-ASCII input would split a character.
if trimmed.len() != 11 || !trimmed.is_ascii() {
return Err(IdxError::ParseError(format!(
"invalid KSEI archive date `{trimmed}`"
)));
}
let canonical = format!(
"{}-{}-{}",
&trimmed[..2],
titlecase_month(&trimmed[3..6]),
&trimmed[7..11]
);
NaiveDate::parse_from_str(&canonical, "%d-%b-%Y")
.map_err(|e| IdxError::ParseError(format!("invalid KSEI archive date `{trimmed}`: {e}")))
}
fn titlecase_month(raw: &str) -> String {
let upper = raw.trim().to_ascii_uppercase();
let mut chars = upper.chars();
match chars.next() {
Some(first) => {
let mut output = String::new();
output.push(first.to_ascii_uppercase());
output.push_str(&chars.as_str().to_ascii_lowercase());
output
}
None => String::new(),
}
}
fn parse_archive_number(raw: &str, line_number: usize, field: &str) -> Result<i64, IdxError> {
raw.trim().parse::<i64>().map_err(|e| {
IdxError::ParseError(format!(
"invalid KSEI archive TXT value in row {line_number} field `{field}`: {e}"
))
})
}
fn append_bucket_drafts(
drafts: &mut Vec<KseiHoldingDraft>,
ticker_code: &str,
report_date: NaiveDate,
sec_num: i64,
columns: &[&str],
locality: Locality,
buckets: &[(&str, usize)],
) -> Result<(), IdxError> {
for (investor_type, column_index) in buckets {
let shares =
parse_archive_number(columns[*column_index], 0, investor_type).map_err(|_| {
IdxError::ParseError(format!(
"invalid KSEI archive TXT share count for {ticker_code} {investor_type}"
))
})?;
if shares <= 0 {
continue;
}
drafts.push(KseiHoldingDraft {
ticker_code: ticker_code.to_string(),
issuer_name: None,
raw_investor_name: synthetic_holder_name(locality, investor_type),
investor_type: Some(InvestorTypeCode((*investor_type).to_string())),
locality: Some(locality),
nationality: None,
domicile: None,
holdings_scripless: shares,
holdings_scrip: 0,
total_shares: shares,
percentage_bps: compute_percentage_bps(shares, sec_num),
report_date,
});
}
Ok(())
}
fn synthetic_holder_name(locality: Locality, investor_type: &str) -> String {
let prefix = match locality {
Locality::Local => "LOCAL",
Locality::Foreign => "FOREIGN",
};
format!("KSEI AGGREGATE {prefix} {investor_type}")
}
fn compute_percentage_bps(shares: i64, sec_num: i64) -> i64 {
let shares_i128 = i128::from(shares);
let sec_num_i128 = i128::from(sec_num);
let rounded = ((shares_i128 * 10_000) + (sec_num_i128 / 2)) / sec_num_i128;
i64::try_from(rounded).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::parse_balancepos_text;
use crate::ownership::entities::normalize_ksei_row;
use crate::ownership::parser::parse_stext_xml;
use crate::ownership::types::Locality;
#[test]
fn parses_balancepos_excerpt_into_bucket_holders() {
let raw = include_str!("../../tests/fixtures/ksei_balancepos_20260227_excerpt.txt");
let drafts = parse_balancepos_text(raw).expect("balancepos excerpt parses");
let local_cp = drafts
.iter()
.find(|draft| {
draft.ticker_code == "AADI"
&& draft.investor_type.as_ref().map(|code| code.0.as_str()) == Some("CP")
&& draft.locality == Some(Locality::Local)
})
.expect("AADI local CP bucket");
assert_eq!(local_cp.raw_investor_name, "KSEI AGGREGATE LOCAL CP");
assert_eq!(local_cp.total_shares, 5_035_745_466);
assert_eq!(local_cp.percentage_bps, 6467);
}
#[test]
fn balancepos_cross_check_contains_pdf_holder_bucket() {
let pdf_rows = parse_stext_xml(include_str!(
"../../tests/fixtures/ksei_above1_stext_excerpt.xml"
))
.expect("pdf fixture rows");
let pdf_drafts: Vec<_> = pdf_rows
.iter()
.map(normalize_ksei_row)
.collect::<Result<_, _>>()
.expect("normalized pdf drafts");
let archive_drafts = parse_balancepos_text(include_str!(
"../../tests/fixtures/ksei_balancepos_20260227_excerpt.txt"
))
.expect("archive drafts");
let pdf_local_cp = pdf_drafts
.iter()
.find(|draft| {
draft.ticker_code == "AADI"
&& draft.investor_type.as_ref().map(|code| code.0.as_str()) == Some("CP")
&& draft.locality == Some(Locality::Local)
})
.expect("pdf local cp");
let archive_local_cp = archive_drafts
.iter()
.find(|draft| {
draft.ticker_code == "AADI"
&& draft.investor_type.as_ref().map(|code| code.0.as_str()) == Some("CP")
&& draft.locality == Some(Locality::Local)
})
.expect("archive local cp");
assert_eq!(pdf_local_cp.report_date, archive_local_cp.report_date);
assert!(pdf_local_cp.total_shares <= archive_local_cp.total_shares);
assert!(pdf_local_cp.percentage_bps <= archive_local_cp.percentage_bps);
}
#[test]
fn non_ascii_archive_dates_are_parse_errors_not_panics() {
// 11 bytes, but byte offsets would split the 'é'.
assert!(super::parse_archive_date("1éJan-2026").is_err());
assert_eq!(
super::parse_archive_date("27-FEB-2026").unwrap(),
chrono::NaiveDate::from_ymd_opt(2026, 2, 27).unwrap()
);
}
}