moon-robots

    RFC 9309 robots.txt parser, matcher, serializer and audit toolkit for MoonBit.

    robots
    robots-txt
    rfc9309
    parser
    matcher
    audit
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    2 months ago
    Downloads
    34

    No dependencies

    #moon-robots

    • Module: liuhang0001/moon-robots
    • Version: 0.1.1
    • Status: first public release
    • Repository: https://github.com/liuhang0001/moon-robots
    • Maintainer: liuhang0001

    #Overview

    moon-robots is a strict, local-only RFC 9309 robots.txt parser, matcher, serializer and audit toolkit for MoonBit.

    It turns existing robots.txt text into a semantic model, selects the applicable User-agent group for a crawler product token, evaluates URI paths with RFC-style pattern matching, emits deterministic robots.txt text, and reports lightweight advisory audit findings.

    #Why

    Robots exclusion logic is easy to under-specify. This project keeps the MVP focused on the core interpretation path:

    robots.txt -> parse -> groups/rules -> product-token selection -> path matching -> access decision

    #RFC 9309 Scope

    Implemented:

    • User-agent, Allow, Disallow
    • comments, inline comments and blank lines
    • CR, LF and CRLF inputs
    • UTF-8 text
    • multiple matching groups merged by equal specificity
    • wildcard *
    • end marker $
    • percent-encoding normalization for unreserved octets
    • longest matching rule, with Allow winning equal-length ties
    • implicit allow for /robots.txt
    • deterministic serializer
    • lightweight audit
    • defensive resource limits

    Recognized but non-normative extension:

    • Sitemap

    Unknown records are preserved as OtherRecord; they do not alter group parsing or matching.

    #Quick Start

    let text = "User-agent: *\nDisallow: /private/\nAllow: /private/public/\n"
    match parse_robots(text) {
    Ok(file) => {
    let decision = evaluate(file, "ExampleBot", "/private/public/page").unwrap()
    println(decision.summary())
    }
    Err(e) => println(e.to_display())
    }

    #Parsing

    Use parse_robots(input) for default limits or parse_robots_with_limits(input, limits) for explicit limits.

    Every public parser returns Result[RobotsFile, RobotsError]; external input must not panic.

    #Checking Access

    Use evaluate(file, product_token, uri_path) for full decision metadata, or can_fetch(file, product_token, uri_path) for a boolean convenience API.

    #Pattern Matching

    Matching is case-sensitive for paths. Product tokens use case-insensitive exact matching, with * used only as a fallback. Within path rules, * matches zero or more characters and $ anchors the pattern to the end of the input path. Raw non-ASCII text is converted to percent-encoded UTF-8 octets; percent-encoded unreserved ASCII is decoded for comparison, while reserved octets remain encoded.

    #Serialization

    serialize_robots(file) emits deterministic LF-terminated robots.txt text using canonical field names:

    • User-agent
    • Allow
    • Disallow
    • Sitemap

    Whitespace round-tripping is intentionally out of scope.

    #Audit

    audit_robots(file) reports advisory issues such as duplicate rules, duplicate agents, empty rules, broad Disallow: /, suspicious patterns and unknown records. Audit findings do not make otherwise valid robots.txt input invalid.

    #CLI

    The portable CLI is robots-tool.

    Commands:

    • parse
    • validate
    • check
    • format
    • audit
    • stats

    Target-portable input uses --text:

    robots-tool check --agent ExampleBot --path /private/page --text "User-agent: *\nDisallow: /private/\n"

    CLI output is deterministic JSON for command results and errors.

    #Examples

    Four executable examples are included:

    • examples/parse
    • examples/check_access
    • examples/multi_bot
    • examples/audit

    #Testing

    The test suite covers parser behavior, comments, newlines, group selection, wildcard fallback, merged groups, allow/disallow decisions, longest match, equal-length ties, *, $, percent encoding, UTF-8, unknown records, serializer, audit, limits, property tests and truncation safety.

    #Security

    robots.txt is not an access-control system. Disallow does not protect private resources. This library only interprets a policy document supplied by the caller. It does not fetch URLs, crawl the web, parse HTML, resolve DNS, perform TLS, or enforce authorization.

    #Limitations

    This MVP does not implement a web crawler, HTTP client, sitemap XML parser, full RFC 3986 URL library, database, cache, scheduler, search engine, indexing layer, DNS or TLS.

    #License

    Apache-2.0.

    #Release Status

    Version 0.1.1 fixes RFC 9309 product-token selection and Unicode path normalization.

    RobotsError

    pub(all) suberror RobotsError {
    RobotsError(RobotsErrorStage, RobotsErrorKind, Int, Int, String)
    }

    RobotsError::byte_offset

    fn RobotsError::byte_offset(self : RobotsError) -> Int

    RobotsError::context

    fn RobotsError::context(self : RobotsError) -> String

    RobotsError::kind

    RobotsError::line

    fn RobotsError::line(self : RobotsError) -> Int

    RobotsError::stage

    RobotsError::to_display

    fn RobotsError::to_display(self : RobotsError) -> String

    AccessDecision

    pub struct AccessDecision {
    allowed : Bool
    product_token : String
    uri_path : String
    normalized_path : String
    matched_rule : RobotsRule?
    matched_length : Int
    matched_groups : Int
    reason : String
    } derive(Eq,
    Debug
    )

    AccessDecision::allowed

    fn AccessDecision::allowed(self : AccessDecision) -> Bool

    AccessDecision::matched_groups

    fn AccessDecision::matched_groups(self : AccessDecision) -> Int

    AccessDecision::matched_length

    fn AccessDecision::matched_length(self : AccessDecision) -> Int

    AccessDecision::matched_rule

    fn AccessDecision::matched_rule(self : AccessDecision) -> RobotsRule?

    AccessDecision::normalized_path

    fn AccessDecision::normalized_path(self : AccessDecision) -> String

    AccessDecision::product_token

    fn AccessDecision::product_token(self : AccessDecision) -> String

    AccessDecision::reason

    fn AccessDecision::reason(self : AccessDecision) -> String

    AccessDecision::summary

    fn AccessDecision::summary(self : AccessDecision) -> String

    AccessDecision::uri_path

    fn AccessDecision::uri_path(self : AccessDecision) -> String

    AuditIssue

    pub struct AuditIssue {
    severity : AuditSeverity
    kind : AuditKind
    line : Int
    message : String
    } derive(Eq,
    Debug
    )

    AuditIssue::kind

    fn AuditIssue::kind(self : AuditIssue) -> AuditKind

    AuditIssue::line

    fn AuditIssue::line(self : AuditIssue) -> Int

    AuditIssue::message

    fn AuditIssue::message(self : AuditIssue) -> String

    AuditIssue::severity

    fn AuditIssue::severity(self : AuditIssue) -> AuditSeverity

    AuditKind

    pub(all) enum AuditKind {
    EmptyUserAgent
    RuleBeforeFirstGroup
    EmptyRule
    DuplicateRule
    DuplicateUserAgent
    UnknownRecord
    VeryBroadDisallow
    UnusedGroup
    SuspiciousPattern
    } derive(Eq,
    Debug
    )

    AuditKind::label

    fn AuditKind::label(self : AuditKind) -> String

    AuditSeverity

    pub(all) enum AuditSeverity {
    Info
    Warning
    } derive(Eq,
    Debug
    )

    AuditSeverity::label

    fn AuditSeverity::label(self : AuditSeverity) -> String

    Limits

    pub struct Limits {
    max_input_bytes : Int
    max_lines : Int
    max_groups : Int
    max_user_agents_per_group : Int
    max_rules_per_group : Int
    max_pattern_bytes : Int
    max_record_bytes : Int
    } derive(Eq,
    Debug
    )

    Limits::custom

    fn Limits::custom(max_input_bytes : Int, max_lines : Int, max_groups : Int, max_user_agents_per_group : Int, max_rules_per_group : Int, max_pattern_bytes : Int, max_record_bytes : Int) -> Limits

    Limits::default

    fn Limits::default() -> Limits

    Limits::max_groups

    fn Limits::max_groups(self : Limits) -> Int

    Limits::max_input_bytes

    fn Limits::max_input_bytes(self : Limits) -> Int

    Limits::max_lines

    fn Limits::max_lines(self : Limits) -> Int

    Limits::max_pattern_bytes

    fn Limits::max_pattern_bytes(self : Limits) -> Int

    Limits::max_record_bytes

    fn Limits::max_record_bytes(self : Limits) -> Int

    Limits::max_rules_per_group

    fn Limits::max_rules_per_group(self : Limits) -> Int

    Limits::max_user_agents_per_group

    fn Limits::max_user_agents_per_group(self : Limits) -> Int

    Limits::permissive

    fn Limits::permissive() -> Limits

    Limits::strict

    fn Limits::strict() -> Limits

    OtherRecord

    pub struct OtherRecord {
    name : String
    value : String
    line : Int
    byte_offset : Int
    } derive(Eq,
    Debug
    )

    OtherRecord::byte_offset

    fn OtherRecord::byte_offset(self : OtherRecord) -> Int

    OtherRecord::canonical_name

    fn OtherRecord::canonical_name(self : OtherRecord) -> String

    OtherRecord::line

    fn OtherRecord::line(self : OtherRecord) -> Int

    OtherRecord::name

    fn OtherRecord::name(self : OtherRecord) -> String

    OtherRecord::value

    fn OtherRecord::value(self : OtherRecord) -> String

    RecordKind

    pub(all) enum RecordKind {
    UserAgentRecord
    AllowRecord
    DisallowRecord
    SitemapRecord
    OtherRecordKind
    } derive(Eq,
    Debug
    )

    RobotsErrorKind

    pub(all) enum RobotsErrorKind {
    EmptyInput
    MissingColon
    EmptyUserAgent
    InvalidUserAgent
    InvalidRecordName
    InvalidPattern
    InvalidPercentEncoding
    InvalidUtf8
    LimitExceeded
    RuleBeforeFirstGroup
    ControlCharacter
    } derive(Eq)

    RobotsErrorKind::to_string

    fn RobotsErrorKind::to_string(self : RobotsErrorKind) -> String

    RobotsErrorStage

    pub(all) enum RobotsErrorStage {
    Input
    Line
    UserAgent
    Rule
    Pattern
    Utf8
    Limit
    } derive(Eq)

    RobotsErrorStage::to_string

    fn RobotsErrorStage::to_string(self : RobotsErrorStage) -> String

    RobotsFile

    pub struct RobotsFile {
    groups : Array[RobotsGroup]
    other_records : Array[OtherRecord]
    comments : Int
    blank_lines : Int
    source_lines : Int
    } derive(Eq,
    Debug
    )

    RobotsFile::blank_lines

    fn RobotsFile::blank_lines(self : RobotsFile) -> Int

    RobotsFile::comments

    fn RobotsFile::comments(self : RobotsFile) -> Int

    RobotsFile::group_count

    fn RobotsFile::group_count(self : RobotsFile) -> Int

    RobotsFile::groups

    fn RobotsFile::groups(self : RobotsFile) -> Array[RobotsGroup]

    RobotsFile::other_record_count

    fn RobotsFile::other_record_count(self : RobotsFile) -> Int

    RobotsFile::other_records

    fn RobotsFile::other_records(self : RobotsFile) -> Array[OtherRecord]

    RobotsFile::rule_count

    fn RobotsFile::rule_count(self : RobotsFile) -> Int

    RobotsFile::source_lines

    fn RobotsFile::source_lines(self : RobotsFile) -> Int

    RobotsFile::stats

    fn RobotsFile::stats(self : RobotsFile) -> RobotsStats

    RobotsFile::user_agent_count

    fn RobotsFile::user_agent_count(self : RobotsFile) -> Int

    RobotsGroup

    pub struct RobotsGroup {
    user_agents : Array[String]
    rules : Array[RobotsRule]
    start_line : Int
    end_line : Int
    } derive(Eq,
    Debug
    )

    RobotsGroup::agent_count

    fn RobotsGroup::agent_count(self : RobotsGroup) -> Int

    RobotsGroup::end_line

    fn RobotsGroup::end_line(self : RobotsGroup) -> Int

    RobotsGroup::rule_count

    fn RobotsGroup::rule_count(self : RobotsGroup) -> Int

    RobotsGroup::rules

    fn RobotsGroup::rules(self : RobotsGroup) -> Array[RobotsRule]

    RobotsGroup::start_line

    fn RobotsGroup::start_line(self : RobotsGroup) -> Int

    RobotsGroup::user_agents

    fn RobotsGroup::user_agents(self : RobotsGroup) -> Array[String]

    RobotsRule

    pub struct RobotsRule {
    kind : RuleKind
    pattern : String
    line : Int
    byte_offset : Int
    } derive(Eq,
    Debug
    )

    RobotsRule::byte_offset

    fn RobotsRule::byte_offset(self : RobotsRule) -> Int

    RobotsRule::is_empty

    fn RobotsRule::is_empty(self : RobotsRule) -> Bool

    RobotsRule::kind

    fn RobotsRule::kind(self : RobotsRule) -> RuleKind

    RobotsRule::line

    fn RobotsRule::line(self : RobotsRule) -> Int

    RobotsRule::pattern

    fn RobotsRule::pattern(self : RobotsRule) -> String

    RobotsStats

    pub struct RobotsStats {
    groups : Int
    user_agents : Int
    rules : Int
    allows : Int
    disallows : Int
    other_records : Int
    comments : Int
    blank_lines : Int
    source_lines : Int
    } derive(Eq,
    Debug
    )

    RobotsStats::allows

    fn RobotsStats::allows(self : RobotsStats) -> Int

    RobotsStats::blank_lines

    fn RobotsStats::blank_lines(self : RobotsStats) -> Int

    RobotsStats::comments

    fn RobotsStats::comments(self : RobotsStats) -> Int

    RobotsStats::disallows

    fn RobotsStats::disallows(self : RobotsStats) -> Int

    RobotsStats::groups

    fn RobotsStats::groups(self : RobotsStats) -> Int

    RobotsStats::other_records

    fn RobotsStats::other_records(self : RobotsStats) -> Int

    RobotsStats::rules

    fn RobotsStats::rules(self : RobotsStats) -> Int

    RobotsStats::source_lines

    fn RobotsStats::source_lines(self : RobotsStats) -> Int

    RobotsStats::user_agents

    fn RobotsStats::user_agents(self : RobotsStats) -> Int

    RuleKind

    pub(all) enum RuleKind {
    Allow
    Disallow
    } derive(Eq,
    Debug
    )

    RuleKind::directive_name

    fn RuleKind::directive_name(self : RuleKind) -> String

    RuleKind::json_name

    fn RuleKind::json_name(self : RuleKind) -> String

    RuleKind::label

    fn RuleKind::label(self : RuleKind) -> String

    access_decision

    fn access_decision(allowed : Bool, product_token : String, uri_path : String, normalized_path : String, matched_rule : RobotsRule?, matched_length : Int, matched_groups : Int, reason : String) -> AccessDecision

    audit_robots

    fn audit_robots(file : RobotsFile) -> Array[AuditIssue]

    audit_to_text

    fn audit_to_text(issues : Array[AuditIssue]) -> String

    can_fetch

    fn can_fetch(file : RobotsFile, product_token : String, uri_path : String) -> Bool

    empty_robots_file

    fn empty_robots_file() -> RobotsFile

    evaluate

    fn evaluate(file : RobotsFile, product_token : String, uri_path : String) -> Result[AccessDecision, RobotsError]

    is_allowed

    fn is_allowed(file : RobotsFile, product_token : String, uri_path : String) -> Result[AccessDecision, RobotsError]

    is_sitemap

    fn is_sitemap(record : OtherRecord) -> Bool

    json_string

    fn json_string(input : String) -> String

    library_version

    fn library_version() -> String

    max_context_bytes

    fn max_context_bytes() -> Int

    normalize_path_for_match

    fn normalize_path_for_match(path : String) -> Result[String, RobotsError]

    other_record

    fn other_record(name : String, value : String, line : Int, byte_offset : Int) -> OtherRecord

    parse_robots

    fn parse_robots(input : String) -> Result[RobotsFile, RobotsError]

    parse_robots_with_limits

    fn parse_robots_with_limits(input : String, limits : Limits) -> Result[RobotsFile, RobotsError]

    pattern_matches

    fn pattern_matches(pattern : String, path : String) -> Bool

    pattern_specificity

    fn pattern_specificity(pattern : String) -> Int

    robots_error

    fn robots_error(stage : RobotsErrorStage, kind : RobotsErrorKind, line : Int, byte_offset : Int, context : String) -> RobotsError

    robots_file

    fn robots_file(groups : Array[RobotsGroup], other_records : Array[OtherRecord], comments : Int, blank_lines : Int, source_lines : Int) -> RobotsFile

    robots_group

    fn robots_group(user_agents : Array[String], rules : Array[RobotsRule], start_line : Int, end_line : Int) -> RobotsGroup

    robots_rule

    fn robots_rule(kind : RuleKind, pattern : String, line : Int, byte_offset : Int) -> RobotsRule

    rules_for

    fn rules_for(file : RobotsFile, product_token : String) -> Array[RobotsRule]

    serialize_robots

    fn serialize_robots(file : RobotsFile) -> String

    stats_to_text

    fn stats_to_text(file : RobotsFile) -> String