ips-le 0.2.2

Extract every IP address, CIDR block and MAC, normalized and classified by scope
//! Reading a document: what addresses are in it, where, and what they
//! are.
//!
//! Nothing here touches the filesystem or the network — that is the
//! whole point of the split, and it is a stronger claim for this crate
//! than for its siblings. A tool that reports what an address *is* must
//! never be the tool that finds out by asking: no DNS, no connect, no
//! WHOIS, not once, not behind a flag. Classification is arithmetic
//! over the bits and the registries in `policy.rs`, and it stays that
//! way because a lookup would make the answer depend on the network the
//! auditor happened to be sitting on.
//!
//! ```text
//! scanner.rs   bytes  → candidate runs        (no format knows about this)
//! <format>.rs  bytes  → key spans             (json, yaml, toml, ini, env, csv, log)
//! policy.rs    run    → address or refusal    (the decision layer)
//! locate.rs    joins the three                (position, key, context)
//! ```

pub(crate) mod corpus;
pub(crate) mod csv;
pub(crate) mod dotenv;
pub(crate) mod format;
pub(crate) mod ini;
pub(crate) mod json;
pub(crate) mod locate;
pub(crate) mod log;
pub(crate) mod policy;
pub(crate) mod position;
pub(crate) mod scanner;
pub(crate) mod toml;
pub(crate) mod yaml;

use serde::Serialize;

pub(crate) use format::{FALLBACK_FORMAT, SUPPORTED_FORMATS, resolve_format};
pub(crate) use locate::find;
pub(crate) use policy::{Block, Class, Kind, Refusal};

/// One finding: an address, or a refusal to call something an address.
///
/// **Every field is always present**, nulls included. A consumer writes
/// one reader, and "the key is absent" and "the key is null" never have
/// to mean different things.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Found {
    /// `null` where the refusal is about *which* kind it is.
    pub(crate) kind: Option<Kind>,
    /// The text as the document spells it, never rewritten.
    pub(crate) text: String,
    pub(crate) line: usize,
    pub(crate) column: usize,
    /// The path to the value holding this address, where the format has
    /// one. Best effort, and `null` rather than a guess — including for
    /// an address in a comment, whether the comment takes the whole line
    /// or trails a value. A key names where an address lives, so one
    /// attached to an address the file does not use is worse than none.
    pub(crate) key: Option<String>,
    /// The canonical form. IPv6 follows RFC 5952, which is the reason
    /// this tool is worth running: `2001:0db8::0001` and `2001:db8::1`
    /// are one address, and a dedupe over raw text says they are two.
    pub(crate) normalized: Option<String>,
    pub(crate) class: Option<Class>,
    pub(crate) cidr: Option<Block>,
    pub(crate) refused: Option<Refusal>,
}

impl Found {
    pub(crate) const fn is_refusal(&self) -> bool {
        self.refused.is_some()
    }

    /// Whether this finding survives a kind and class filter. Empty
    /// means every value of that dimension.
    ///
    /// **A refusal survives every filter it cannot be judged by.** A
    /// caller who asked for `private` is asking a question this tool
    /// declined to answer for `010.1.1.1`; dropping it would hide the
    /// one finding they most need, and hide it quietly.
    ///
    /// It lives here rather than on either surface's options because
    /// both surfaces filter — the CLI through `ScanOptions` and the
    /// `extract_ips` tool on its own arguments — and two copies of this
    /// rule would be two chances to lose a refusal on one of them.
    /// **A refusal survives every filter.** SPEC.md, the README, `--help`
    /// and both MCP tool schemas all say so in those words, and the
    /// reason is the exit code: `--strict` is computed from what
    /// survives, so a refusal a filter removed is a build that goes
    /// green over a document nobody could read. `ips-le --strict` on a
    /// file holding `10.0.0.0/33` exits 2; adding `--kind ipv4` used to
    /// make the same file exit 0.
    ///
    /// The two halves disagreed, which is how it hid: `class_ok` kept
    /// every refusal, `kind_ok` kept only those with no kind — and a
    /// refusal that named one (`octal_hazard`, `prefix_out_of_range`,
    /// `malformed_address`, `integer_form`) was dropped. The contract
    /// test written to guard this only ever passed `--class`, which is
    /// the branch that was already right.
    pub(crate) fn survives(&self, kinds: &[Kind], classes: &[Class]) -> bool {
        if self.is_refusal() {
            return true;
        }
        let kind_ok = kinds.is_empty() || self.kind.is_some_and(|kind| kinds.contains(&kind));
        let class_ok =
            classes.is_empty() || self.class.is_some_and(|class| classes.contains(&class));
        kind_ok && class_ok
    }
}

