idx-cli/crates/idx-core/src/api/mod.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

394 lines
13 KiB
Rust

#[cfg(feature = "testing")]
pub mod mock;
pub mod msn;
pub mod types;
pub mod yahoo;
use chrono::FixedOffset;
use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError;
use types::{
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Period, Quote, SentimentData,
};
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
/// candles in UTC, so day boundaries must be taken in this zone.
pub(crate) const IDX_UTC_OFFSET_SECS: i32 = 7 * 3600;
pub(crate) fn idx_offset() -> FixedOffset {
FixedOffset::east_opt(IDX_UTC_OFFSET_SECS).expect("valid WIB offset")
}
/// Parse a provider's RFC 3339 trade time and express it in WIB.
pub(crate) fn parse_as_of(raw: Option<&str>) -> Option<chrono::DateTime<FixedOffset>> {
chrono::DateTime::parse_from_rfc3339(raw?)
.ok()
.map(|t| t.with_timezone(&idx_offset()))
}
pub trait QuoteProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
}
pub trait HistoryProvider {
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Bar>, IdxError>;
}
pub trait FundamentalsProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
}
/// Core provider trait — quote + fundamentals only.
/// History is a separate capability (`HistoryProvider`) not all providers support
/// (e.g. MSN Finance/Charts returns 404 for IDX/XIDX stocks).
pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider {}
impl<T> MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider {}
#[allow(dead_code)]
pub trait ProfileProvider {
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError>;
}
#[allow(dead_code)]
pub trait EarningsProvider {
fn earnings(&self, symbol: &str) -> Result<EarningsReport, IdxError>;
}
#[allow(dead_code)]
pub trait FinancialsProvider {
fn financials(&self, symbol: &str) -> Result<FinancialStatements, IdxError>;
}
#[allow(dead_code)]
pub trait SentimentProvider {
fn sentiment(&self, symbol: &str) -> Result<SentimentData, IdxError>;
}
#[allow(dead_code)]
pub trait InsightsProvider {
fn insights(&self, symbol: &str) -> Result<InsightData, IdxError>;
}
#[allow(dead_code)]
pub trait NewsProvider {
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError>;
}
#[allow(dead_code)]
pub trait ScreenerProvider {
/// `None` requests the complete candidate list for client-side ranking.
fn screener(
&self,
filter: &str,
region: &str,
limit: Option<usize>,
) -> Result<Vec<Quote>, IdxError>;
}
pub struct SelectedProvider {
kind: ProviderKind,
market: Box<dyn MarketDataProvider>,
profile: Option<Box<dyn ProfileProvider>>,
financials: Option<Box<dyn FinancialsProvider>>,
earnings: Option<Box<dyn EarningsProvider>>,
sentiment: Option<Box<dyn SentimentProvider>>,
insights: Option<Box<dyn InsightsProvider>>,
news: Option<Box<dyn NewsProvider>>,
screener: Option<Box<dyn ScreenerProvider>>,
}
impl SelectedProvider {
pub fn kind(&self) -> ProviderKind {
self.kind
}
pub fn market(&self) -> &dyn MarketDataProvider {
self.market.as_ref()
}
pub fn profile_provider(&self, subject: &str) -> Result<&dyn ProfileProvider, IdxError> {
self.profile
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
pub fn financials_provider(&self, subject: &str) -> Result<&dyn FinancialsProvider, IdxError> {
self.financials
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
pub fn earnings_provider(&self, subject: &str) -> Result<&dyn EarningsProvider, IdxError> {
self.earnings
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
pub fn sentiment_provider(&self, subject: &str) -> Result<&dyn SentimentProvider, IdxError> {
self.sentiment
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
pub fn insights_provider(&self, subject: &str) -> Result<&dyn InsightsProvider, IdxError> {
self.insights
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
pub fn news_provider(&self, subject: &str) -> Result<&dyn NewsProvider, IdxError> {
self.news
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
pub fn screener_provider(&self, subject: &str) -> Result<&dyn ScreenerProvider, IdxError> {
self.screener
.as_deref()
.ok_or_else(|| msn_capability_error(subject))
}
}
pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result<String, IdxError> {
let trimmed = symbol.trim().to_uppercase();
if trimmed.is_empty() {
return Err(IdxError::InvalidInput(
"ticker symbol cannot be empty".into(),
));
}
// Tickers name cache files and provider URLs: letters, digits, and the
// separators real symbols use (BBCA.JK, BRK-B, ^JKSE), nothing else.
if trimmed.len() > 32
|| trimmed.contains("..")
|| !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '^' | '='))
{
return Err(IdxError::InvalidInput(format!(
"invalid ticker symbol '{}'",
symbol.trim()
)));
}
if let Some((base, suffix)) = trimmed.rsplit_once('.')
&& !base.is_empty()
&& !suffix.is_empty()
{
return Ok(trimmed);
}
Ok(format!("{trimmed}.{}", exchange.trim().to_uppercase()))
}
pub fn default_provider(provider: ProviderKind, verbose: bool) -> SelectedProvider {
build_selected_provider(provider, verbose, mock_enabled())
}
fn mock_enabled() -> bool {
#[cfg(feature = "testing")]
return mock::enabled();
#[cfg(not(feature = "testing"))]
false
}
#[cfg(feature = "testing")]
fn mock_market(provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
Some(Box::new(mock::MockProvider::from_fixtures(provider)))
}
#[cfg(not(feature = "testing"))]
fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
None
}
#[cfg(feature = "testing")]
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
Some(Box::new(
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
))
}
#[cfg(not(feature = "testing"))]
fn mock_history(_provider: ProviderKind, _verbose: bool) -> Option<Box<dyn HistoryProvider>> {
None
}
fn build_selected_provider(
provider: ProviderKind,
verbose: bool,
use_mock: bool,
) -> SelectedProvider {
let mock = if use_mock {
mock_market(provider)
} else {
None
};
match provider {
ProviderKind::Yahoo => SelectedProvider {
kind: ProviderKind::Yahoo,
market: mock.unwrap_or_else(|| Box::new(yahoo::YahooProvider::new(verbose))),
profile: None,
financials: None,
earnings: None,
sentiment: None,
insights: None,
news: None,
screener: None,
},
ProviderKind::Msn => SelectedProvider {
kind: ProviderKind::Msn,
market: mock.unwrap_or_else(|| Box::new(msn::MsnProvider::new(verbose))),
profile: Some(Box::new(msn::MsnProvider::new(verbose))),
financials: Some(Box::new(msn::MsnProvider::new(verbose))),
earnings: Some(Box::new(msn::MsnProvider::new(verbose))),
sentiment: Some(Box::new(msn::MsnProvider::new(verbose))),
insights: Some(Box::new(msn::MsnProvider::new(verbose))),
news: Some(Box::new(msn::MsnProvider::new(verbose))),
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
},
}
}
fn msn_capability_error(subject: &str) -> IdxError {
IdxError::Unsupported(format!("{subject}: command requires --provider msn"))
}
/// The provider that serves history for `provider` under `history_mode`.
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
match history_mode {
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
HistoryProviderKind::Msn => ProviderKind::Msn,
HistoryProviderKind::Auto => match provider {
ProviderKind::Yahoo => ProviderKind::Yahoo,
ProviderKind::Msn => ProviderKind::Yahoo,
},
}
}
/// Resolves a history provider based on the selected market data provider and
/// history provider strategy.
///
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
pub fn history_provider(
provider: ProviderKind,
history_mode: HistoryProviderKind,
verbose: bool,
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
let resolved = history_source(provider, history_mode);
if mock_enabled()
&& let Some(mock) = mock_history(resolved, verbose)
{
return Ok((resolved, mock));
}
match resolved {
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
}
}
#[cfg(test)]
mod tests {
use super::{ProviderKind, build_selected_provider, resolve_symbol};
use crate::error::IdxError;
#[test]
fn resolves_symbol_variants() {
assert_eq!(resolve_symbol("bbca", "JK").unwrap(), "BBCA.JK");
assert_eq!(resolve_symbol("BBCA.JK", "JK").unwrap(), "BBCA.JK");
assert_eq!(resolve_symbol("TLKM.us", "JK").unwrap(), "TLKM.US");
assert_eq!(resolve_symbol("abcd.ef.gh", "JK").unwrap(), "ABCD.EF.GH");
assert_eq!(resolve_symbol(" bbri ", "jk").unwrap(), "BBRI.JK");
// Empty ticker should return error
assert!(resolve_symbol("", "JK").is_err());
// Whitespace-only ticker should also return error
assert!(resolve_symbol(" ", "JK").is_err());
// Valid ticker returns Ok
assert_eq!(resolve_symbol("BBCA", "JK").unwrap(), "BBCA.JK");
}
#[test]
fn rejects_symbols_that_are_not_tickers() {
for bad in [
"../../REPORT",
"a/b",
"a\\b",
"BB CA",
"BBCA..JK",
"BBCA\u{0}",
&"A".repeat(40),
] {
assert!(
matches!(resolve_symbol(bad, "JK"), Err(IdxError::InvalidInput(_))),
"{bad:?} accepted"
);
}
assert_eq!(resolve_symbol("^jkse", "JK").unwrap(), "^JKSE.JK");
assert_eq!(resolve_symbol("BRK-B.US", "JK").unwrap(), "BRK-B.US");
}
#[test]
fn selected_provider_exposes_expected_capabilities_for_msn() {
let provider = build_selected_provider(ProviderKind::Msn, false, false);
assert!(provider.profile_provider("BBCA.JK").is_ok());
assert!(provider.financials_provider("BBCA.JK").is_ok());
assert!(provider.earnings_provider("BBCA.JK").is_ok());
assert!(provider.sentiment_provider("BBCA.JK").is_ok());
assert!(provider.insights_provider("BBCA.JK").is_ok());
assert!(provider.news_provider("BBCA.JK").is_ok());
assert!(provider.screener_provider("screen").is_ok());
}
#[cfg(feature = "testing")]
#[test]
fn selected_provider_exposes_expected_capabilities_for_mock_msn() {
let provider = build_selected_provider(ProviderKind::Msn, false, true);
assert!(provider.profile_provider("BBCA.JK").is_ok());
assert!(provider.financials_provider("BBCA.JK").is_ok());
assert!(provider.earnings_provider("BBCA.JK").is_ok());
assert!(provider.sentiment_provider("BBCA.JK").is_ok());
assert!(provider.insights_provider("BBCA.JK").is_ok());
assert!(provider.news_provider("BBCA.JK").is_ok());
assert!(provider.screener_provider("screen").is_ok());
}
#[test]
fn selected_provider_rejects_msn_only_capabilities_for_yahoo() {
let provider = build_selected_provider(ProviderKind::Yahoo, false, false);
let err = match provider.profile_provider("BBCA.JK") {
Ok(_) => panic!("yahoo should not expose msn-only profile"),
Err(err) => err,
};
assert!(matches!(err, IdxError::Unsupported(_)));
assert_eq!(
err.to_string(),
"unsupported: BBCA.JK: command requires --provider msn"
);
}
#[cfg(feature = "testing")]
#[test]
fn selected_provider_rejects_msn_only_capabilities_for_mock_yahoo() {
let provider = build_selected_provider(ProviderKind::Yahoo, false, true);
let err = match provider.screener_provider("screen") {
Ok(_) => panic!("mock yahoo should not expose msn-only screener"),
Err(err) => err,
};
assert!(matches!(err, IdxError::Unsupported(_)));
assert_eq!(
err.to_string(),
"unsupported: screen: command requires --provider msn"
);
}
}