1#![cfg_attr(docsrs, feature(doc_cfg))]
2use std::{collections::HashMap, str};
92
93use memchr::{memchr, memmem};
94use thiserror::Error;
95
96pub const DEFAULT_MAX_BYTES: usize = 512 * 1024;
102
103#[derive(Debug, Error)]
110pub enum ParseError {
111 #[error("robots.txt is not valid UTF-8")]
113 Utf8(#[from] str::Utf8Error),
114
115 #[error("robots.txt is too large: {len} bytes exceeds limit of {max} bytes")]
117 TooLarge {
118 len: usize,
120 max: usize,
122 },
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct ParseOptions {
144 pub max_bytes: Option<usize>,
149}
150
151impl Default for ParseOptions {
152 fn default() -> Self {
153 Self {
154 max_bytes: Some(DEFAULT_MAX_BYTES),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ParseReport<'a> {
165 pub robots: RobotsTxt<'a>,
167 pub warnings: Vec<ParseWarning<'a>>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ParseWarning<'a> {
174 pub line: usize,
176 pub kind: ParseWarningKind<'a>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ParseWarningKind<'a> {
183 MissingSeparator {
185 line: &'a str,
187 },
188 EmptyDirectiveKey,
190 EmptyUserAgent,
192 RuleBeforeUserAgent {
194 key: &'a str,
196 },
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct RobotsTxt<'a> {
218 pub groups: Vec<Group<'a>>,
220 #[cfg(feature = "extensions")]
222 #[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
223 pub extensions: Extensions<'a>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct Group<'a> {
233 pub agents: Vec<&'a str>,
235 pub rules: Vec<Rule<'a>>,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct Rule<'a> {
242 pub kind: RuleKind,
244 pub pattern: &'a str,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum RuleKind {
253 Allow,
255 Disallow,
257}
258
259#[derive(Debug, Clone)]
266pub struct RobotsMatcher<'a> {
267 agent_groups: HashMap<String, Vec<usize>>,
268 fallback_groups: Vec<usize>,
269 compiled_groups: Vec<CompiledGroup<'a>>,
270}
271
272#[derive(Debug, Clone)]
273struct CompiledGroup<'a> {
274 prefix_rules: RuleTrie,
275 exact_rules: HashMap<&'a str, RuleKind>,
276 wildcard_rules: Vec<WildcardRule<'a>>,
277 wildcard_prefixes: WildcardRuleTrie,
278}
279
280#[derive(Debug, Clone)]
281struct RuleTrie {
282 nodes: Vec<RuleTrieNode>,
283}
284
285#[derive(Debug, Clone, Default)]
286struct RuleTrieNode {
287 edges: Vec<(u8, usize)>,
288 decision: Option<(usize, RuleKind)>,
289}
290
291#[derive(Debug, Clone)]
292struct WildcardRuleTrie {
293 nodes: Vec<WildcardRuleTrieNode>,
294}
295
296#[derive(Debug, Clone, Default)]
297struct WildcardRuleTrieNode {
298 edges: Vec<(u8, usize)>,
299 rule_indexes: Vec<usize>,
300}
301
302#[derive(Debug, Clone)]
303struct WildcardRule<'a> {
304 kind: RuleKind,
305 first_len: usize,
306 segments: Vec<&'a [u8]>,
307 anchored: bool,
308 ends_with_star: bool,
309 specificity: usize,
310}
311
312#[cfg(feature = "extensions")]
334#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
335#[derive(Debug, Clone, PartialEq, Eq, Default)]
336pub struct Extensions<'a> {
337 pub sitemaps: Vec<&'a str>,
339 pub crawl_delays: Vec<CrawlDelay<'a>>,
341 pub hosts: Vec<&'a str>,
343 pub clean_params: Vec<CleanParam<'a>>,
345 pub other: Vec<Directive<'a>>,
347}
348
349#[cfg(feature = "extensions")]
351#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct CrawlDelay<'a> {
354 pub agents: Vec<&'a str>,
358 pub value: &'a str,
360}
361
362#[cfg(feature = "extensions")]
364#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct CleanParam<'a> {
367 pub value: &'a str,
369}
370
371#[cfg(feature = "extensions")]
373#[cfg_attr(docsrs, doc(cfg(feature = "extensions")))]
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub struct Directive<'a> {
376 pub key: &'a str,
378 pub value: &'a str,
380}
381
382impl<'a> RobotsTxt<'a> {
383 pub fn parse(input: &'a str) -> Self {
400 parse_inner(input, false).robots
401 }
402
403 pub fn parse_bytes(input: &'a [u8]) -> Result<Self, ParseError> {
421 Self::parse_bytes_with_options(input, ParseOptions::default())
422 }
423
424 pub fn parse_bytes_with_options(
444 input: &'a [u8],
445 options: ParseOptions,
446 ) -> Result<Self, ParseError> {
447 check_size(input.len(), options)?;
448 let input = str::from_utf8(input)?;
449 Ok(Self::parse(input))
450 }
451
452 pub fn parse_with_options(input: &'a str, options: ParseOptions) -> Result<Self, ParseError> {
473 check_size(input.len(), options)?;
474 Ok(Self::parse(input))
475 }
476
477 pub fn parse_with_diagnostics(input: &'a str) -> ParseReport<'a> {
499 parse_inner(input, true)
500 }
501
502 pub fn parse_with_diagnostics_options(
521 input: &'a str,
522 options: ParseOptions,
523 ) -> Result<ParseReport<'a>, ParseError> {
524 check_size(input.len(), options)?;
525 Ok(parse_inner(input, true))
526 }
527
528 pub fn parse_bytes_with_diagnostics(input: &'a [u8]) -> Result<ParseReport<'a>, ParseError> {
548 Self::parse_bytes_with_diagnostics_options(input, ParseOptions::default())
549 }
550
551 pub fn parse_bytes_with_diagnostics_options(
569 input: &'a [u8],
570 options: ParseOptions,
571 ) -> Result<ParseReport<'a>, ParseError> {
572 check_size(input.len(), options)?;
573 let input = str::from_utf8(input)?;
574 Ok(parse_inner(input, true))
575 }
576
577 pub fn matcher(&'a self) -> RobotsMatcher<'a> {
596 RobotsMatcher::new(self)
597 }
598
599 pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
624 if path == "/robots.txt" {
625 return true;
626 }
627
628 let mut exact_match = false;
629 let mut best: Option<(usize, RuleKind)> = None;
630
631 for group in &self.groups {
632 if group
633 .agents
634 .iter()
635 .any(|agent| *agent != "*" && agent.eq_ignore_ascii_case(user_agent))
636 {
637 exact_match = true;
638 apply_group_rules(group, path, &mut best);
639 }
640 }
641
642 if !exact_match {
643 for group in &self.groups {
644 if group.agents.contains(&"*") {
645 apply_group_rules(group, path, &mut best);
646 }
647 }
648 }
649
650 rule_decision(best)
651 }
652}
653
654impl<'a> RobotsMatcher<'a> {
655 fn new(robots: &'a RobotsTxt<'a>) -> Self {
656 let groups = robots.groups.as_slice();
657 let mut agent_groups: HashMap<String, Vec<usize>> = HashMap::new();
658 let mut fallback_groups = vec![];
659 let mut compiled_groups = Vec::with_capacity(groups.len());
660
661 for (group_index, group) in groups.iter().enumerate() {
662 for agent in &group.agents {
663 if *agent == "*" {
664 fallback_groups.push(group_index);
665 } else {
666 let indexes = agent_groups.entry(agent.to_ascii_lowercase()).or_default();
667 if !indexes.contains(&group_index) {
668 indexes.push(group_index);
669 }
670 }
671 }
672
673 compiled_groups.push(CompiledGroup::new(group));
674 }
675
676 Self {
677 agent_groups,
678 fallback_groups,
679 compiled_groups,
680 }
681 }
682
683 pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
689 if path == "/robots.txt" {
690 return true;
691 }
692
693 let mut best: Option<(usize, RuleKind)> = None;
694 let agent = user_agent.to_ascii_lowercase();
695
696 if let Some(group_indexes) = self.agent_groups.get(&agent) {
697 self.apply_group_indexes(group_indexes, path, &mut best);
698 } else {
699 self.apply_group_indexes(&self.fallback_groups, path, &mut best);
700 }
701
702 rule_decision(best)
703 }
704
705 fn apply_group_indexes(
706 &self,
707 group_indexes: &[usize],
708 path: &str,
709 best: &mut Option<(usize, RuleKind)>,
710 ) {
711 for &group_index in group_indexes {
712 self.compiled_groups[group_index].apply_rules(path, best);
713 }
714 }
715}
716
717impl<'a> CompiledGroup<'a> {
718 fn new(group: &Group<'a>) -> Self {
719 let mut compiled = Self {
720 prefix_rules: RuleTrie::new(),
721 exact_rules: HashMap::new(),
722 wildcard_rules: vec![],
723 wildcard_prefixes: WildcardRuleTrie::new(),
724 };
725
726 for rule in &group.rules {
727 compiled.push_rule(rule);
728 }
729
730 compiled
731 }
732
733 fn push_rule(&mut self, rule: &Rule<'a>) {
734 if rule.pattern.is_empty() {
735 return;
736 }
737
738 let (pattern, anchored) = strip_end_anchor(rule.pattern);
739 let pattern_bytes = pattern.as_bytes();
740
741 if let Some(first_wildcard) = memchr(b'*', pattern_bytes) {
742 let rule_index = self.wildcard_rules.len();
743 self.wildcard_prefixes
744 .insert(&pattern_bytes[..first_wildcard], rule_index);
745 self.wildcard_rules.push(WildcardRule::new(
746 rule.kind,
747 pattern,
748 anchored,
749 first_wildcard,
750 ));
751 } else if anchored {
752 let decision = self.exact_rules.entry(pattern).or_insert(rule.kind);
753 if rule.kind == RuleKind::Allow {
754 *decision = RuleKind::Allow;
755 }
756 } else {
757 self.prefix_rules
758 .insert(pattern_bytes, pattern.len(), rule.kind);
759 }
760 }
761
762 fn apply_rules(&self, path: &str, best: &mut Option<(usize, RuleKind)>) {
763 let path_bytes = path.as_bytes();
764
765 self.prefix_rules.apply_matches(path_bytes, best);
766
767 if let Some(&kind) = self.exact_rules.get(path) {
768 apply_rule_decision(path.len(), kind, best);
769 }
770
771 self.wildcard_prefixes
772 .apply_matches(path_bytes, &self.wildcard_rules, best);
773 }
774}
775
776impl RuleTrie {
777 fn new() -> Self {
778 Self {
779 nodes: vec![RuleTrieNode::default()],
780 }
781 }
782
783 fn insert(&mut self, pattern: &[u8], specificity: usize, kind: RuleKind) {
784 let mut node_index = 0;
785 for &byte in pattern {
786 node_index = self.child_or_insert(node_index, byte);
787 }
788
789 apply_rule_decision(specificity, kind, &mut self.nodes[node_index].decision);
790 }
791
792 fn apply_matches(&self, path: &[u8], best: &mut Option<(usize, RuleKind)>) {
793 let mut node_index = 0;
794
795 for &byte in path {
796 let Some(next_index) = self.child(node_index, byte) else {
797 return;
798 };
799 node_index = next_index;
800
801 if let Some((specificity, kind)) = self.nodes[node_index].decision {
802 apply_rule_decision(specificity, kind, best);
803 }
804 }
805 }
806
807 fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
808 self.nodes[node_index]
809 .edges
810 .iter()
811 .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
812 }
813
814 fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
815 if let Some(child_index) = self.child(node_index, byte) {
816 return child_index;
817 }
818
819 let child_index = self.nodes.len();
820 self.nodes.push(RuleTrieNode::default());
821 self.nodes[node_index].edges.push((byte, child_index));
822 child_index
823 }
824}
825
826impl WildcardRuleTrie {
827 fn new() -> Self {
828 Self {
829 nodes: vec![WildcardRuleTrieNode::default()],
830 }
831 }
832
833 fn insert(&mut self, literal_prefix: &[u8], rule_index: usize) {
834 let mut node_index = 0;
835 for &byte in literal_prefix {
836 node_index = self.child_or_insert(node_index, byte);
837 }
838
839 self.nodes[node_index].rule_indexes.push(rule_index);
840 }
841
842 fn apply_matches(
843 &self,
844 path: &[u8],
845 rules: &[WildcardRule<'_>],
846 best: &mut Option<(usize, RuleKind)>,
847 ) {
848 let mut node_index = 0;
849 self.apply_node_rules(node_index, path, rules, best);
850
851 for &byte in path {
852 let Some(next_index) = self.child(node_index, byte) else {
853 return;
854 };
855 node_index = next_index;
856 self.apply_node_rules(node_index, path, rules, best);
857 }
858 }
859
860 fn apply_node_rules(
861 &self,
862 node_index: usize,
863 path: &[u8],
864 rules: &[WildcardRule<'_>],
865 best: &mut Option<(usize, RuleKind)>,
866 ) {
867 for &rule_index in &self.nodes[node_index].rule_indexes {
868 let rule = &rules[rule_index];
869 let Some(specificity) = rule.matching_specificity(path) else {
870 continue;
871 };
872
873 apply_rule_decision(specificity, rule.kind, best);
874 }
875 }
876
877 fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
878 self.nodes[node_index]
879 .edges
880 .iter()
881 .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
882 }
883
884 fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
885 if let Some(child_index) = self.child(node_index, byte) {
886 return child_index;
887 }
888
889 let child_index = self.nodes.len();
890 self.nodes.push(WildcardRuleTrieNode::default());
891 self.nodes[node_index].edges.push((byte, child_index));
892 child_index
893 }
894}
895
896impl<'a> WildcardRule<'a> {
897 fn new(kind: RuleKind, pattern: &'a str, anchored: bool, first_wildcard: usize) -> Self {
898 let pattern_bytes = pattern.as_bytes();
899 let segments = pattern_bytes[first_wildcard + 1..]
900 .split(|byte| *byte == b'*')
901 .filter(|segment| !segment.is_empty())
902 .collect();
903
904 Self {
905 kind,
906 first_len: first_wildcard,
907 segments,
908 anchored,
909 ends_with_star: pattern_bytes.last() == Some(&b'*'),
910 specificity: pattern.len(),
911 }
912 }
913
914 fn matching_specificity(&self, path: &[u8]) -> Option<usize> {
915 let mut offset = self.first_len;
916
917 for segment in &self.segments {
918 let found = memmem::find(&path[offset..], segment)?;
919 offset += found + segment.len();
920 }
921
922 (!self.anchored || self.ends_with_star || offset == path.len()).then_some(self.specificity)
923 }
924}
925
926fn check_size(len: usize, options: ParseOptions) -> Result<(), ParseError> {
928 if let Some(max) = options.max_bytes {
929 if len > max {
930 return Err(ParseError::TooLarge { len, max });
931 }
932 }
933
934 Ok(())
935}
936
937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938enum DirectiveKind {
939 UserAgent,
940 Allow,
941 Disallow,
942 #[cfg(feature = "extensions")]
943 Sitemap,
944 #[cfg(feature = "extensions")]
945 CrawlDelay,
946 #[cfg(feature = "extensions")]
947 Host,
948 #[cfg(feature = "extensions")]
949 CleanParam,
950 Other,
951}
952
953fn classify_directive_key(key: &str) -> DirectiveKind {
954 match key.as_bytes() {
955 b"Allow" | b"allow" => return DirectiveKind::Allow,
956 b"Disallow" | b"disallow" => return DirectiveKind::Disallow,
957 b"User-agent" | b"user-agent" => return DirectiveKind::UserAgent,
958 #[cfg(feature = "extensions")]
959 b"Host" | b"host" => return DirectiveKind::Host,
960 #[cfg(feature = "extensions")]
961 b"Sitemap" | b"sitemap" => return DirectiveKind::Sitemap,
962 #[cfg(feature = "extensions")]
963 b"Crawl-delay" | b"crawl-delay" => return DirectiveKind::CrawlDelay,
964 #[cfg(feature = "extensions")]
965 b"Clean-param" | b"clean-param" => return DirectiveKind::CleanParam,
966 _ => {}
967 }
968
969 classify_directive_key_ignore_case(key)
970}
971
972#[cold]
973#[inline(never)]
974fn classify_directive_key_ignore_case(key: &str) -> DirectiveKind {
975 match key.len() {
976 5 if key.eq_ignore_ascii_case("allow") => DirectiveKind::Allow,
977 8 if key.eq_ignore_ascii_case("disallow") => DirectiveKind::Disallow,
978 10 if key.eq_ignore_ascii_case("user-agent") => DirectiveKind::UserAgent,
979 #[cfg(feature = "extensions")]
980 4 if key.eq_ignore_ascii_case("host") => DirectiveKind::Host,
981 #[cfg(feature = "extensions")]
982 7 if key.eq_ignore_ascii_case("sitemap") => DirectiveKind::Sitemap,
983 #[cfg(feature = "extensions")]
984 11 if key.eq_ignore_ascii_case("crawl-delay") => DirectiveKind::CrawlDelay,
985 #[cfg(feature = "extensions")]
986 11 if key.eq_ignore_ascii_case("clean-param") => DirectiveKind::CleanParam,
987 _ => DirectiveKind::Other,
988 }
989}
990
991fn new_group<'a>(agent: &'a str) -> Group<'a> {
992 Group {
993 agents: vec![agent],
994 rules: Vec::with_capacity(4),
995 }
996}
997
998fn parse_inner<'a>(input: &'a str, diagnostics: bool) -> ParseReport<'a> {
1004 let mut groups = vec![];
1005 let mut current: Option<Group<'a>> = None;
1006 let mut current_has_rules = false;
1007 let mut warnings = vec![];
1008
1009 #[cfg(feature = "extensions")]
1010 let mut extensions = Extensions::default();
1011
1012 for (line_number, line) in Lines::new(input) {
1013 let line = trim_ascii(strip_comment(line));
1014 if line.is_empty() {
1015 continue;
1016 }
1017
1018 let Some((key, value)) = split_directive(line) else {
1019 if diagnostics {
1020 warnings.push(ParseWarning {
1021 line: line_number,
1022 kind: ParseWarningKind::MissingSeparator { line },
1023 });
1024 }
1025 continue;
1026 };
1027
1028 let key = trim_ascii(key);
1029 let value = trim_ascii(value);
1030 if key.is_empty() {
1031 if diagnostics {
1032 warnings.push(ParseWarning {
1033 line: line_number,
1034 kind: ParseWarningKind::EmptyDirectiveKey,
1035 });
1036 }
1037 continue;
1038 }
1039
1040 let directive = classify_directive_key(key);
1041
1042 match directive {
1043 DirectiveKind::UserAgent => {
1044 if value.is_empty() {
1045 if diagnostics {
1046 warnings.push(ParseWarning {
1047 line: line_number,
1048 kind: ParseWarningKind::EmptyUserAgent,
1049 });
1050 }
1051 continue;
1052 };
1053
1054 match current.as_mut() {
1055 Some(group) if !current_has_rules => group.agents.push(value),
1056 Some(_) => {
1057 groups.push(current.take().expect("current group exists"));
1058 current = Some(new_group(value));
1059 current_has_rules = false;
1060 }
1061 None => {
1062 current = Some(new_group(value));
1063 }
1064 }
1065 }
1066 DirectiveKind::Allow | DirectiveKind::Disallow => {
1067 let Some(group) = current.as_mut() else {
1068 if diagnostics {
1069 warnings.push(ParseWarning {
1070 line: line_number,
1071 kind: ParseWarningKind::RuleBeforeUserAgent { key },
1072 });
1073 }
1074 continue;
1075 };
1076
1077 let kind = match directive {
1078 DirectiveKind::Allow => RuleKind::Allow,
1079 DirectiveKind::Disallow => RuleKind::Disallow,
1080 _ => unreachable!("only allow/disallow directives reach this branch"),
1081 };
1082
1083 group.rules.push(Rule {
1084 kind,
1085 pattern: value,
1086 });
1087 current_has_rules = true;
1088 }
1089 _ => {
1090 #[cfg(feature = "extensions")]
1091 collect_extension(&mut extensions, current.as_ref(), directive, key, value);
1092 }
1093 }
1094 }
1095
1096 if let Some(group) = current {
1097 groups.push(group);
1098 }
1099
1100 ParseReport {
1101 robots: RobotsTxt {
1102 groups,
1103 #[cfg(feature = "extensions")]
1104 extensions,
1105 },
1106 warnings,
1107 }
1108}
1109
1110fn apply_group_rules(group: &Group<'_>, path: &str, best: &mut Option<(usize, RuleKind)>) {
1116 for rule in &group.rules {
1117 let Some(specificity) = matching_specificity(rule.pattern, path) else {
1118 continue;
1119 };
1120
1121 apply_rule_decision(specificity, rule.kind, best);
1122 }
1123}
1124
1125fn apply_rule_decision(specificity: usize, kind: RuleKind, best: &mut Option<(usize, RuleKind)>) {
1126 let should_replace = !matches!(
1127 *best,
1128 Some((best_specificity, best_kind))
1129 if specificity < best_specificity
1130 || (specificity == best_specificity
1131 && !(kind == RuleKind::Allow && best_kind == RuleKind::Disallow))
1132 );
1133
1134 if should_replace {
1135 *best = Some((specificity, kind));
1136 }
1137}
1138
1139fn rule_decision(best: Option<(usize, RuleKind)>) -> bool {
1140 match best {
1141 Some((_, RuleKind::Allow)) | None => true,
1142 Some((_, RuleKind::Disallow)) => false,
1143 }
1144}
1145
1146fn matching_specificity(pattern: &str, path: &str) -> Option<usize> {
1152 if pattern.is_empty() {
1153 return None;
1154 }
1155
1156 let (pattern, anchored) = strip_end_anchor(pattern);
1157 let matched = if pattern.as_bytes().contains(&b'*') {
1158 glob_matches(pattern.as_bytes(), path.as_bytes(), anchored)
1159 } else if anchored {
1160 path == pattern
1161 } else {
1162 path.starts_with(pattern)
1163 };
1164
1165 matched.then_some(pattern.len())
1166}
1167
1168fn glob_matches(pattern: &[u8], path: &[u8], anchored: bool) -> bool {
1173 let mut parts = pattern.split(|byte| *byte == b'*');
1174 let Some(first) = parts.next() else {
1175 return true;
1176 };
1177
1178 if !path.starts_with(first) {
1179 return false;
1180 }
1181
1182 let mut offset = first.len();
1183 let mut ends_with_star = pattern.last() == Some(&b'*');
1184
1185 for part in parts {
1186 if part.is_empty() {
1187 ends_with_star = true;
1188 continue;
1189 }
1190
1191 ends_with_star = false;
1192 let Some(found) = memmem::find(&path[offset..], part) else {
1193 return false;
1194 };
1195 offset += found + part.len();
1196 }
1197
1198 !anchored || ends_with_star || offset == path.len()
1199}
1200
1201fn strip_end_anchor(pattern: &str) -> (&str, bool) {
1202 match pattern.strip_suffix('$') {
1203 Some(pattern) => (pattern, true),
1204 None => (pattern, false),
1205 }
1206}
1207
1208#[cfg(feature = "extensions")]
1209fn collect_extension<'a>(
1215 extensions: &mut Extensions<'a>,
1216 current: Option<&Group<'a>>,
1217 directive: DirectiveKind,
1218 key: &'a str,
1219 value: &'a str,
1220) {
1221 match directive {
1222 DirectiveKind::Sitemap => {
1223 if !value.is_empty() {
1224 extensions.sitemaps.push(value);
1225 }
1226 }
1227 DirectiveKind::CrawlDelay => {
1228 extensions.crawl_delays.push(CrawlDelay {
1229 agents: current
1230 .map(|group| group.agents.clone())
1231 .unwrap_or_default(),
1232 value,
1233 });
1234 }
1235 DirectiveKind::Host => {
1236 if !value.is_empty() {
1237 extensions.hosts.push(value);
1238 }
1239 }
1240 DirectiveKind::CleanParam => {
1241 if !value.is_empty() {
1242 extensions.clean_params.push(CleanParam { value });
1243 }
1244 }
1245 _ => {
1246 extensions.other.push(Directive { key, value });
1247 }
1248 }
1249}
1250
1251fn strip_comment(line: &str) -> &str {
1253 match memchr(b'#', line.as_bytes()) {
1254 Some(index) => &line[..index],
1255 None => line,
1256 }
1257}
1258
1259fn split_directive(line: &str) -> Option<(&str, &str)> {
1263 let index = memchr(b':', line.as_bytes())?;
1264 Some((&line[..index], &line[index + 1..]))
1265}
1266
1267fn trim_ascii(value: &str) -> &str {
1272 let bytes = value.as_bytes();
1273 let Some((&first, rest)) = bytes.split_first() else {
1274 return value;
1275 };
1276 let last = rest.last().copied().unwrap_or(first);
1277
1278 if !matches!(first, b' ' | b'\t') && !matches!(last, b' ' | b'\t') {
1279 return value;
1280 }
1281
1282 let mut start = 0;
1283 let mut end = bytes.len();
1284
1285 while start < end && matches!(bytes[start], b' ' | b'\t') {
1286 start += 1;
1287 }
1288 while end > start && matches!(bytes[end - 1], b' ' | b'\t') {
1289 end -= 1;
1290 }
1291
1292 &value[start..end]
1293}
1294
1295struct Lines<'a> {
1300 input: &'a str,
1301 offset: usize,
1302 line: usize,
1303}
1304
1305impl<'a> Lines<'a> {
1306 fn new(input: &'a str) -> Self {
1308 Self {
1309 input,
1310 offset: 0,
1311 line: 1,
1312 }
1313 }
1314}
1315
1316impl<'a> Iterator for Lines<'a> {
1317 type Item = (usize, &'a str);
1318
1319 fn next(&mut self) -> Option<Self::Item> {
1321 if self.offset > self.input.len() {
1322 return None;
1323 }
1324
1325 let remaining = &self.input[self.offset..];
1326 if remaining.is_empty() {
1327 self.offset += 1;
1328 return None;
1329 }
1330
1331 let line_end = memchr(b'\n', remaining.as_bytes()).unwrap_or(remaining.len());
1332 let mut line = &remaining[..line_end];
1333 if let Some(stripped) = line.strip_suffix('\r') {
1334 line = stripped;
1335 }
1336
1337 let line_number = self.line;
1338 self.line += 1;
1339 self.offset += line_end + 1;
1340 Some((line_number, line))
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347
1348 #[test]
1349 fn parses_groups_comments_and_crlf() {
1350 let robots = RobotsTxt::parse(
1351 "# ignored\r\nUser-agent: FooBot\r\nUser-agent: BarBot # same group\r\nDisallow: /private\r\nAllow: /private/public\r\n",
1352 );
1353
1354 assert_eq!(robots.groups.len(), 1);
1355 assert_eq!(robots.groups[0].agents, vec!["FooBot", "BarBot"]);
1356 assert_eq!(robots.groups[0].rules.len(), 2);
1357 assert!(!robots.is_allowed("FooBot", "/private/file"));
1358 assert!(robots.is_allowed("FooBot", "/private/public/file"));
1359 }
1360
1361 #[test]
1362 fn parses_directive_keys_case_insensitively() {
1363 let robots =
1364 RobotsTxt::parse("uSeR-aGeNt: FooBot\nDiSaLlOw: /private\nAlLoW: /private/public\n");
1365
1366 assert!(!robots.is_allowed("FooBot", "/private/file"));
1367 assert!(robots.is_allowed("FooBot", "/private/public/file"));
1368 }
1369
1370 #[test]
1371 fn ignores_rules_before_first_user_agent() {
1372 let robots = RobotsTxt::parse("Disallow: /\nUser-agent: *\nAllow: /\n");
1373
1374 assert!(robots.is_allowed("AnyBot", "/anything"));
1375 }
1376
1377 #[test]
1378 fn starts_new_group_after_rules() {
1379 let robots = RobotsTxt::parse(
1380 "User-agent: FooBot\nDisallow: /foo\nUser-agent: BarBot\nDisallow: /bar\n",
1381 );
1382
1383 assert_eq!(robots.groups.len(), 2);
1384 assert!(!robots.is_allowed("FooBot", "/foo"));
1385 assert!(robots.is_allowed("FooBot", "/bar"));
1386 assert!(!robots.is_allowed("BarBot", "/bar"));
1387 }
1388
1389 #[test]
1390 fn merges_multiple_exact_matching_groups() {
1391 let robots = RobotsTxt::parse(
1392 "User-agent: FooBot\nDisallow: /foo\n\nUser-agent: FooBot\nDisallow: /bar\n",
1393 );
1394
1395 assert!(!robots.is_allowed("FooBot", "/foo"));
1396 assert!(!robots.is_allowed("FooBot", "/bar"));
1397 }
1398
1399 #[test]
1400 fn falls_back_to_star_group() {
1401 let robots =
1402 RobotsTxt::parse("User-agent: *\nDisallow: /all\nUser-agent: FooBot\nAllow: /\n");
1403
1404 assert!(!robots.is_allowed("OtherBot", "/all"));
1405 assert!(robots.is_allowed("FooBot", "/all"));
1406 }
1407
1408 #[test]
1409 fn longest_match_wins_and_allow_wins_ties() {
1410 let robots = RobotsTxt::parse(
1411 "User-agent: *\nDisallow: /example/\nAllow: /example/public\nDisallow: /tie\nAllow: /tie\n",
1412 );
1413
1414 assert!(!robots.is_allowed("AnyBot", "/example/private"));
1415 assert!(robots.is_allowed("AnyBot", "/example/public/page"));
1416 assert!(robots.is_allowed("AnyBot", "/tie"));
1417 }
1418
1419 #[test]
1420 fn supports_wildcard_and_end_anchor() {
1421 let robots = RobotsTxt::parse("User-agent: *\nDisallow: /*.gif$\nAllow: /public/*.gif$\n");
1422
1423 assert!(!robots.is_allowed("AnyBot", "/images/a.gif"));
1424 assert!(robots.is_allowed("AnyBot", "/images/a.gif?size=large"));
1425 assert!(robots.is_allowed("AnyBot", "/public/a.gif"));
1426 }
1427
1428 #[test]
1429 fn empty_disallow_does_not_block() {
1430 let robots = RobotsTxt::parse("User-agent: *\nDisallow:\n");
1431
1432 assert!(robots.is_allowed("AnyBot", "/anything"));
1433 }
1434
1435 #[test]
1436 fn robots_txt_is_implicitly_allowed() {
1437 let robots = RobotsTxt::parse("User-agent: *\nDisallow: /\n");
1438
1439 assert!(robots.is_allowed("AnyBot", "/robots.txt"));
1440 }
1441
1442 #[test]
1443 fn compiled_matcher_matches_regular_matcher_for_core_rules() {
1444 let robots = RobotsTxt::parse(
1445 "User-agent: FooBot\n\
1446 Disallow: /foo\n\
1447 \n\
1448 User-agent: FooBot\n\
1449 Disallow: /bar\n\
1450 Allow: /bar/public\n\
1451 Disallow: /tie\n\
1452 Allow: /tie\n\
1453 \n\
1454 User-agent: ImageBot\n\
1455 Disallow: /*.gif$\n\
1456 Allow: /public/*.gif$\n\
1457 \n\
1458 User-agent: *\n\
1459 Disallow: /fallback\n",
1460 );
1461 let matcher = robots.matcher();
1462
1463 for (agent, path) in [
1464 ("FooBot", "/foo/page"),
1465 ("FooBot", "/bar/page"),
1466 ("FooBot", "/bar/public/page"),
1467 ("FooBot", "/tie"),
1468 ("ImageBot", "/images/a.gif"),
1469 ("ImageBot", "/images/a.gif?size=large"),
1470 ("ImageBot", "/public/a.gif"),
1471 ("OtherBot", "/fallback/page"),
1472 ("OtherBot", "/public/page"),
1473 ("OtherBot", "/robots.txt"),
1474 ] {
1475 assert_eq!(
1476 matcher.is_allowed(agent, path),
1477 robots.is_allowed(agent, path),
1478 "compiled matcher differed for {agent} {path}"
1479 );
1480 }
1481 }
1482
1483 #[test]
1484 fn compiled_matcher_matches_specialized_rule_indexes() {
1485 let robots = RobotsTxt::parse(
1486 "User-agent: *\n\
1487 Disallow: /exact$\n\
1488 Allow: /exact/public\n\
1489 Disallow: *.secret$\n\
1490 Allow: /download/*/public/*.secret$\n\
1491 Disallow: /download/private/*\n",
1492 );
1493 let matcher = robots.matcher();
1494
1495 for path in [
1496 "/exact",
1497 "/exactly",
1498 "/exact/public/file",
1499 "/a.secret",
1500 "/a.secret?x=1",
1501 "/download/foo/public/file.secret",
1502 "/download/private/file",
1503 ] {
1504 assert_eq!(
1505 matcher.is_allowed("AnyBot", path),
1506 robots.is_allowed("AnyBot", path),
1507 "compiled matcher differed for {path}"
1508 );
1509 }
1510 }
1511
1512 #[test]
1513 fn parse_bytes_rejects_invalid_utf8() {
1514 let error = RobotsTxt::parse_bytes(&[0xff]).expect_err("invalid UTF-8 should fail");
1515
1516 assert!(matches!(error, ParseError::Utf8(_)));
1517 }
1518
1519 #[test]
1520 fn parse_with_options_rejects_oversized_input() {
1521 let error =
1522 RobotsTxt::parse_with_options("User-agent: *\n", ParseOptions { max_bytes: Some(4) })
1523 .expect_err("oversized input should fail");
1524
1525 assert!(matches!(error, ParseError::TooLarge { len: 14, max: 4 }));
1526 }
1527
1528 #[test]
1529 fn parse_with_options_allows_disabled_limit() {
1530 let robots = RobotsTxt::parse_with_options(
1531 "User-agent: *\nDisallow: /private\n",
1532 ParseOptions { max_bytes: None },
1533 )
1534 .expect("disabled size limit should parse");
1535
1536 assert!(!robots.is_allowed("AnyBot", "/private"));
1537 }
1538
1539 #[test]
1540 fn diagnostics_report_soft_parse_issues() {
1541 let report = RobotsTxt::parse_with_diagnostics(
1542 "Disallow: /\nMissing separator\n: value\nUser-agent:\nUser-agent: *\nDisallow: /private\n",
1543 );
1544
1545 assert_eq!(report.warnings.len(), 4);
1546 assert_eq!(
1547 report.warnings,
1548 vec![
1549 ParseWarning {
1550 line: 1,
1551 kind: ParseWarningKind::RuleBeforeUserAgent { key: "Disallow" },
1552 },
1553 ParseWarning {
1554 line: 2,
1555 kind: ParseWarningKind::MissingSeparator {
1556 line: "Missing separator",
1557 },
1558 },
1559 ParseWarning {
1560 line: 3,
1561 kind: ParseWarningKind::EmptyDirectiveKey,
1562 },
1563 ParseWarning {
1564 line: 4,
1565 kind: ParseWarningKind::EmptyUserAgent,
1566 },
1567 ]
1568 );
1569 assert!(!report.robots.is_allowed("AnyBot", "/private"));
1570 }
1571
1572 #[cfg(feature = "extensions")]
1573 #[test]
1574 fn collects_extensions_without_changing_groups() {
1575 let robots = RobotsTxt::parse(
1576 "Sitemap: https://example.com/sitemap.xml\nUser-agent: Bingbot\nCrawl-delay: 5\nDisallow: /slow\nHost: example.com\nClean-param: ref /shop\nX-Test: value\n",
1577 );
1578
1579 assert_eq!(
1580 robots.extensions.sitemaps,
1581 vec!["https://example.com/sitemap.xml"]
1582 );
1583 assert_eq!(robots.extensions.crawl_delays.len(), 1);
1584 assert_eq!(robots.extensions.crawl_delays[0].agents, vec!["Bingbot"]);
1585 assert_eq!(robots.extensions.crawl_delays[0].value, "5");
1586 assert_eq!(robots.extensions.hosts, vec!["example.com"]);
1587 assert_eq!(robots.extensions.clean_params[0].value, "ref /shop");
1588 assert_eq!(robots.extensions.other[0].key, "X-Test");
1589 assert!(!robots.is_allowed("Bingbot", "/slow"));
1590 }
1591}