/// A region of the document that a key names, and the key.
///
/// Leaf values only, so spans never overlap and a lookup is a binary
/// search rather than a walk. A key is a locator, not a claim: nothing
/// in the report depends on it being right, which is what lets the
/// line-oriented readers be as simple as they are.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KeySpan {
    pub(crate) start: usize,
    pub(crate) end: usize,
    pub(crate) path: String,
}

/// Each line of a document with the byte offset it starts at.
///
/// `str::lines` drops the offsets and every key reader here needs them.
/// A `\r` is left on the line for the readers to trim, so that a
/// Windows-written config does not silently carry it into a key.
pub(crate) fn lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
    let mut offset = 0;
    text.split_inclusive('\n').map(move |line| {
        let start = offset;
        offset += line.len();
        (start, line.trim_end_matches(['\n', '\r']))
    })
}

/// The part of a line before its comment.
///
/// **A value region must end where the value ends.** Every line reader
/// here spans from after the separator to the end of the line, and a
/// trailing comment sitting inside that span hands its addresses the key
/// beside them — so a config binding to one address is reported as
/// binding to the one somebody wrote in a note next to it. SPEC.md
/// promises an address in a comment carries no key, and this is what
/// makes the trailing form agree with the whole-line form.
///
/// **Quote-aware**, because `bind = "10.0.0.5 # not a comment"` is a
/// value. Cutting there would take a real address's key away for a `#`
/// in a password or a URL fragment.
///
/// `after_space` is the difference between the dialects. TOML ends a
/// value at any unquoted `#`; YAML, INI and `.env` only start a comment
/// where the marker opens the line or follows whitespace, so
/// `http://host/page#anchor` and `PASSWORD=a#b` keep theirs.
pub(crate) fn strip_comment<'a>(line: &'a str, markers: &[char], after_space: bool) -> &'a str {
    let mut quote: Option<char> = None;
    // A marker at index 0 opens a comment, so the line starts as though
    // whitespace preceded it.
    let mut previous = ' ';
    for (index, character) in line.char_indices() {
        match (quote, character) {
            (Some(open), _) if character == open => quote = None,
            (None, '"' | '\'') => quote = Some(character),
            (None, _)
                if markers.contains(&character) && (!after_space || previous.is_whitespace()) =>
            {
                return &line[..index];
            }
            _ => {}
        }
        previous = character;
    }
    line
}

/// A key under a section or table header, or the bare key when there is
/// none.
///
/// Shared by the INI and TOML readers, which spell a header differently
/// and join it identically. Two copies of a four-line join is how one of
/// them ends up emitting a leading `.` for a top-level key.
pub(crate) fn dotted(prefix: &str, key: &str) -> String {
    if prefix.is_empty() {
        return key.to_string();
    }
    format!("{prefix}.{key}")
}

/// Why a document yielded no key paths, when the reason is a parse
/// failure.
///
/// Only JSON can answer this: it is the one format here read by a
/// parser rather than line by line, and a line reader has no shape it
/// can reject. A JSON document that does not parse still gets its full
/// byte scan — the addresses are reported, and the diagnostic says the
/// key paths are missing rather than that nothing was found.
pub(crate) fn parse_error(text: &str, format: &str) -> Option<String> {
    match format::canonical(format) {
        "json" => json::parse_error(text),
        _ => None,
    }
}

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

    #[test]
    fn lines_carry_the_offset_they_start_at() {
        let text = "a\nbb\nccc";
        let found: Vec<(usize, &str)> = lines(text).collect();
        assert_eq!(found, [(0, "a"), (2, "bb"), (5, "ccc")]);
        for (offset, line) in found {
            assert_eq!(&text[offset..offset + line.len()], line);
        }
    }

    #[test]
    fn a_windows_line_ending_is_not_part_of_the_line() {
        assert_eq!(
            lines("a\r\nb\r\n").collect::<Vec<_>>(),
            [(0, "a"), (3, "b")]
        );
    }

    #[test]
    fn only_json_can_fail_to_parse() {
        assert!(parse_error("{not json", "json").is_some());
        for format in ["yaml", "toml", "ini", "env", "csv", "log", "unknown"] {
            assert!(parse_error("{not json", format).is_none(), "{format}");
        }
    }
}