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