1use camino::{Utf8Path, Utf8PathBuf};
58use serde::Deserialize;
59
60use crate::http::SourceAllowlist;
61
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub struct HostPattern(String);
73
74impl HostPattern {
75 pub fn new(raw: impl Into<String>) -> Result<Self, PatternError> {
83 let s: String = raw.into();
84 validate_pattern(&s)?;
85 Ok(Self(s))
86 }
87
88 pub fn as_str(&self) -> &str {
90 &self.0
91 }
92}
93
94impl TryFrom<&str> for HostPattern {
95 type Error = PatternError;
96 fn try_from(value: &str) -> Result<Self, Self::Error> {
97 Self::new(value)
98 }
99}
100
101impl TryFrom<String> for HostPattern {
102 type Error = PatternError;
103 fn try_from(value: String) -> Result<Self, Self::Error> {
104 Self::new(value)
105 }
106}
107
108impl<'de> serde::Deserialize<'de> for HostPattern {
109 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
110 let raw = String::deserialize(d)?;
111 Self::new(raw).map_err(serde::de::Error::custom)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
122#[non_exhaustive]
123pub struct UserExtensionHost {
124 pub host: HostPattern,
128 #[serde(default)]
130 pub note: Option<String>,
131}
132
133impl UserExtensionHost {
134 #[cfg(test)]
136 #[allow(clippy::expect_used)]
137 pub(crate) fn for_test(host: &str) -> Self {
138 Self {
139 host: HostPattern::new(host).expect("test host must be valid"),
140 note: None,
141 }
142 }
143}
144
145#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum PatternError {
152 #[error("empty pattern")]
154 Empty,
155 #[error("pattern has leading or trailing whitespace")]
157 Whitespace,
158 #[error("bare wildcard `*` is not allowed")]
160 BareWildcard,
161 #[error("wildcard `*` is only allowed as the first character followed by `.`")]
164 MisplacedWildcard,
165 #[error("multi-segment globs are not allowed; use a single `*.<suffix>`")]
167 MultiSegmentGlob,
168 #[error("nothing after wildcard prefix `*.`")]
170 EmptySuffix,
171 #[error("host must contain at least one `.`")]
173 NoDot,
174 #[error("empty label (consecutive `.` or leading/trailing `.`)")]
176 EmptyLabel,
177 #[error("label `{label}` starts or ends with `-`")]
179 LabelHyphenBorder {
180 label: String,
182 },
183 #[error("label `{label}` contains a non-host character (allowed: A-Z a-z 0-9 - .)")]
185 BadChar {
186 label: String,
188 },
189}
190
191#[derive(Debug, thiserror::Error)]
193#[non_exhaustive]
194pub enum UserExtensionError {
195 #[error("io reading {path}: {source}")]
198 Io {
199 path: String,
201 #[source]
203 source: std::io::Error,
204 },
205 #[error("toml parse of {path}: {source}")]
207 Parse {
208 path: String,
210 #[source]
212 source: toml::de::Error,
213 },
214 #[error("invalid host pattern(s) in {path}: {issues:?}")]
219 InvalidPatterns {
220 path: String,
222 issues: Vec<InvalidPatternIssue>,
224 },
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229#[non_exhaustive]
230pub struct InvalidPatternIssue {
231 pub pattern: String,
233 pub kind: PatternError,
235}
236
237impl std::fmt::Display for InvalidPatternIssue {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 write!(f, "`{}`: {}", self.pattern, self.kind)
240 }
241}
242
243pub fn load(config_path: &Utf8Path) -> Result<UserExtensionConfig, UserExtensionError> {
261 let text = match std::fs::read_to_string(config_path.as_std_path()) {
262 Ok(s) => s,
263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
264 return Ok(UserExtensionConfig::default())
265 }
266 Err(e) => {
267 return Err(UserExtensionError::Io {
268 path: config_path.to_string(),
269 source: e,
270 })
271 }
272 };
273 parse_str(&text, config_path)
274}
275
276#[derive(Debug, Default, Deserialize)]
282struct RawConfig {
283 #[serde(default)]
284 network: Option<RawNetwork>,
285 #[serde(default)]
286 store: Option<RawStore>,
287 #[serde(default)]
288 cite: Option<RawCite>,
289 #[serde(flatten)]
290 _other: serde::de::IgnoredAny,
291}
292
293#[derive(Debug, Default, Deserialize)]
295struct RawCite {
296 #[serde(default)]
297 key_template: Option<String>,
298 #[serde(default)]
299 file_field: Option<String>,
300 #[serde(flatten)]
301 _other: serde::de::IgnoredAny,
302}
303
304#[derive(Debug, Default, Deserialize)]
309struct RawStore {
310 #[serde(default)]
316 root: Option<String>,
317 #[serde(flatten)]
318 _other: serde::de::IgnoredAny,
319}
320
321#[derive(Debug, Default, Deserialize)]
322struct RawNetwork {
323 #[serde(default)]
324 additional_hosts: Vec<RawHost>,
325 #[serde(default)]
328 trust_academic_repos: bool,
329 #[serde(default)]
332 trust_oa_registries: bool,
333 #[serde(default)]
335 contact_email: Option<String>,
336 #[serde(default)]
340 unpaywall_email: Option<String>,
341 #[serde(flatten)]
342 _other: serde::de::IgnoredAny,
343}
344
345#[derive(Debug, Deserialize)]
349#[serde(deny_unknown_fields)]
350struct RawHost {
351 host: String,
352 #[serde(default)]
353 note: Option<String>,
354}
355
356#[derive(Debug, Default)]
362#[non_exhaustive]
363pub struct UserExtensionConfig {
364 pub additional_hosts: Vec<UserExtensionHost>,
366 pub trust_academic_repos: bool,
370 pub trust_oa_registries: bool,
374 pub store_root: Option<String>,
381 pub contact_email: Option<String>,
384 pub unpaywall_email: Option<String>,
388 pub cite_key_template: Option<String>,
391 pub cite_file_field: Option<String>,
393}
394
395pub fn academic_repo_hosts() -> Vec<UserExtensionHost> {
406 const PATTERNS: &[(&str, &str)] = &[
407 ("*.ac.uk", "UK academic institutions (Universities UK)"),
408 ("*.ac.jp", "Japanese academic institutions (NII)"),
409 ("*.jst.go.jp", "J-STAGE / JST academic platform (Japan)"),
410 ("*.edu.au", "Australian universities (TEQSA)"),
411 ("*.edu.cn", "Chinese universities (MoE)"),
412 ("*.ac.cn", "Chinese academic institutions"),
413 ("*.edu.pl", "Polish universities (MEiN)"),
414 ("*.ac.nz", "New Zealand universities"),
415 ("*.ac.za", "South African universities (DHET)"),
416 ("*.ac.in", "Indian academic institutions"),
417 ("*.edu.br", "Brazilian universities (CAPES)"),
418 ("*.edu.tw", "Taiwanese universities (MoE)"),
419 ("*.edu.tr", "Turkish universities (YÖK)"),
420 ("*.edu.ar", "Argentine universities (SPU)"),
421 ("*.edu.mx", "Mexican universities (SEP)"),
422 ];
423 PATTERNS
424 .iter()
425 .filter_map(|(pat, note)| {
426 HostPattern::new(*pat).ok().map(|host| UserExtensionHost {
427 host,
428 note: Some(note.to_string()),
429 })
430 })
431 .collect()
432}
433
434pub fn oa_registry_hosts() -> Vec<UserExtensionHost> {
464 const PATTERNS: &[(&str, &str)] = &[
465 ("scielo.org", "SciELO — Latin American / Iberian OA network"),
466 ("*.scielo.org", "SciELO national portals"),
467 ("*.scielo.br", "SciELO Brazil"),
468 ("zenodo.org", "Zenodo — CERN general-purpose OA repository"),
469 ("*.zenodo.org", "Zenodo subdomains"),
470 ("osf.io", "OSF / OSF Preprints (Center for Open Science)"),
471 ("*.osf.io", "OSF preprint servers (psyarxiv, socarxiv, ...)"),
472 ("hal.science", "HAL — French national OA repository"),
473 ("*.hal.science", "HAL institutional portals"),
474 (
475 "core.ac.uk",
476 "CORE — OA aggregator (Open University / Jisc)",
477 ),
478 (
482 "www.jstage.jst.go.jp",
483 "J-STAGE — Japan's national journal platform (JST)",
484 ),
485 ];
486 PATTERNS
487 .iter()
488 .filter_map(|(pat, note)| {
489 HostPattern::new(*pat).ok().map(|host| UserExtensionHost {
490 host,
491 note: Some(note.to_string()),
492 })
493 })
494 .collect()
495}
496
497fn parse_str(
500 text: &str,
501 config_path: &Utf8Path,
502) -> Result<UserExtensionConfig, UserExtensionError> {
503 let raw: RawConfig = toml::from_str(text).map_err(|e| UserExtensionError::Parse {
504 path: config_path.to_string(),
505 source: e,
506 })?;
507 let raw_net = raw.network.unwrap_or_default();
508 let trust_academic_repos = raw_net.trust_academic_repos;
509 let trust_oa_registries = raw_net.trust_oa_registries;
510 let trim_nonempty =
514 |v: Option<String>| v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
515 let contact_email = trim_nonempty(raw_net.contact_email);
516 let unpaywall_email = trim_nonempty(raw_net.unpaywall_email);
517 let raw_hosts = raw_net.additional_hosts;
518
519 let mut issues = Vec::new();
523 let mut validated = Vec::with_capacity(raw_hosts.len());
524 for raw_host in raw_hosts {
525 match HostPattern::new(raw_host.host.clone()) {
526 Ok(host) => validated.push(UserExtensionHost {
527 host,
528 note: raw_host.note,
529 }),
530 Err(kind) => issues.push(InvalidPatternIssue {
531 pattern: raw_host.host,
532 kind,
533 }),
534 }
535 }
536 if !issues.is_empty() {
537 return Err(UserExtensionError::InvalidPatterns {
538 path: config_path.to_string(),
539 issues,
540 });
541 }
542 let store_root = raw
546 .store
547 .and_then(|st| st.root)
548 .map(|r| r.trim().to_string())
549 .filter(|r| !r.is_empty());
550
551 let nonblank = |v: Option<String>| v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
552 let (cite_key_template, cite_file_field) = match raw.cite {
553 Some(c) => (nonblank(c.key_template), nonblank(c.file_field)),
554 None => (None, None),
555 };
556
557 Ok(UserExtensionConfig {
558 additional_hosts: validated,
559 trust_academic_repos,
560 trust_oa_registries,
561 store_root,
562 contact_email,
563 unpaywall_email,
564 cite_key_template,
565 cite_file_field,
566 })
567}
568
569#[derive(Debug, thiserror::Error)]
571#[non_exhaustive]
572pub enum ConfigDirError {
573 #[error("{key} is not valid UTF-8")]
579 NotUnicode {
580 key: &'static str,
582 },
583 #[error("neither HOME nor USERPROFILE is set")]
585 NoHome,
586}
587
588fn env_utf8(key: &'static str) -> Result<Option<String>, ConfigDirError> {
590 match std::env::var(key) {
591 Ok(s) if s.is_empty() => Ok(None),
592 Ok(s) => Ok(Some(s)),
593 Err(std::env::VarError::NotPresent) => Ok(None),
594 Err(std::env::VarError::NotUnicode(_)) => Err(ConfigDirError::NotUnicode { key }),
595 }
596}
597
598pub fn config_dir() -> Result<Utf8PathBuf, ConfigDirError> {
625 if let Some(s) = env_utf8("XDG_CONFIG_HOME")? {
626 return Ok(Utf8PathBuf::from(s));
627 }
628 if let Some(s) = env_utf8("APPDATA")? {
629 return Ok(Utf8PathBuf::from(s));
630 }
631 if let Some(s) = env_utf8("HOME")? {
632 return Ok(Utf8PathBuf::from(s).join(".config"));
633 }
634 if let Some(s) = env_utf8("USERPROFILE")? {
635 return Ok(Utf8PathBuf::from(s).join(".config"));
636 }
637 Err(ConfigDirError::NoHome)
638}
639
640pub fn config_path() -> Result<Utf8PathBuf, ConfigDirError> {
646 Ok(config_dir()?.join("doiget").join("config.toml"))
647}
648
649#[must_use]
659pub fn load_or_default() -> UserExtensionConfig {
660 let path = match config_path() {
661 Ok(p) => p,
662 Err(e) => {
663 tracing::debug!(error = %e, "no config directory; config.toml not read");
664 return UserExtensionConfig::default();
665 }
666 };
667 match load(&path) {
668 Ok(c) => c,
669 Err(e) => {
670 tracing::warn!(
671 path = %path,
672 error = %e,
673 "config.toml could not be read; its settings are ignored and defaults used \
674 instead. Run `doiget config doctor` to see what is in effect."
675 );
676 UserExtensionConfig::default()
677 }
678 }
679}
680
681#[must_use]
695pub fn expand_store_root(raw: &str) -> Utf8PathBuf {
696 let rest = match raw.strip_prefix('~') {
697 Some(r) => r,
698 None => return Utf8PathBuf::from(raw),
699 };
700 if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\u{5c}')) {
705 return Utf8PathBuf::from(raw);
706 }
707 let home = std::env::var("HOME")
708 .ok()
709 .filter(|h| !h.is_empty())
710 .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()));
711 match home {
712 Some(h) => {
713 let trimmed = rest.trim_start_matches(['/', '\u{5c}']);
714 if trimmed.is_empty() {
715 Utf8PathBuf::from(h)
716 } else {
717 Utf8PathBuf::from(h).join(trimmed)
718 }
719 }
720 None => Utf8PathBuf::from(raw),
721 }
722}
723
724pub fn validate_pattern(pattern: &str) -> Result<(), PatternError> {
739 if pattern.is_empty() {
740 return Err(PatternError::Empty);
741 }
742 if pattern.trim() != pattern {
743 return Err(PatternError::Whitespace);
744 }
745 if pattern == "*" {
746 return Err(PatternError::BareWildcard);
747 }
748 let body = match pattern.strip_prefix("*.") {
749 Some(rest) => {
750 if rest.contains('*') {
753 return Err(PatternError::MultiSegmentGlob);
754 }
755 rest
756 }
757 None if pattern.contains('*') => {
758 return Err(PatternError::MisplacedWildcard);
761 }
762 None => pattern,
763 };
764 if body.is_empty() {
765 return Err(PatternError::EmptySuffix);
766 }
767 validate_fqdn(body)
768}
769
770fn validate_fqdn(body: &str) -> Result<(), PatternError> {
771 if !body.contains('.') {
772 return Err(PatternError::NoDot);
773 }
774 for label in body.split('.') {
775 if label.is_empty() {
776 return Err(PatternError::EmptyLabel);
777 }
778 if label.starts_with('-') || label.ends_with('-') {
779 return Err(PatternError::LabelHyphenBorder {
780 label: label.to_string(),
781 });
782 }
783 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
784 return Err(PatternError::BadChar {
785 label: label.to_string(),
786 });
787 }
788 }
789 Ok(())
790}
791
792pub fn merge_into_allowlists(
806 allowlists: &mut Vec<SourceAllowlist>,
807 user_hosts: &[UserExtensionHost],
808) {
809 if user_hosts.is_empty() {
810 return;
811 }
812 if let Some(oa) = allowlists.iter_mut().find(|a| a.source == "oa-publisher") {
813 for h in user_hosts {
814 let s = h.host.as_str();
815 if !oa.redirect_hosts.iter().any(|p| p == s) {
816 oa.redirect_hosts.push(s.to_string());
817 }
818 }
819 return;
820 }
821 let mut new_patterns: Vec<String> = Vec::with_capacity(user_hosts.len());
822 for h in user_hosts {
823 let s = h.host.as_str().to_string();
824 if !new_patterns.contains(&s) {
825 new_patterns.push(s);
826 }
827 }
828 allowlists.push(SourceAllowlist::new("oa-publisher", new_patterns));
829}
830
831#[cfg(test)]
832#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
833mod tests {
834 use super::*;
835
836 fn p(s: &str) -> &Utf8Path {
837 Utf8Path::new(s)
838 }
839
840 #[test]
843 fn validate_pattern_accepts_literal_fqdn() {
844 assert!(validate_pattern("ruj.uj.edu.pl").is_ok());
845 assert!(validate_pattern("example.org").is_ok());
846 assert!(validate_pattern("a.b.c.d.e").is_ok());
847 }
848
849 #[test]
850 fn validate_pattern_accepts_single_suffix_wildcard() {
851 assert!(validate_pattern("*.uj.edu.pl").is_ok());
852 assert!(validate_pattern("*.aps.org").is_ok());
853 }
854
855 #[test]
856 fn validate_pattern_rejects_empty() {
857 assert_eq!(validate_pattern(""), Err(PatternError::Empty));
858 }
859
860 #[test]
861 fn validate_pattern_rejects_whitespace() {
862 assert_eq!(
863 validate_pattern(" example.org"),
864 Err(PatternError::Whitespace)
865 );
866 assert_eq!(
867 validate_pattern("example.org "),
868 Err(PatternError::Whitespace)
869 );
870 }
871
872 #[test]
873 fn validate_pattern_rejects_bare_wildcard() {
874 assert_eq!(validate_pattern("*"), Err(PatternError::BareWildcard));
875 }
876
877 #[test]
878 fn validate_pattern_rejects_multi_segment_globs() {
879 for bad in ["*.edu.*", "*.ac.*", "*.*", "*.example.*"] {
880 assert_eq!(
881 validate_pattern(bad),
882 Err(PatternError::MultiSegmentGlob),
883 "{bad} should be MultiSegmentGlob"
884 );
885 }
886 }
887
888 #[test]
889 fn validate_pattern_rejects_misplaced_wildcards() {
890 for bad in ["foo.*.org", "f*o.bar", "*foo.bar"] {
891 assert_eq!(
892 validate_pattern(bad),
893 Err(PatternError::MisplacedWildcard),
894 "{bad} should be MisplacedWildcard"
895 );
896 }
897 }
898
899 #[test]
900 fn validate_pattern_rejects_non_host_chars() {
901 for bad in ["user@host.com", "host.com/", "host.com:80", "https://x.y"] {
902 assert!(
903 matches!(
904 validate_pattern(bad),
905 Err(PatternError::BadChar { .. }) | Err(PatternError::EmptyLabel)
906 ),
907 "{bad} should be BadChar or EmptyLabel; got {:?}",
908 validate_pattern(bad)
909 );
910 }
911 }
912
913 #[test]
914 fn validate_pattern_rejects_no_dot() {
915 assert_eq!(validate_pattern("singlelabel"), Err(PatternError::NoDot));
916 }
917
918 #[test]
919 fn validate_pattern_rejects_empty_label_classes() {
920 for bad in [".example.org", "example..org", "example.org."] {
921 assert_eq!(
922 validate_pattern(bad),
923 Err(PatternError::EmptyLabel),
924 "{bad} should be EmptyLabel"
925 );
926 }
927 }
928
929 #[test]
930 fn validate_pattern_rejects_hyphen_bordering_labels() {
931 for (bad, label) in [
932 ("-foo.example.org", "-foo"),
933 ("foo.-example.org", "-example"),
934 ("foo.example-.org", "example-"),
935 ] {
936 assert_eq!(
937 validate_pattern(bad),
938 Err(PatternError::LabelHyphenBorder {
939 label: label.to_string()
940 }),
941 "{bad} should be LabelHyphenBorder({label})"
942 );
943 }
944 }
945
946 #[test]
947 fn validate_pattern_rejects_empty_suffix_after_wildcard() {
948 assert_eq!(validate_pattern("*."), Err(PatternError::EmptySuffix));
949 }
950
951 #[test]
954 fn host_pattern_new_validates() {
955 assert!(HostPattern::new("ruj.uj.edu.pl").is_ok());
956 assert_eq!(HostPattern::new(""), Err(PatternError::Empty));
957 }
958
959 #[test]
960 fn host_pattern_try_from_str_and_string() {
961 let from_str: HostPattern = "*.aps.org".try_into().expect("ok");
962 let from_string: HostPattern = String::from("*.aps.org").try_into().expect("ok");
963 assert_eq!(from_str, from_string);
964 }
965
966 #[test]
967 fn host_pattern_deserialize_validates() {
968 let bad = toml::from_str::<HostPattern>("\"*.edu.*\"");
972 assert!(bad.is_err(), "TOML deserialize MUST validate the pattern");
973 }
974
975 #[test]
978 fn parse_empty_config_returns_no_hosts() {
979 let cfg = parse_str("", p("config.toml")).unwrap();
980 assert_eq!(cfg.additional_hosts, vec![]);
981 assert!(!cfg.trust_academic_repos);
982 }
983
984 #[test]
985 fn parse_config_without_network_section_returns_no_hosts() {
986 let toml = r#"
987 [store]
988 root = "/tmp"
989 "#;
990 let cfg = parse_str(toml, p("config.toml")).unwrap();
991 assert_eq!(cfg.additional_hosts, vec![]);
992 assert!(!cfg.trust_academic_repos);
993 }
994
995 #[test]
996 fn parse_config_with_unknown_network_fields_is_accepted() {
997 let toml = r#"
1002 [network]
1003 contact_email = "x@y.org"
1004 cooldown_ms = 250
1005 "#;
1006 let cfg = parse_str(toml, p("config.toml")).unwrap();
1007 assert_eq!(cfg.additional_hosts, vec![]);
1008 assert!(!cfg.trust_academic_repos);
1009 }
1010
1011 #[test]
1012 fn parse_rejects_unknown_field_inside_additional_hosts_entry() {
1013 let toml = r#"
1020 [[network.additional_hosts]]
1021 host = "ruj.uj.edu.pl"
1022 notez = "typo"
1023 "#;
1024 let err = parse_str(toml, p("config.toml")).expect_err("typo must fail");
1025 assert!(matches!(err, UserExtensionError::Parse { .. }));
1026 }
1027
1028 #[test]
1029 fn parse_one_literal_host_with_note() {
1030 let toml = r#"
1031 [[network.additional_hosts]]
1032 host = "ruj.uj.edu.pl"
1033 note = "Jagiellonian University Repository"
1034 "#;
1035 let got = parse_str(toml, p("config.toml")).unwrap();
1036 assert_eq!(got.additional_hosts.len(), 1);
1037 assert_eq!(got.additional_hosts[0].host.as_str(), "ruj.uj.edu.pl");
1038 assert_eq!(
1039 got.additional_hosts[0].note.as_deref(),
1040 Some("Jagiellonian University Repository")
1041 );
1042 }
1043
1044 #[test]
1045 fn parse_multiple_hosts_mixed_literal_and_wildcard() {
1046 let toml = r#"
1047 [[network.additional_hosts]]
1048 host = "ruj.uj.edu.pl"
1049
1050 [[network.additional_hosts]]
1051 host = "*.aps.org"
1052 note = "user override"
1053 "#;
1054 let got = parse_str(toml, p("config.toml")).unwrap();
1055 assert_eq!(got.additional_hosts.len(), 2);
1056 assert_eq!(got.additional_hosts[0].host.as_str(), "ruj.uj.edu.pl");
1057 assert!(got.additional_hosts[0].note.is_none());
1058 assert_eq!(got.additional_hosts[1].host.as_str(), "*.aps.org");
1059 assert_eq!(
1060 got.additional_hosts[1].note.as_deref(),
1061 Some("user override")
1062 );
1063 }
1064
1065 #[test]
1066 fn parse_collects_all_invalid_patterns_not_just_first() {
1067 let toml = r#"
1069 [[network.additional_hosts]]
1070 host = "*.edu.*"
1071
1072 [[network.additional_hosts]]
1073 host = "ok.example.org"
1074
1075 [[network.additional_hosts]]
1076 host = "user@host.com"
1077 "#;
1078 let err = parse_str(toml, p("/home/u/.config/doiget/config.toml"))
1079 .expect_err("invalid patterns must error");
1080 match err {
1081 UserExtensionError::InvalidPatterns { path, issues } => {
1082 assert_eq!(path, "/home/u/.config/doiget/config.toml");
1083 assert_eq!(issues.len(), 2, "both bad patterns collected");
1084 assert_eq!(issues[0].pattern, "*.edu.*");
1085 assert_eq!(issues[0].kind, PatternError::MultiSegmentGlob);
1086 assert_eq!(issues[1].pattern, "user@host.com");
1087 assert!(matches!(
1088 issues[1].kind,
1089 PatternError::BadChar { .. } | PatternError::EmptyLabel
1090 ));
1091 }
1092 other => panic!("expected InvalidPatterns, got {other:?}"),
1093 }
1094 }
1095
1096 #[test]
1097 fn parse_rejects_malformed_toml() {
1098 let err = parse_str("[[network.additional_hosts\nhost=\"foo\"", p("config.toml"))
1099 .expect_err("malformed toml must error");
1100 assert!(matches!(err, UserExtensionError::Parse { .. }));
1101 }
1102
1103 #[test]
1106 fn load_returns_empty_when_file_missing() {
1107 let td = tempfile::TempDir::new().unwrap();
1108 let path = Utf8Path::from_path(td.path()).unwrap().join("missing.toml");
1109 let got = load(&path).expect("missing file MUST be Ok(empty)");
1110 assert_eq!(got.additional_hosts, vec![]);
1111 assert!(!got.trust_academic_repos);
1112 }
1113
1114 #[test]
1115 fn load_reads_real_file() {
1116 use std::io::Write;
1117 let td = tempfile::TempDir::new().unwrap();
1118 let path = Utf8Path::from_path(td.path()).unwrap().join("config.toml");
1119 let mut f = std::fs::File::create(path.as_std_path()).unwrap();
1120 f.write_all(
1121 br#"
1122[[network.additional_hosts]]
1123host = "ruj.uj.edu.pl"
1124note = "Jagiellonian"
1125"#,
1126 )
1127 .unwrap();
1128 let got = load(&path).expect("ok");
1129 assert_eq!(got.additional_hosts.len(), 1);
1130 assert_eq!(got.additional_hosts[0].host.as_str(), "ruj.uj.edu.pl");
1131 }
1132
1133 #[test]
1136 fn merge_appends_to_existing_oa_publisher_entry() {
1137 let mut allowlists = vec![
1138 SourceAllowlist::new("crossref", vec!["api.crossref.org".into()]),
1139 SourceAllowlist::new("oa-publisher", vec!["pmc.ncbi.nlm.nih.gov".into()]),
1140 ];
1141 let user_hosts = vec![UserExtensionHost::for_test("ruj.uj.edu.pl")];
1142 merge_into_allowlists(&mut allowlists, &user_hosts);
1143
1144 let oa = allowlists
1145 .iter()
1146 .find(|a| a.source == "oa-publisher")
1147 .unwrap();
1148 assert_eq!(
1149 oa.redirect_hosts,
1150 vec![
1151 "pmc.ncbi.nlm.nih.gov".to_string(),
1152 "ruj.uj.edu.pl".to_string()
1153 ]
1154 );
1155 assert_eq!(allowlists.len(), 2);
1156 }
1157
1158 #[test]
1159 fn merge_creates_oa_publisher_entry_if_missing() {
1160 let mut allowlists = vec![SourceAllowlist::new(
1161 "crossref",
1162 vec!["api.crossref.org".into()],
1163 )];
1164 let user_hosts = vec![UserExtensionHost::for_test("ruj.uj.edu.pl")];
1165 merge_into_allowlists(&mut allowlists, &user_hosts);
1166 assert_eq!(allowlists.len(), 2);
1167 let oa = allowlists
1168 .iter()
1169 .find(|a| a.source == "oa-publisher")
1170 .unwrap();
1171 assert_eq!(oa.redirect_hosts, vec!["ruj.uj.edu.pl".to_string()]);
1172 }
1173
1174 #[test]
1175 fn merge_is_noop_on_empty_user_hosts() {
1176 let mut allowlists = vec![SourceAllowlist::new(
1177 "crossref",
1178 vec!["api.crossref.org".into()],
1179 )];
1180 let snapshot: Vec<(String, Vec<String>)> = allowlists
1181 .iter()
1182 .map(|a| (a.source.clone(), a.redirect_hosts.clone()))
1183 .collect();
1184 merge_into_allowlists(&mut allowlists, &[]);
1185 let after: Vec<(String, Vec<String>)> = allowlists
1186 .iter()
1187 .map(|a| (a.source.clone(), a.redirect_hosts.clone()))
1188 .collect();
1189 assert_eq!(snapshot, after);
1190 }
1191
1192 #[test]
1193 fn merge_dedupes_against_existing_entries() {
1194 let mut allowlists = vec![SourceAllowlist::new(
1196 "oa-publisher",
1197 vec!["ruj.uj.edu.pl".into()],
1198 )];
1199 let user_hosts = vec![
1200 UserExtensionHost::for_test("ruj.uj.edu.pl"),
1201 UserExtensionHost::for_test("*.uj.edu.pl"),
1202 UserExtensionHost::for_test("*.uj.edu.pl"),
1203 ];
1204 merge_into_allowlists(&mut allowlists, &user_hosts);
1205 let oa = allowlists
1206 .iter()
1207 .find(|a| a.source == "oa-publisher")
1208 .unwrap();
1209 assert_eq!(
1210 oa.redirect_hosts,
1211 vec!["ruj.uj.edu.pl".to_string(), "*.uj.edu.pl".to_string()]
1212 );
1213 }
1214
1215 #[test]
1216 fn merge_dedupes_when_creating_new_entry() {
1217 let mut allowlists = Vec::new();
1218 let user_hosts = vec![
1219 UserExtensionHost::for_test("ruj.uj.edu.pl"),
1220 UserExtensionHost::for_test("ruj.uj.edu.pl"),
1221 ];
1222 merge_into_allowlists(&mut allowlists, &user_hosts);
1223 assert_eq!(allowlists.len(), 1);
1224 assert_eq!(allowlists[0].redirect_hosts, vec!["ruj.uj.edu.pl"]);
1225 }
1226
1227 #[test]
1228 fn merged_pattern_is_matched_by_source_allowlist() {
1229 let parsed = parse_str(
1230 r#"
1231[[network.additional_hosts]]
1232host = "*.uj.edu.pl"
1233"#,
1234 p("config.toml"),
1235 )
1236 .unwrap();
1237 let mut allowlists = vec![SourceAllowlist::new("oa-publisher", vec![])];
1238 merge_into_allowlists(&mut allowlists, &parsed.additional_hosts);
1239 let oa = allowlists
1240 .iter()
1241 .find(|a| a.source == "oa-publisher")
1242 .unwrap();
1243 assert!(oa.matches("ruj.uj.edu.pl"));
1244 assert!(oa.matches("alpha.uj.edu.pl"));
1245 assert!(!oa.matches("ruj.uj.edu.ru"));
1246 }
1247
1248 #[test]
1251 fn parse_trust_academic_repos_false_by_default() {
1252 let cfg = parse_str("[network]\ncooldown_ms = 100", p("config.toml")).unwrap();
1253 assert!(!cfg.trust_academic_repos);
1254 }
1255
1256 #[test]
1257 fn parse_trust_academic_repos_true_when_set() {
1258 let toml = "[network]\ntrust_academic_repos = true";
1259 let cfg = parse_str(toml, p("config.toml")).unwrap();
1260 assert!(cfg.trust_academic_repos);
1261 assert_eq!(cfg.additional_hosts, vec![]);
1262 }
1263
1264 #[test]
1267 fn parse_trust_oa_registries_false_by_default() {
1268 let cfg = parse_str("[network]\n", p("config.toml")).unwrap();
1269 assert!(!cfg.trust_oa_registries);
1270 }
1271
1272 #[test]
1273 fn parse_trust_oa_registries_true_when_set() {
1274 let toml = "[network]\ntrust_oa_registries = true";
1275 let cfg = parse_str(toml, p("config.toml")).unwrap();
1276 assert!(cfg.trust_oa_registries);
1277 assert!(!cfg.trust_academic_repos);
1280 assert_eq!(cfg.additional_hosts, vec![]);
1281 }
1282
1283 #[test]
1284 fn oa_registry_hosts_are_valid_patterns() {
1285 let hosts = oa_registry_hosts();
1286 assert!(
1287 !hosts.is_empty(),
1288 "at least one OA registry pattern expected"
1289 );
1290 for h in &hosts {
1291 validate_pattern(h.host.as_str()).unwrap_or_else(|e| {
1295 panic!("invalid OA registry pattern {}: {e:?}", h.host.as_str())
1296 });
1297 assert!(
1298 h.note.is_some(),
1299 "every curated entry carries a note: {}",
1300 h.host.as_str()
1301 );
1302 }
1303 }
1304
1305 #[test]
1309 fn oa_registry_hosts_exclude_doaj_which_is_now_a_default() {
1310 let hosts = oa_registry_hosts();
1311 let patterns: Vec<&str> = hosts.iter().map(|h| h.host.as_str()).collect();
1312 for gone in &["doaj.org", "*.doaj.org"] {
1313 assert!(
1314 !patterns.contains(gone),
1315 "{gone} belongs to oa_publisher_allowlist since ADR-0037, not to this flag; got {patterns:?}"
1316 );
1317 }
1318 for expected in &[
1319 "scielo.org",
1320 "zenodo.org",
1321 "osf.io",
1322 "hal.science",
1323 "core.ac.uk",
1324 "www.jstage.jst.go.jp",
1325 ] {
1326 assert!(
1327 patterns.contains(expected),
1328 "expected OA registry pattern {expected} not found in {patterns:?}"
1329 );
1330 }
1331 }
1332
1333 #[test]
1337 fn trust_oa_registries_alone_admits_jstage() {
1338 const JSTAGE: &str = "www.jstage.jst.go.jp";
1339 let oa = |a: &Vec<SourceAllowlist>| {
1340 a.iter()
1341 .find(|x| x.source == "oa-publisher")
1342 .expect("oa-publisher allowlist")
1343 .matches(JSTAGE)
1344 };
1345 let mut allowlists = crate::http::oa_publisher_allowlist();
1346 assert!(!oa(&allowlists), "J-STAGE is not a default host");
1347 merge_into_allowlists(&mut allowlists, &oa_registry_hosts());
1348 assert!(oa(&allowlists), "the flag's own hosts admit J-STAGE");
1349 }
1350
1351 #[test]
1354 fn curated_sets_are_disjoint() {
1355 let academic: Vec<String> = academic_repo_hosts()
1356 .iter()
1357 .map(|h| h.host.as_str().to_string())
1358 .collect();
1359 for h in oa_registry_hosts() {
1360 assert!(
1361 !academic.contains(&h.host.as_str().to_string()),
1362 "{} is in both curated sets",
1363 h.host.as_str()
1364 );
1365 }
1366 }
1367
1368 #[test]
1369 fn academic_repo_hosts_are_valid_patterns() {
1370 let hosts = academic_repo_hosts();
1371 assert!(
1372 !hosts.is_empty(),
1373 "at least one academic host pattern expected"
1374 );
1375 for h in &hosts {
1377 assert!(
1378 h.host.as_str().starts_with("*."),
1379 "academic patterns are single-suffix wildcards: {}",
1380 h.host.as_str()
1381 );
1382 }
1383 }
1384
1385 #[test]
1386 fn academic_repo_hosts_match_expected_domains() {
1387 let hosts = academic_repo_hosts();
1388 let patterns: Vec<&str> = hosts.iter().map(|h| h.host.as_str()).collect();
1389 for expected in &["*.ac.uk", "*.ac.jp", "*.edu.au", "*.edu.cn", "*.edu.br"] {
1390 assert!(
1391 patterns.contains(expected),
1392 "expected academic pattern {expected} not found"
1393 );
1394 }
1395 }
1396 #[test]
1398 fn store_root_is_parsed_from_the_same_file_as_the_network_gate() {
1399 let cfg = parse_str(
1400 "[store]\nroot = \"/home/alice/papers\"\n\n[network]\ntrust_academic_repos = true\n",
1401 Utf8Path::new("test.toml"),
1402 )
1403 .expect("parses");
1404 assert_eq!(cfg.store_root.as_deref(), Some("/home/alice/papers"));
1405 assert!(
1406 cfg.trust_academic_repos,
1407 "the network section must still be read from the same pass"
1408 );
1409 }
1410
1411 #[test]
1414 fn cite_section_parses_and_blank_values_are_absent() {
1415 let cfg = parse_str(
1417 "[cite]\nkey_template = \"{author}{year}\"\nfile_field = \" \"\n",
1418 Utf8Path::new("t.toml"),
1419 )
1420 .expect("parses");
1421 assert_eq!(cfg.cite_key_template.as_deref(), Some("{author}{year}"));
1422 assert_eq!(cfg.cite_file_field, None, "a blank value is absent");
1423 let cfg = parse_str("", Utf8Path::new("t.toml")).expect("parses");
1424 assert_eq!((cfg.cite_key_template, cfg.cite_file_field), (None, None));
1425 }
1426
1427 #[test]
1428 fn blank_store_root_parses_as_absent() {
1429 let cfg =
1430 parse_str("[store]\nroot = \" \"\n", Utf8Path::new("test.toml")).expect("parses");
1431 assert_eq!(cfg.store_root, None);
1432 }
1433
1434 #[test]
1436 fn absent_store_table_is_not_an_error() {
1437 let cfg = parse_str(
1438 "[network]\ntrust_oa_registries = true\n",
1439 Utf8Path::new("t.toml"),
1440 )
1441 .expect("parses");
1442 assert_eq!(cfg.store_root, None);
1443 assert!(cfg.trust_oa_registries);
1444 }
1445
1446 #[test]
1449 #[serial_test::serial]
1450 fn expand_store_root_resolves_a_leading_tilde() {
1451 let prior_home = std::env::var("HOME").ok();
1452 let prior_profile = std::env::var("USERPROFILE").ok();
1453 std::env::set_var("HOME", "/home/alice");
1454 std::env::remove_var("USERPROFILE");
1455
1456 assert_eq!(
1457 expand_store_root("~/papers")
1458 .as_str()
1459 .replace('\u{5c}', "/"),
1460 "/home/alice/papers"
1461 );
1462 assert_eq!(expand_store_root("~").as_str(), "/home/alice");
1463 assert_eq!(expand_store_root("/srv/papers").as_str(), "/srv/papers");
1465 assert_eq!(expand_store_root("papers").as_str(), "papers");
1466 assert_eq!(expand_store_root("~bob/papers").as_str(), "~bob/papers");
1469
1470 match prior_home {
1471 Some(v) => std::env::set_var("HOME", v),
1472 None => std::env::remove_var("HOME"),
1473 }
1474 if let Some(v) = prior_profile {
1475 std::env::set_var("USERPROFILE", v);
1476 }
1477 }
1478}