optionchain_simulator 0.2.14

OptionChain-Simulator is a lightweight REST API service that simulates an evolving option chain with every request. It is designed for developers building or testing trading systems, backtesters, and visual tools that depend on option data streams but want to avoid relying on live data feeds.
//! Environment-driven configuration for every external service.
//!
//! One rule holds across all of it, and across every other knob this service
//! reads: **a blank value is an unset value**. See [`crate::utils::env`].

pub mod clickhouse;
/// Resolving the process log level from `LOGLEVEL`.
pub mod logging;
pub mod mongo;
pub mod redis;
/// Where the HTTP server listens: `OCS_BIND_ADDRESS` and `OCS_PORT`.
pub mod server;
/// Operational configuration for v2 rolling simulations: retention, the
/// cleanup cadence, and the domain-cache capacities.
pub mod simulation_v2;
/// Operational configuration for persisting v2 snapshots: whether it happens,
/// how large a batch may be, how long an insert may take, and how long the rows
/// are kept.
pub mod snapshot;

/// Reads an environment variable, treating a blank value as unset.
///
/// The rule and its rationale live in [`crate::utils::env`], which every layer
/// of this service reads knobs through. Re-exported here so the configuration
/// modules read it from their own namespace.
pub(crate) use crate::utils::env::{read_secret, read_var};

/// Redacts URL userinfo (credentials) from every URL-like substring inside a
/// larger text (log lines, driver error messages).
///
/// For each occurrence of `scheme://`, the candidate section runs up to the
/// next whitespace (or end of string); if it contains an `@`, everything
/// between `://` and its LAST `@` is treated as credentials and replaced with
/// `***`. Bounding by the last `@` is deliberately CONSERVATIVE: a raw
/// credential may contain `/`, `:`, or `@`, which an operator-supplied URI such
/// as `MONGODB_URI` embeds verbatim, so the scan prefers over-redacting a
/// legitimate path `@` (`scheme://host/some@path` → `scheme://***@path`) to
/// ever leaking a password fragment. `RedisConfig::url` percent-encodes its own
/// userinfo (issue #87), so for the URL this service builds the last `@` IS the
/// separator and the conservative rule is load-bearing only for the URIs it was
/// handed. Credentials containing whitespace cannot be recovered
/// from mid-text scanning; whole-string values should use [`redact_uri`].
pub(crate) fn redact_userinfo(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut i = 0;

    while i < s.len() {
        match s[i..].find("://") {
            Some(pos) => {
                // Byte index just past the "://" scheme separator.
                let sep = i + pos + 3;
                result.push_str(&s[i..sep]);

                // The candidate section runs to the next whitespace (or end).
                // NOT stopping at '/' is intentional — see the doc comment.
                let rest = &s[sep..];
                let section_end = rest
                    .find(|c: char| c.is_whitespace())
                    .map(|p| sep + p)
                    .unwrap_or_else(|| s.len());

                // Everything before the LAST '@' is treated as credentials.
                match s[sep..section_end].rfind('@') {
                    Some(at_rel) => {
                        let at = sep + at_rel;
                        result.push_str("***@");
                        i = at + 1;
                    }
                    None => {
                        i = sep;
                    }
                }
            }
            None => {
                result.push_str(&s[i..]);
                break;
            }
        }
    }

    result
}

/// Redacts the userinfo of a value that IS a single URI (not text containing
/// one), e.g. `MongoDBConfig::uri` taken verbatim from the environment.
///
/// Scans the WHOLE string after `://` for the last `@`, so credentials
/// containing `/`, `:`, `@`, or even whitespace are fully covered — anything
/// before the last `@` is replaced with `***`. URIs without an `@` are
/// returned unchanged.
pub(crate) fn redact_uri(uri: &str) -> String {
    match uri.find("://") {
        Some(pos) => {
            let sep = pos + 3;
            match uri[sep..].rfind('@') {
                Some(at_rel) => {
                    let at = sep + at_rel;
                    format!("{}***@{}", &uri[..sep], &uri[at + 1..])
                }
                None => uri.to_string(),
            }
        }
        None => uri.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_redact_userinfo_user_and_password() {
        assert_eq!(
            redact_userinfo("mongodb://admin:s3cret@localhost:27017"),
            "mongodb://***@localhost:27017"
        );
    }

    #[test]
    fn test_redact_userinfo_username_only() {
        assert_eq!(
            redact_userinfo("redis://user@localhost:6379"),
            "redis://***@localhost:6379"
        );
    }

    #[test]
    fn test_redact_userinfo_password_only() {
        assert_eq!(
            redact_userinfo("redis://:s3cret@localhost:6379"),
            "redis://***@localhost:6379"
        );
    }

    #[test]
    fn test_redact_userinfo_no_userinfo_unchanged() {
        let input = "redis://localhost:6379/3";
        assert_eq!(redact_userinfo(input), input);
    }

    #[test]
    fn test_redact_userinfo_multiple_urls() {
        assert_eq!(
            redact_userinfo("redis://u:p@h1:6379 and mongodb://a:b@h2:27017"),
            "redis://***@h1:6379 and mongodb://***@h2:27017"
        );
    }

    #[test]
    fn test_redact_userinfo_at_in_path_over_redacts_conservatively() {
        // A path '@' cannot be distinguished from an unencoded credential
        // containing '/' without parsing knowledge we don't have; the scan
        // deliberately over-redacts rather than risk leaking a password.
        assert_eq!(
            redact_userinfo("redis://localhost:6379/some@path"),
            "redis://***@path"
        );
    }

    #[test]
    fn test_redact_userinfo_slash_in_password() {
        // A raw '/' inside a credential must not defeat the redaction.
        let out = redact_userinfo("connect failed: redis://user:p/secret@localhost:6379 timeout");
        assert!(!out.contains("p/secret"));
        assert_eq!(out, "connect failed: redis://***@localhost:6379 timeout");
    }

    #[test]
    fn test_redact_uri_slash_in_password() {
        assert_eq!(
            redact_uri("mongodb://admin:p/secret@localhost:27017"),
            "mongodb://***@localhost:27017"
        );
    }

    #[test]
    fn test_redact_uri_whitespace_in_password() {
        // Whole-string scanning covers credentials containing whitespace too.
        assert_eq!(
            redact_uri("mongodb://admin:p secret@localhost:27017"),
            "mongodb://***@localhost:27017"
        );
    }

    #[test]
    fn test_redact_uri_no_userinfo_unchanged() {
        let input = "mongodb://localhost:27017";
        assert_eq!(redact_uri(input), input);
    }

    #[test]
    fn test_redact_uri_plain_text_unchanged() {
        let input = "no uri here";
        assert_eq!(redact_uri(input), input);
    }

    #[test]
    fn test_redact_userinfo_unencoded_at_in_password() {
        // An unencoded '@' inside the password must not leak a fragment: the
        // LAST '@' in the authority bounds the userinfo.
        assert_eq!(
            redact_userinfo("redis://user:p@ss@localhost:6379"),
            "redis://***@localhost:6379"
        );
    }

    #[test]
    fn test_redact_userinfo_plain_text_unchanged() {
        let input = "no url here";
        assert_eq!(redact_userinfo(input), input);
    }
}