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 agents: HashMap<String, Vec<usize>>,
268 fallback: Vec<usize>,
269 compiled: 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 #[must_use]
400 pub fn parse(input: &'a str) -> Self {
401 parse_inner(input, false).robots
402 }
403
404 pub fn parse_bytes(input: &'a [u8]) -> Result<Self, ParseError> {
427 Self::parse_bytes_with_options(input, ParseOptions::default())
428 }
429
430 pub fn parse_bytes_with_options(
455 input: &'a [u8],
456 options: ParseOptions,
457 ) -> Result<Self, ParseError> {
458 check_size(input.len(), options)?;
459 let input = str::from_utf8(input)?;
460 Ok(Self::parse(input))
461 }
462
463 pub fn parse_with_options(input: &'a str, options: ParseOptions) -> Result<Self, ParseError> {
488 check_size(input.len(), options)?;
489 Ok(Self::parse(input))
490 }
491
492 #[must_use]
514 pub fn parse_with_diagnostics(input: &'a str) -> ParseReport<'a> {
515 parse_inner(input, true)
516 }
517
518 pub fn parse_with_diagnostics_options(
541 input: &'a str,
542 options: ParseOptions,
543 ) -> Result<ParseReport<'a>, ParseError> {
544 check_size(input.len(), options)?;
545 Ok(parse_inner(input, true))
546 }
547
548 pub fn parse_bytes_with_diagnostics(input: &'a [u8]) -> Result<ParseReport<'a>, ParseError> {
573 Self::parse_bytes_with_diagnostics_options(input, ParseOptions::default())
574 }
575
576 pub fn parse_bytes_with_diagnostics_options(
599 input: &'a [u8],
600 options: ParseOptions,
601 ) -> Result<ParseReport<'a>, ParseError> {
602 check_size(input.len(), options)?;
603 let input = str::from_utf8(input)?;
604 Ok(parse_inner(input, true))
605 }
606
607 #[must_use]
626 pub fn matcher(&'a self) -> RobotsMatcher<'a> {
627 RobotsMatcher::new(self)
628 }
629
630 #[must_use]
655 pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
656 if path == "/robots.txt" {
657 return true;
658 }
659
660 let mut exact_match = false;
661 let mut best: Option<(usize, RuleKind)> = None;
662
663 for group in &self.groups {
664 if group
665 .agents
666 .iter()
667 .any(|agent| *agent != "*" && agent.eq_ignore_ascii_case(user_agent))
668 {
669 exact_match = true;
670 apply_group_rules(group, path, &mut best);
671 }
672 }
673
674 if !exact_match {
675 for group in &self.groups {
676 if group.agents.contains(&"*") {
677 apply_group_rules(group, path, &mut best);
678 }
679 }
680 }
681
682 rule_decision(best)
683 }
684}
685
686impl<'a> RobotsMatcher<'a> {
687 fn new(robots: &'a RobotsTxt<'a>) -> Self {
688 let groups = robots.groups.as_slice();
689 let mut agents: HashMap<String, Vec<usize>> = HashMap::new();
690 let mut fallback = vec![];
691 let mut compiled = Vec::with_capacity(groups.len());
692
693 for (group_index, group) in groups.iter().enumerate() {
694 for agent in &group.agents {
695 if *agent == "*" {
696 fallback.push(group_index);
697 } else {
698 let indexes = agents.entry(agent.to_ascii_lowercase()).or_default();
699 if !indexes.contains(&group_index) {
700 indexes.push(group_index);
701 }
702 }
703 }
704
705 compiled.push(CompiledGroup::new(group));
706 }
707
708 Self {
709 agents,
710 fallback,
711 compiled,
712 }
713 }
714
715 #[must_use]
721 pub fn is_allowed(&self, user_agent: &str, path: &str) -> bool {
722 if path == "/robots.txt" {
723 return true;
724 }
725
726 let mut best: Option<(usize, RuleKind)> = None;
727 let agent = user_agent.to_ascii_lowercase();
728
729 if let Some(group_indexes) = self.agents.get(&agent) {
730 self.apply_group_indexes(group_indexes, path, &mut best);
731 } else {
732 self.apply_group_indexes(&self.fallback, path, &mut best);
733 }
734
735 rule_decision(best)
736 }
737
738 fn apply_group_indexes(
739 &self,
740 group_indexes: &[usize],
741 path: &str,
742 best: &mut Option<(usize, RuleKind)>,
743 ) {
744 for &group_index in group_indexes {
745 self.compiled[group_index].apply_rules(path, best);
746 }
747 }
748}
749
750impl<'a> CompiledGroup<'a> {
751 fn new(group: &Group<'a>) -> Self {
752 let mut compiled = Self {
753 prefix_rules: RuleTrie::new(),
754 exact_rules: HashMap::new(),
755 wildcard_rules: vec![],
756 wildcard_prefixes: WildcardRuleTrie::new(),
757 };
758
759 for rule in &group.rules {
760 compiled.push_rule(rule);
761 }
762
763 compiled
764 }
765
766 fn push_rule(&mut self, rule: &Rule<'a>) {
767 if rule.pattern.is_empty() {
768 return;
769 }
770
771 let (pattern, anchored) = strip_end_anchor(rule.pattern);
772 let pattern_bytes = pattern.as_bytes();
773
774 if let Some(first_wildcard) = memchr(b'*', pattern_bytes) {
775 let rule_index = self.wildcard_rules.len();
776 self.wildcard_prefixes
777 .insert(&pattern_bytes[..first_wildcard], rule_index);
778 self.wildcard_rules.push(WildcardRule::new(
779 rule.kind,
780 pattern,
781 anchored,
782 first_wildcard,
783 ));
784 } else if anchored {
785 let decision = self.exact_rules.entry(pattern).or_insert(rule.kind);
786 if rule.kind == RuleKind::Allow {
787 *decision = RuleKind::Allow;
788 }
789 } else {
790 self.prefix_rules
791 .insert(pattern_bytes, pattern.len(), rule.kind);
792 }
793 }
794
795 fn apply_rules(&self, path: &str, best: &mut Option<(usize, RuleKind)>) {
796 let path_bytes = path.as_bytes();
797
798 self.prefix_rules.apply_matches(path_bytes, best);
799
800 if let Some(&kind) = self.exact_rules.get(path) {
801 apply_rule_decision(path.len(), kind, best);
802 }
803
804 self.wildcard_prefixes
805 .apply_matches(path_bytes, &self.wildcard_rules, best);
806 }
807}
808
809impl RuleTrie {
810 fn new() -> Self {
811 Self {
812 nodes: vec![RuleTrieNode::default()],
813 }
814 }
815
816 fn insert(&mut self, pattern: &[u8], specificity: usize, kind: RuleKind) {
817 let mut node_index = 0;
818 for &byte in pattern {
819 node_index = self.child_or_insert(node_index, byte);
820 }
821
822 apply_rule_decision(specificity, kind, &mut self.nodes[node_index].decision);
823 }
824
825 fn apply_matches(&self, path: &[u8], best: &mut Option<(usize, RuleKind)>) {
826 let mut node_index = 0;
827
828 for &byte in path {
829 let Some(next_index) = self.child(node_index, byte) else {
830 return;
831 };
832 node_index = next_index;
833
834 if let Some((specificity, kind)) = self.nodes[node_index].decision {
835 apply_rule_decision(specificity, kind, best);
836 }
837 }
838 }
839
840 fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
841 self.nodes[node_index]
842 .edges
843 .iter()
844 .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
845 }
846
847 fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
848 if let Some(child_index) = self.child(node_index, byte) {
849 return child_index;
850 }
851
852 let child_index = self.nodes.len();
853 self.nodes.push(RuleTrieNode::default());
854 self.nodes[node_index].edges.push((byte, child_index));
855 child_index
856 }
857}
858
859impl WildcardRuleTrie {
860 fn new() -> Self {
861 Self {
862 nodes: vec![WildcardRuleTrieNode::default()],
863 }
864 }
865
866 fn insert(&mut self, literal_prefix: &[u8], rule_index: usize) {
867 let mut node_index = 0;
868 for &byte in literal_prefix {
869 node_index = self.child_or_insert(node_index, byte);
870 }
871
872 self.nodes[node_index].rule_indexes.push(rule_index);
873 }
874
875 fn apply_matches(
876 &self,
877 path: &[u8],
878 rules: &[WildcardRule<'_>],
879 best: &mut Option<(usize, RuleKind)>,
880 ) {
881 let mut node_index = 0;
882 self.apply_node_rules(node_index, path, rules, best);
883
884 for &byte in path {
885 let Some(next_index) = self.child(node_index, byte) else {
886 return;
887 };
888 node_index = next_index;
889 self.apply_node_rules(node_index, path, rules, best);
890 }
891 }
892
893 fn apply_node_rules(
894 &self,
895 node_index: usize,
896 path: &[u8],
897 rules: &[WildcardRule<'_>],
898 best: &mut Option<(usize, RuleKind)>,
899 ) {
900 for &rule_index in &self.nodes[node_index].rule_indexes {
901 let rule = &rules[rule_index];
902 let Some(specificity) = rule.matching_specificity(path) else {
903 continue;
904 };
905
906 apply_rule_decision(specificity, rule.kind, best);
907 }
908 }
909
910 fn child(&self, node_index: usize, byte: u8) -> Option<usize> {
911 self.nodes[node_index]
912 .edges
913 .iter()
914 .find_map(|&(edge_byte, child_index)| (edge_byte == byte).then_some(child_index))
915 }
916
917 fn child_or_insert(&mut self, node_index: usize, byte: u8) -> usize {
918 if let Some(child_index) = self.child(node_index, byte) {
919 return child_index;
920 }
921
922 let child_index = self.nodes.len();
923 self.nodes.push(WildcardRuleTrieNode::default());
924 self.nodes[node_index].edges.push((byte, child_index));
925 child_index
926 }
927}
928
929impl<'a> WildcardRule<'a> {
930 fn new(kind: RuleKind, pattern: &'a str, anchored: bool, first_wildcard: usize) -> Self {
931 let pattern_bytes = pattern.as_bytes();
932 let segments = pattern_bytes[first_wildcard + 1..]
933 .split(|byte| *byte == b'*')
934 .filter(|segment| !segment.is_empty())
935 .collect();
936
937 Self {
938 kind,
939 first_len: first_wildcard,
940 segments,
941 anchored,
942 ends_with_star: pattern_bytes.last() == Some(&b'*'),
943 specificity: pattern.len(),
944 }
945 }
946
947 fn matching_specificity(&self, path: &[u8]) -> Option<usize> {
948 let mut offset = self.first_len;
949
950 for segment in &self.segments {
951 let found = memmem::find(&path[offset..], segment)?;
952 offset += found + segment.len();
953 }
954
955 (!self.anchored || self.ends_with_star || offset == path.len()).then_some(self.specificity)
956 }
957}
958
959fn check_size(len: usize, options: ParseOptions) -> Result<(), ParseError> {
961 if let Some(max) = options.max_bytes {
962 if len > max {
963 return Err(ParseError::TooLarge { len, max });
964 }
965 }
966
967 Ok(())
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971enum DirectiveKind {
972 UserAgent,
973 Allow,
974 Disallow,
975 #[cfg(feature = "extensions")]
976 Sitemap,
977 #[cfg(feature = "extensions")]
978 CrawlDelay,
979 #[cfg(feature = "extensions")]
980 Host,
981 #[cfg(feature = "extensions")]
982 CleanParam,
983 Other,
984}
985
986fn classify_directive_key(key: &str) -> DirectiveKind {
987 match key.as_bytes() {
988 b"Allow" | b"allow" => return DirectiveKind::Allow,
989 b"Disallow" | b"disallow" => return DirectiveKind::Disallow,
990 b"User-agent" | b"user-agent" => return DirectiveKind::UserAgent,
991 #[cfg(feature = "extensions")]
992 b"Host" | b"host" => return DirectiveKind::Host,
993 #[cfg(feature = "extensions")]
994 b"Sitemap" | b"sitemap" => return DirectiveKind::Sitemap,
995 #[cfg(feature = "extensions")]
996 b"Crawl-delay" | b"crawl-delay" => return DirectiveKind::CrawlDelay,
997 #[cfg(feature = "extensions")]
998 b"Clean-param" | b"clean-param" => return DirectiveKind::CleanParam,
999 _ => {}
1000 }
1001
1002 classify_directive_key_ignore_case(key)
1003}
1004
1005#[cold]
1006#[inline(never)]
1007fn classify_directive_key_ignore_case(key: &str) -> DirectiveKind {
1008 match key.len() {
1009 5 if key.eq_ignore_ascii_case("allow") => DirectiveKind::Allow,
1010 8 if key.eq_ignore_ascii_case("disallow") => DirectiveKind::Disallow,
1011 10 if key.eq_ignore_ascii_case("user-agent") => DirectiveKind::UserAgent,
1012 #[cfg(feature = "extensions")]
1013 4 if key.eq_ignore_ascii_case("host") => DirectiveKind::Host,
1014 #[cfg(feature = "extensions")]
1015 7 if key.eq_ignore_ascii_case("sitemap") => DirectiveKind::Sitemap,
1016 #[cfg(feature = "extensions")]
1017 11 if key.eq_ignore_ascii_case("crawl-delay") => DirectiveKind::CrawlDelay,
1018 #[cfg(feature = "extensions")]
1019 11 if key.eq_ignore_ascii_case("clean-param") => DirectiveKind::CleanParam,
1020 _ => DirectiveKind::Other,
1021 }
1022}
1023
1024fn new_group(agent: &str) -> Group<'_> {
1025 Group {
1026 agents: vec![agent],
1027 rules: Vec::with_capacity(4),
1028 }
1029}
1030
1031fn parse_inner<'a>(input: &'a str, diagnostics: bool) -> ParseReport<'a> {
1037 let mut groups = vec![];
1038 let mut current: Option<Group<'a>> = None;
1039 let mut current_has_rules = false;
1040 let mut warnings = vec![];
1041
1042 #[cfg(feature = "extensions")]
1043 let mut extensions = Extensions::default();
1044
1045 for (line_number, line) in Lines::new(input) {
1046 let line = trim_ascii(strip_comment(line));
1047 if line.is_empty() {
1048 continue;
1049 }
1050
1051 let Some((key, value)) = split_directive(line) else {
1052 if diagnostics {
1053 warnings.push(ParseWarning {
1054 line: line_number,
1055 kind: ParseWarningKind::MissingSeparator { line },
1056 });
1057 }
1058 continue;
1059 };
1060
1061 let key = trim_ascii(key);
1062 let value = trim_ascii(value);
1063 if key.is_empty() {
1064 if diagnostics {
1065 warnings.push(ParseWarning {
1066 line: line_number,
1067 kind: ParseWarningKind::EmptyDirectiveKey,
1068 });
1069 }
1070 continue;
1071 }
1072
1073 let directive = classify_directive_key(key);
1074
1075 match directive {
1076 DirectiveKind::UserAgent => {
1077 if value.is_empty() {
1078 if diagnostics {
1079 warnings.push(ParseWarning {
1080 line: line_number,
1081 kind: ParseWarningKind::EmptyUserAgent,
1082 });
1083 }
1084 continue;
1085 }
1086
1087 match current.as_mut() {
1088 Some(group) if !current_has_rules => group.agents.push(value),
1089 Some(_) => {
1090 groups.push(current.take().expect("current group exists"));
1091 current = Some(new_group(value));
1092 current_has_rules = false;
1093 }
1094 None => {
1095 current = Some(new_group(value));
1096 }
1097 }
1098 }
1099 DirectiveKind::Allow | DirectiveKind::Disallow => {
1100 let Some(group) = current.as_mut() else {
1101 if diagnostics {
1102 warnings.push(ParseWarning {
1103 line: line_number,
1104 kind: ParseWarningKind::RuleBeforeUserAgent { key },
1105 });
1106 }
1107 continue;
1108 };
1109
1110 let kind = match directive {
1111 DirectiveKind::Allow => RuleKind::Allow,
1112 DirectiveKind::Disallow => RuleKind::Disallow,
1113 _ => unreachable!("only allow/disallow directives reach this branch"),
1114 };
1115
1116 group.rules.push(Rule {
1117 kind,
1118 pattern: value,
1119 });
1120 current_has_rules = true;
1121 }
1122 _ => {
1123 #[cfg(feature = "extensions")]
1124 collect_extension(&mut extensions, current.as_ref(), directive, key, value);
1125 }
1126 }
1127 }
1128
1129 if let Some(group) = current {
1130 groups.push(group);
1131 }
1132
1133 ParseReport {
1134 robots: RobotsTxt {
1135 groups,
1136 #[cfg(feature = "extensions")]
1137 extensions,
1138 },
1139 warnings,
1140 }
1141}
1142
1143fn apply_group_rules(group: &Group<'_>, path: &str, best: &mut Option<(usize, RuleKind)>) {
1149 for rule in &group.rules {
1150 let Some(specificity) = matching_specificity(rule.pattern, path) else {
1151 continue;
1152 };
1153
1154 apply_rule_decision(specificity, rule.kind, best);
1155 }
1156}
1157
1158fn apply_rule_decision(specificity: usize, kind: RuleKind, best: &mut Option<(usize, RuleKind)>) {
1159 let should_replace = !matches!(
1160 *best,
1161 Some((best_specificity, best_kind))
1162 if specificity < best_specificity
1163 || (specificity == best_specificity
1164 && !(kind == RuleKind::Allow && best_kind == RuleKind::Disallow))
1165 );
1166
1167 if should_replace {
1168 *best = Some((specificity, kind));
1169 }
1170}
1171
1172fn rule_decision(best: Option<(usize, RuleKind)>) -> bool {
1173 match best {
1174 Some((_, RuleKind::Allow)) | None => true,
1175 Some((_, RuleKind::Disallow)) => false,
1176 }
1177}
1178
1179fn matching_specificity(pattern: &str, path: &str) -> Option<usize> {
1185 if pattern.is_empty() {
1186 return None;
1187 }
1188
1189 let (pattern, anchored) = strip_end_anchor(pattern);
1190 let matched = if pattern.as_bytes().contains(&b'*') {
1191 glob_matches(pattern.as_bytes(), path.as_bytes(), anchored)
1192 } else if anchored {
1193 path == pattern
1194 } else {
1195 path.starts_with(pattern)
1196 };
1197
1198 matched.then_some(pattern.len())
1199}
1200
1201fn glob_matches(pattern: &[u8], path: &[u8], anchored: bool) -> bool {
1206 let mut parts = pattern.split(|byte| *byte == b'*');
1207 let Some(first) = parts.next() else {
1208 return true;
1209 };
1210
1211 if !path.starts_with(first) {
1212 return false;
1213 }
1214
1215 let mut offset = first.len();
1216 let mut ends_with_star = pattern.last() == Some(&b'*');
1217
1218 for part in parts {
1219 if part.is_empty() {
1220 ends_with_star = true;
1221 continue;
1222 }
1223
1224 ends_with_star = false;
1225 let Some(found) = memmem::find(&path[offset..], part) else {
1226 return false;
1227 };
1228 offset += found + part.len();
1229 }
1230
1231 !anchored || ends_with_star || offset == path.len()
1232}
1233
1234fn strip_end_anchor(pattern: &str) -> (&str, bool) {
1235 match pattern.strip_suffix('$') {
1236 Some(pattern) => (pattern, true),
1237 None => (pattern, false),
1238 }
1239}
1240
1241#[cfg(feature = "extensions")]
1242fn collect_extension<'a>(
1248 extensions: &mut Extensions<'a>,
1249 current: Option<&Group<'a>>,
1250 directive: DirectiveKind,
1251 key: &'a str,
1252 value: &'a str,
1253) {
1254 match directive {
1255 DirectiveKind::Sitemap => {
1256 if !value.is_empty() {
1257 extensions.sitemaps.push(value);
1258 }
1259 }
1260 DirectiveKind::CrawlDelay => {
1261 extensions.crawl_delays.push(CrawlDelay {
1262 agents: current
1263 .map(|group| group.agents.clone())
1264 .unwrap_or_default(),
1265 value,
1266 });
1267 }
1268 DirectiveKind::Host => {
1269 if !value.is_empty() {
1270 extensions.hosts.push(value);
1271 }
1272 }
1273 DirectiveKind::CleanParam => {
1274 if !value.is_empty() {
1275 extensions.clean_params.push(CleanParam { value });
1276 }
1277 }
1278 _ => {
1279 extensions.other.push(Directive { key, value });
1280 }
1281 }
1282}
1283
1284fn strip_comment(line: &str) -> &str {
1286 match memchr(b'#', line.as_bytes()) {
1287 Some(index) => &line[..index],
1288 None => line,
1289 }
1290}
1291
1292fn split_directive(line: &str) -> Option<(&str, &str)> {
1296 let index = memchr(b':', line.as_bytes())?;
1297 Some((&line[..index], &line[index + 1..]))
1298}
1299
1300fn trim_ascii(value: &str) -> &str {
1305 let bytes = value.as_bytes();
1306 let Some((&first, rest)) = bytes.split_first() else {
1307 return value;
1308 };
1309 let last = rest.last().copied().unwrap_or(first);
1310
1311 if !matches!(first, b' ' | b'\t') && !matches!(last, b' ' | b'\t') {
1312 return value;
1313 }
1314
1315 let mut start = 0;
1316 let mut end = bytes.len();
1317
1318 while start < end && matches!(bytes[start], b' ' | b'\t') {
1319 start += 1;
1320 }
1321 while end > start && matches!(bytes[end - 1], b' ' | b'\t') {
1322 end -= 1;
1323 }
1324
1325 &value[start..end]
1326}
1327
1328struct Lines<'a> {
1333 input: &'a str,
1334 offset: usize,
1335 line: usize,
1336}
1337
1338impl<'a> Lines<'a> {
1339 fn new(input: &'a str) -> Self {
1341 Self {
1342 input,
1343 offset: 0,
1344 line: 1,
1345 }
1346 }
1347}
1348
1349impl<'a> Iterator for Lines<'a> {
1350 type Item = (usize, &'a str);
1351
1352 fn next(&mut self) -> Option<Self::Item> {
1354 if self.offset > self.input.len() {
1355 return None;
1356 }
1357
1358 let remaining = &self.input[self.offset..];
1359 if remaining.is_empty() {
1360 self.offset += 1;
1361 return None;
1362 }
1363
1364 let line_end = memchr(b'\n', remaining.as_bytes()).unwrap_or(remaining.len());
1365 let mut line = &remaining[..line_end];
1366 if let Some(stripped) = line.strip_suffix('\r') {
1367 line = stripped;
1368 }
1369
1370 let line_number = self.line;
1371 self.line += 1;
1372 self.offset += line_end + 1;
1373 Some((line_number, line))
1374 }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 #[test]
1382 fn parses_groups_comments_and_crlf() {
1383 let robots = RobotsTxt::parse(
1384 "# ignored\r\nUser-agent: FooBot\r\nUser-agent: BarBot # same group\r\nDisallow: /private\r\nAllow: /private/public\r\n",
1385 );
1386
1387 assert_eq!(robots.groups.len(), 1);
1388 assert_eq!(robots.groups[0].agents, vec!["FooBot", "BarBot"]);
1389 assert_eq!(robots.groups[0].rules.len(), 2);
1390 assert!(!robots.is_allowed("FooBot", "/private/file"));
1391 assert!(robots.is_allowed("FooBot", "/private/public/file"));
1392 }
1393
1394 #[test]
1395 fn parses_directive_keys_case_insensitively() {
1396 let robots =
1397 RobotsTxt::parse("uSeR-aGeNt: FooBot\nDiSaLlOw: /private\nAlLoW: /private/public\n");
1398
1399 assert!(!robots.is_allowed("FooBot", "/private/file"));
1400 assert!(robots.is_allowed("FooBot", "/private/public/file"));
1401 }
1402
1403 #[test]
1404 fn ignores_rules_before_first_user_agent() {
1405 let robots = RobotsTxt::parse("Disallow: /\nUser-agent: *\nAllow: /\n");
1406
1407 assert!(robots.is_allowed("AnyBot", "/anything"));
1408 }
1409
1410 #[test]
1411 fn starts_new_group_after_rules() {
1412 let robots = RobotsTxt::parse(
1413 "User-agent: FooBot\nDisallow: /foo\nUser-agent: BarBot\nDisallow: /bar\n",
1414 );
1415
1416 assert_eq!(robots.groups.len(), 2);
1417 assert!(!robots.is_allowed("FooBot", "/foo"));
1418 assert!(robots.is_allowed("FooBot", "/bar"));
1419 assert!(!robots.is_allowed("BarBot", "/bar"));
1420 }
1421
1422 #[test]
1423 fn merges_multiple_exact_matching_groups() {
1424 let robots = RobotsTxt::parse(
1425 "User-agent: FooBot\nDisallow: /foo\n\nUser-agent: FooBot\nDisallow: /bar\n",
1426 );
1427
1428 assert!(!robots.is_allowed("FooBot", "/foo"));
1429 assert!(!robots.is_allowed("FooBot", "/bar"));
1430 }
1431
1432 #[test]
1433 fn falls_back_to_star_group() {
1434 let robots =
1435 RobotsTxt::parse("User-agent: *\nDisallow: /all\nUser-agent: FooBot\nAllow: /\n");
1436
1437 assert!(!robots.is_allowed("OtherBot", "/all"));
1438 assert!(robots.is_allowed("FooBot", "/all"));
1439 }
1440
1441 #[test]
1442 fn longest_match_wins_and_allow_wins_ties() {
1443 let robots = RobotsTxt::parse(
1444 "User-agent: *\nDisallow: /example/\nAllow: /example/public\nDisallow: /tie\nAllow: /tie\n",
1445 );
1446
1447 assert!(!robots.is_allowed("AnyBot", "/example/private"));
1448 assert!(robots.is_allowed("AnyBot", "/example/public/page"));
1449 assert!(robots.is_allowed("AnyBot", "/tie"));
1450 }
1451
1452 #[test]
1453 fn supports_wildcard_and_end_anchor() {
1454 let robots = RobotsTxt::parse("User-agent: *\nDisallow: /*.gif$\nAllow: /public/*.gif$\n");
1455
1456 assert!(!robots.is_allowed("AnyBot", "/images/a.gif"));
1457 assert!(robots.is_allowed("AnyBot", "/images/a.gif?size=large"));
1458 assert!(robots.is_allowed("AnyBot", "/public/a.gif"));
1459 }
1460
1461 #[test]
1462 fn empty_disallow_does_not_block() {
1463 let robots = RobotsTxt::parse("User-agent: *\nDisallow:\n");
1464
1465 assert!(robots.is_allowed("AnyBot", "/anything"));
1466 }
1467
1468 #[test]
1469 fn robots_txt_is_implicitly_allowed() {
1470 let robots = RobotsTxt::parse("User-agent: *\nDisallow: /\n");
1471
1472 assert!(robots.is_allowed("AnyBot", "/robots.txt"));
1473 }
1474
1475 #[test]
1476 fn compiled_matcher_matches_regular_matcher_for_core_rules() {
1477 let robots = RobotsTxt::parse(
1478 "User-agent: FooBot\n\
1479 Disallow: /foo\n\
1480 \n\
1481 User-agent: FooBot\n\
1482 Disallow: /bar\n\
1483 Allow: /bar/public\n\
1484 Disallow: /tie\n\
1485 Allow: /tie\n\
1486 \n\
1487 User-agent: ImageBot\n\
1488 Disallow: /*.gif$\n\
1489 Allow: /public/*.gif$\n\
1490 \n\
1491 User-agent: *\n\
1492 Disallow: /fallback\n",
1493 );
1494 let matcher = robots.matcher();
1495
1496 for (agent, path) in [
1497 ("FooBot", "/foo/page"),
1498 ("FooBot", "/bar/page"),
1499 ("FooBot", "/bar/public/page"),
1500 ("FooBot", "/tie"),
1501 ("ImageBot", "/images/a.gif"),
1502 ("ImageBot", "/images/a.gif?size=large"),
1503 ("ImageBot", "/public/a.gif"),
1504 ("OtherBot", "/fallback/page"),
1505 ("OtherBot", "/public/page"),
1506 ("OtherBot", "/robots.txt"),
1507 ] {
1508 assert_eq!(
1509 matcher.is_allowed(agent, path),
1510 robots.is_allowed(agent, path),
1511 "compiled matcher differed for {agent} {path}"
1512 );
1513 }
1514 }
1515
1516 #[test]
1517 fn compiled_matcher_matches_specialized_rule_indexes() {
1518 let robots = RobotsTxt::parse(
1519 "User-agent: *\n\
1520 Disallow: /exact$\n\
1521 Allow: /exact/public\n\
1522 Disallow: *.secret$\n\
1523 Allow: /download/*/public/*.secret$\n\
1524 Disallow: /download/private/*\n",
1525 );
1526 let matcher = robots.matcher();
1527
1528 for path in [
1529 "/exact",
1530 "/exactly",
1531 "/exact/public/file",
1532 "/a.secret",
1533 "/a.secret?x=1",
1534 "/download/foo/public/file.secret",
1535 "/download/private/file",
1536 ] {
1537 assert_eq!(
1538 matcher.is_allowed("AnyBot", path),
1539 robots.is_allowed("AnyBot", path),
1540 "compiled matcher differed for {path}"
1541 );
1542 }
1543 }
1544
1545 #[test]
1546 fn parse_bytes_rejects_invalid_utf8() {
1547 let error = RobotsTxt::parse_bytes(&[0xff]).expect_err("invalid UTF-8 should fail");
1548
1549 assert!(matches!(error, ParseError::Utf8(_)));
1550 }
1551
1552 #[test]
1553 fn parse_with_options_rejects_oversized_input() {
1554 let error =
1555 RobotsTxt::parse_with_options("User-agent: *\n", ParseOptions { max_bytes: Some(4) })
1556 .expect_err("oversized input should fail");
1557
1558 assert!(matches!(error, ParseError::TooLarge { len: 14, max: 4 }));
1559 }
1560
1561 #[test]
1562 fn parse_with_options_allows_disabled_limit() {
1563 let robots = RobotsTxt::parse_with_options(
1564 "User-agent: *\nDisallow: /private\n",
1565 ParseOptions { max_bytes: None },
1566 )
1567 .expect("disabled size limit should parse");
1568
1569 assert!(!robots.is_allowed("AnyBot", "/private"));
1570 }
1571
1572 #[test]
1573 fn diagnostics_report_soft_parse_issues() {
1574 let report = RobotsTxt::parse_with_diagnostics(
1575 "Disallow: /\nMissing separator\n: value\nUser-agent:\nUser-agent: *\nDisallow: /private\n",
1576 );
1577
1578 assert_eq!(report.warnings.len(), 4);
1579 assert_eq!(
1580 report.warnings,
1581 vec![
1582 ParseWarning {
1583 line: 1,
1584 kind: ParseWarningKind::RuleBeforeUserAgent { key: "Disallow" },
1585 },
1586 ParseWarning {
1587 line: 2,
1588 kind: ParseWarningKind::MissingSeparator {
1589 line: "Missing separator",
1590 },
1591 },
1592 ParseWarning {
1593 line: 3,
1594 kind: ParseWarningKind::EmptyDirectiveKey,
1595 },
1596 ParseWarning {
1597 line: 4,
1598 kind: ParseWarningKind::EmptyUserAgent,
1599 },
1600 ]
1601 );
1602 assert!(!report.robots.is_allowed("AnyBot", "/private"));
1603 }
1604
1605 #[cfg(feature = "extensions")]
1606 #[test]
1607 fn collects_extensions_without_changing_groups() {
1608 let robots = RobotsTxt::parse(
1609 "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",
1610 );
1611
1612 assert_eq!(
1613 robots.extensions.sitemaps,
1614 vec!["https://example.com/sitemap.xml"]
1615 );
1616 assert_eq!(robots.extensions.crawl_delays.len(), 1);
1617 assert_eq!(robots.extensions.crawl_delays[0].agents, vec!["Bingbot"]);
1618 assert_eq!(robots.extensions.crawl_delays[0].value, "5");
1619 assert_eq!(robots.extensions.hosts, vec!["example.com"]);
1620 assert_eq!(robots.extensions.clean_params[0].value, "ref /shop");
1621 assert_eq!(robots.extensions.other[0].key, "X-Test");
1622 assert!(!robots.is_allowed("Bingbot", "/slow"));
1623 }
1624}