bitwarden_importers/importers/onepassword/access/
sign_in.rs1use std::fmt;
4
5use super::error::OnePasswordError;
6
7const MAX_SUBDOMAIN_LENGTH: usize = 63;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
18pub enum SignInDomain {
19 Global,
21 Europe,
23 Canada,
25 Enterprise,
27}
28
29impl SignInDomain {
30 pub fn as_str(&self) -> &'static str {
32 match self {
33 SignInDomain::Global => "1password.com",
34 SignInDomain::Europe => "1password.eu",
35 SignInDomain::Canada => "1password.ca",
36 SignInDomain::Enterprise => "ent.1password.com",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
47#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
48pub struct SignInAddress {
49 pub subdomain: String,
51 pub domain: SignInDomain,
53}
54
55impl SignInAddress {
56 pub(crate) fn normalize(&mut self) -> Result<(), OnePasswordError> {
57 self.subdomain = self.subdomain.trim().to_lowercase();
58 self.validate()
59 }
60
61 fn validate(&self) -> Result<(), OnePasswordError> {
62 let invalid = |reason: &str| {
63 Err(OnePasswordError::InvalidSignInAddress(format!(
64 "subdomain '{}': {reason}",
65 self.subdomain
66 )))
67 };
68
69 if self.subdomain.is_empty() {
70 return invalid("it is empty");
71 }
72 if self.subdomain.len() > MAX_SUBDOMAIN_LENGTH {
73 return invalid(&format!(
74 "it is longer than {MAX_SUBDOMAIN_LENGTH} characters"
75 ));
76 }
77 if !self
78 .subdomain
79 .bytes()
80 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
81 {
82 return invalid("only letters, digits and dashes are allowed");
83 }
84 if self.subdomain.starts_with('-') || self.subdomain.ends_with('-') {
85 return invalid("it starts or ends with a dash");
86 }
87
88 Ok(())
89 }
90}
91
92impl fmt::Display for SignInAddress {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 write!(f, "{}.{}", self.subdomain, self.domain.as_str())
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 fn address(subdomain: &str) -> Result<SignInAddress, OnePasswordError> {
103 let mut address = SignInAddress {
104 subdomain: subdomain.into(),
105 domain: SignInDomain::Global,
106 };
107 address.normalize()?;
108 Ok(address)
109 }
110
111 #[test]
112 fn address_joins_the_subdomain_and_the_domain() {
113 for (domain, expected) in [
114 (SignInDomain::Global, "my.1password.com"),
115 (SignInDomain::Europe, "my.1password.eu"),
116 (SignInDomain::Canada, "my.1password.ca"),
117 (SignInDomain::Enterprise, "my.ent.1password.com"),
118 ] {
119 let address = SignInAddress {
120 subdomain: "my".into(),
121 domain,
122 };
123 assert_eq!(address.to_string(), expected);
124 assert_eq!(address.domain, domain);
125 }
126 }
127
128 #[test]
129 fn address_cleans_up_the_subdomain() {
130 assert_eq!(
131 address(" ACME-Team \n")
132 .expect("a valid subdomain")
133 .to_string(),
134 "acme-team.1password.com"
135 );
136 }
137
138 #[test]
140 fn address_rejects_a_subdomain_that_is_not_a_label() {
141 for subdomain in [
142 "",
143 " ",
144 "my.1password.com",
145 "evil.com/x",
146 "[email protected]",
147 "my?x=",
148 "my#x",
149 "my team",
150 "-my",
151 "my-",
152 &"m".repeat(MAX_SUBDOMAIN_LENGTH + 1),
153 ] {
154 let error = address(subdomain).expect_err("not a DNS label");
155 assert!(
156 matches!(&error, OnePasswordError::InvalidSignInAddress(_)),
157 "unexpected error for '{subdomain}': {error}"
158 );
159 }
160 }
161
162 #[test]
163 fn address_accepts_the_longest_label() {
164 address(&"m".repeat(MAX_SUBDOMAIN_LENGTH)).expect("63 characters is a valid label");
165 }
166
167 #[test]
168 fn address_constructed_as_a_record_is_still_validated() {
169 let mut address = SignInAddress {
170 subdomain: "evil.com/x".into(),
171 domain: SignInDomain::Global,
172 };
173
174 assert!(matches!(
175 address.normalize(),
176 Err(OnePasswordError::InvalidSignInAddress(_))
177 ));
178 }
179}