1use super::*;
2
3#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
6pub enum StoreProviderBinding {
7 S3 {
8 endpoint: S3EndpointBinding,
9 region: String,
10 bucket: String,
11 key_prefix: Option<String>,
12 },
13 GoogleDrive {
14 corpus: GoogleDriveCorpus,
15 },
16 Dropbox {
17 namespace_id: String,
18 },
19 OneDrive {
20 drive_id: String,
21 folder_id: String,
22 },
23 CloudKit {
24 container_id: String,
25 environment: CloudKitEnvironment,
26 owner_name: String,
27 zone_name: String,
28 },
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
33pub enum S3EndpointBinding {
34 Aws { partition: String },
35 Custom { origin: String },
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39#[serde(tag = "corpus", rename_all = "snake_case", deny_unknown_fields)]
40pub enum GoogleDriveCorpus {
41 MyDrive { folder_id: String },
42 SharedDrive { drive_id: String, folder_id: String },
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum CloudKitEnvironment {
48 Development,
49 Production,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
54pub enum ProviderPrincipalId {
55 Aws {
56 account_id: String,
57 principal: AwsPrincipal,
58 },
59 CustomS3Credential {
60 access_key_id_hash: ObjectHash,
61 },
62 GoogleDrive {
63 permission_id: String,
64 },
65 Dropbox {
66 account_id: String,
67 },
68 OneDrive {
69 user_id: String,
70 },
71 CloudKitPrivateZoneOwner {
72 record_name: String,
73 },
74 CloudKitSharedZoneParticipant {
75 record_name: String,
76 },
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
81pub enum AwsPrincipal {
82 Root,
83 User { arn: String, user_id: String },
84 Role { role_id: String },
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct ProviderDeviceBinding {
90 pub principal: ProviderPrincipalId,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ResolvedProviderBinding {
96 pub store: StoreProviderBinding,
97 pub device: ProviderDeviceBinding,
98}
99
100impl StoreProviderBinding {
101 pub fn validate(&self) -> Result<(), StorageError> {
102 fn present(label: &str, value: &str) -> Result<(), StorageError> {
103 if value.is_empty() {
104 Err(StorageError::Configuration(format!("{label} is empty")))
105 } else {
106 Ok(())
107 }
108 }
109
110 match self {
111 Self::S3 {
112 endpoint,
113 region,
114 bucket,
115 key_prefix,
116 } => {
117 present("S3 region", region)?;
118 present("S3 bucket", bucket)?;
119 if key_prefix.as_deref().is_some_and(str::is_empty) {
120 return Err(StorageError::Configuration(
121 "S3 key prefix is empty instead of absent".to_string(),
122 ));
123 }
124 match endpoint {
125 S3EndpointBinding::Aws { partition } => present("AWS partition", partition),
126 S3EndpointBinding::Custom { origin } => {
127 let canonical = crate::provider::canonical_custom_s3_origin(origin)?;
128 if canonical != *origin {
129 return Err(StorageError::Configuration(
130 "custom S3 origin is not canonical".to_string(),
131 ));
132 }
133 Ok(())
134 }
135 }
136 }
137 Self::GoogleDrive { corpus } => match corpus {
138 GoogleDriveCorpus::MyDrive { folder_id } => {
139 present("Google Drive folder id", folder_id)
140 }
141 GoogleDriveCorpus::SharedDrive {
142 drive_id,
143 folder_id,
144 } => {
145 present("Google Drive id", drive_id)?;
146 present("Google Drive folder id", folder_id)
147 }
148 },
149 Self::Dropbox { namespace_id } => present("Dropbox namespace id", namespace_id),
150 Self::OneDrive {
151 drive_id,
152 folder_id,
153 } => {
154 present("OneDrive drive id", drive_id)?;
155 present("OneDrive folder id", folder_id)
156 }
157 Self::CloudKit {
158 container_id,
159 owner_name,
160 zone_name,
161 ..
162 } => {
163 present("CloudKit container id", container_id)?;
164 present("CloudKit owner name", owner_name)?;
165 present("CloudKit zone name", zone_name)
166 }
167 }
168 }
169}
170
171impl ProviderDeviceBinding {
172 pub fn validate_for(&self, store: &StoreProviderBinding) -> Result<(), StorageError> {
173 fn present(label: &str, value: &str) -> Result<(), StorageError> {
174 if value.is_empty() {
175 Err(StorageError::Configuration(format!("{label} is empty")))
176 } else {
177 Ok(())
178 }
179 }
180
181 let compatible = matches!(
182 (store, &self.principal),
183 (
184 StoreProviderBinding::S3 {
185 endpoint: S3EndpointBinding::Aws { .. },
186 ..
187 },
188 ProviderPrincipalId::Aws { .. }
189 ) | (
190 StoreProviderBinding::S3 {
191 endpoint: S3EndpointBinding::Custom { .. },
192 ..
193 },
194 ProviderPrincipalId::CustomS3Credential { .. }
195 ) | (
196 StoreProviderBinding::GoogleDrive { .. },
197 ProviderPrincipalId::GoogleDrive { .. }
198 ) | (
199 StoreProviderBinding::Dropbox { .. },
200 ProviderPrincipalId::Dropbox { .. }
201 ) | (
202 StoreProviderBinding::OneDrive { .. },
203 ProviderPrincipalId::OneDrive { .. }
204 ) | (
205 StoreProviderBinding::CloudKit { .. },
206 ProviderPrincipalId::CloudKitPrivateZoneOwner { .. }
207 | ProviderPrincipalId::CloudKitSharedZoneParticipant { .. }
208 )
209 );
210 if !compatible {
211 return Err(StorageError::Configuration(
212 "provider principal is incompatible with the Store provider binding".to_string(),
213 ));
214 }
215 match &self.principal {
216 ProviderPrincipalId::Aws {
217 account_id,
218 principal,
219 } => {
220 if account_id.len() != 12 || !account_id.bytes().all(|byte| byte.is_ascii_digit()) {
221 return Err(StorageError::Configuration(
222 "AWS account id must contain exactly 12 decimal digits".to_string(),
223 ));
224 }
225 match principal {
226 AwsPrincipal::Root => Ok(()),
227 AwsPrincipal::User { arn, user_id } => {
228 present("AWS user id", user_id)?;
229 let fields: Vec<_> = arn.splitn(6, ':').collect();
230 let StoreProviderBinding::S3 {
231 endpoint: S3EndpointBinding::Aws { partition },
232 ..
233 } = store
234 else {
235 return Err(StorageError::Configuration(
236 "AWS user principal is bound to non-AWS S3".to_string(),
237 ));
238 };
239 if fields.len() != 6
240 || fields[0] != "arn"
241 || fields[1] != partition
242 || fields[2] != "iam"
243 || !fields[3].is_empty()
244 || fields[4] != account_id
245 || !fields[5].starts_with("user/")
246 || fields[5].len() == "user/".len()
247 {
248 return Err(StorageError::Configuration(
249 "AWS IAM user ARN is malformed or differs from its Store binding"
250 .to_string(),
251 ));
252 }
253 Ok(())
254 }
255 AwsPrincipal::Role { role_id } => {
256 present("AWS role id", role_id)?;
257 if role_id.contains(':') {
258 return Err(StorageError::Configuration(
259 "AWS role id must be the stable prefix before the session separator"
260 .to_string(),
261 ));
262 }
263 Ok(())
264 }
265 }
266 }
267 ProviderPrincipalId::CustomS3Credential { .. } => Ok(()),
268 ProviderPrincipalId::GoogleDrive { permission_id } => {
269 present("Google Drive permission id", permission_id)
270 }
271 ProviderPrincipalId::Dropbox { account_id } => {
272 present("Dropbox account id", account_id)
273 }
274 ProviderPrincipalId::OneDrive { user_id } => present("OneDrive user id", user_id),
275 ProviderPrincipalId::CloudKitPrivateZoneOwner { record_name } => {
276 present("CloudKit private-zone owner record name", record_name)
277 }
278 ProviderPrincipalId::CloudKitSharedZoneParticipant { record_name } => {
279 present("CloudKit shared-zone participant record name", record_name)
280 }
281 }
282 }
283}
284
285impl ResolvedProviderBinding {
286 pub fn validate(&self) -> Result<(), StorageError> {
287 self.store.validate()?;
288 self.device.validate_for(&self.store)
289 }
290}