cratefield_core/
venture.rs1use crate::config::ConfigError;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
11pub enum VentureEnv {
12 #[default]
13 Development,
14 Staging,
15 Production,
16}
17
18impl VentureEnv {
19 pub fn as_str(&self) -> &'static str {
20 match self {
21 VentureEnv::Development => "development",
22 VentureEnv::Staging => "staging",
23 VentureEnv::Production => "production",
24 }
25 }
26
27 #[must_use]
39 pub fn strictest(self, other: Self) -> Self {
40 self.max(other)
41 }
42
43 pub fn parse(value: &str) -> Option<Self> {
45 match value.trim().to_ascii_lowercase().as_str() {
46 "development" => Some(VentureEnv::Development),
47 "staging" => Some(VentureEnv::Staging),
48 "production" => Some(VentureEnv::Production),
49 _ => None,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub struct Brand {
58 pub accent: String,
60 pub logo_url: Option<String>,
62 pub footer: Option<String>,
64}
65
66impl Default for Brand {
67 fn default() -> Self {
68 Self {
69 accent: "#FF5A36".to_owned(),
70 logo_url: None,
71 footer: None,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
88pub struct Venture {
89 pub name: String,
91 pub domain: String,
93 pub public_url: String,
95 pub cors_origins: Vec<String>,
97 pub env: VentureEnv,
99 pub brand: Brand,
101 pub problem_base: Option<String>,
104}
105
106impl Venture {
107 pub fn new(name: impl Into<String>, domain: impl Into<String>) -> Self {
108 let domain = domain.into();
109 Self {
110 name: name.into(),
111 public_url: format!("https://{domain}"),
112 domain,
113 cors_origins: Vec::new(),
114 env: VentureEnv::default(),
115 brand: Brand::default(),
116 problem_base: None,
117 }
118 }
119
120 #[must_use]
121 pub fn public_url(mut self, url: impl Into<String>) -> Self {
122 self.public_url = url.into();
123 self
124 }
125
126 #[must_use]
131 pub fn problem_base(mut self, url: impl Into<String>) -> Self {
132 self.problem_base = Some(url.into());
133 self
134 }
135
136 #[must_use]
166 pub fn problem_type_base(&self) -> String {
167 if let Some(base) = self
168 .problem_base
169 .as_deref()
170 .map(str::trim)
171 .filter(|base| !base.is_empty())
172 {
173 if base == crate::problem::ABOUT_BLANK {
174 return base.to_owned();
175 }
176 return format!("{}/", base.trim_end_matches('/'));
178 }
179 let public = self.public_url.trim();
180 if public.is_empty() {
181 return crate::problem::ABOUT_BLANK.to_owned();
182 }
183 format!("{}/problems/", public.trim_end_matches('/'))
184 }
185
186 #[must_use]
187 pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
188 self.cors_origins = origins.into_iter().map(Into::into).collect();
189 self
190 }
191
192 #[must_use]
193 pub fn env(mut self, env: VentureEnv) -> Self {
194 self.env = env;
195 self
196 }
197
198 #[must_use]
200 pub fn brand(mut self, brand: Brand) -> Self {
201 self.brand = brand;
202 self
203 }
204
205 pub(crate) fn validate(&self, errors: &mut ConfigError) {
208 if self.name.is_empty() {
209 errors.push("venture: name must not be empty");
210 } else if !is_kebab_case(&self.name) {
211 errors.push(format!(
212 "venture: name `{}` must be kebab-case ([a-z0-9]+ separated by '-')",
213 self.name
214 ));
215 }
216
217 if self.domain.trim().is_empty() {
218 errors.push("venture: domain must not be empty");
219 }
220
221 if self.cors_origins.is_empty() {
222 errors.push(format!(
223 "venture `{}`: at least one CORS origin is required",
224 self.name
225 ));
226 } else {
227 for origin in &self.cors_origins {
228 if origin == "*" {
229 errors.push(format!(
230 "venture `{}`: wildcard CORS origin `*` is not allowed",
231 self.name
232 ));
233 } else if !is_valid_origin(origin) {
234 errors.push(format!(
235 "venture `{}`: CORS origin `{origin}` must be scheme://host[:port] \
236 or a known browser-extension origin",
237 self.name
238 ));
239 }
240 }
241 }
242 }
243}
244
245fn is_kebab_case(name: &str) -> bool {
246 !name.is_empty()
247 && name.split('-').all(|part| {
248 !part.is_empty()
249 && part
250 .chars()
251 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
252 })
253}
254
255fn is_valid_origin(origin: &str) -> bool {
256 let Some((scheme, rest)) = origin.split_once("://") else {
258 return false;
259 };
260 if scheme.is_empty() {
261 return false;
262 }
263 match scheme {
271 "chrome-extension" => return is_chrome_extension_id(rest),
272 "moz-extension" => return is_extension_uuid(rest, false),
274 "safari-web-extension" => return is_extension_uuid(rest, true),
278 _ => {}
279 }
280 if !scheme.chars().all(|c| c.is_ascii_alphanumeric()) {
281 return false;
282 }
283 if rest.contains(['/', '?', '#']) {
284 return false;
285 }
286 let hostport = rest;
287 let host = hostport.split_once(':').map_or(hostport, |(h, _)| h);
288 !host.is_empty()
289 && host
290 .chars()
291 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':')
292}
293
294fn is_chrome_extension_id(id: &str) -> bool {
297 id.len() == 32 && id.chars().all(|c| ('a'..='p').contains(&c))
298}
299
300fn is_extension_uuid(id: &str, any_case: bool) -> bool {
303 let bytes = id.as_bytes();
304 if bytes.len() != 36 {
305 return false;
306 }
307 bytes.iter().enumerate().all(|(i, &b)| match i {
308 8 | 13 | 18 | 23 => b == b'-',
309 _ => b.is_ascii_hexdigit() && (any_case || !b.is_ascii_uppercase()),
310 })
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn errors(v: &Venture) -> Vec<String> {
318 let mut errs = ConfigError::default();
319 v.validate(&mut errs);
320 errs.problems
321 }
322
323 #[test]
324 fn valid_venture_has_no_errors() {
325 let v = Venture::new("factory0", "factory0.ventures")
326 .cors_origins(["https://factory0.ventures"]);
327 assert!(errors(&v).is_empty());
328 }
329
330 #[test]
331 fn name_must_be_kebab_case() {
332 let v = Venture::new("Factory0", "factory0.ventures")
333 .cors_origins(["https://factory0.ventures"]);
334 assert!(errors(&v).iter().any(|e| e.contains("kebab-case")));
335 }
336
337 #[test]
338 fn domain_must_be_non_empty() {
339 let v = Venture::new("factory0", " ").cors_origins(["https://x.dev"]);
340 assert!(errors(&v).iter().any(|e| e.contains("domain")));
341 }
342
343 #[test]
344 fn at_least_one_cors_origin() {
345 let v = Venture::new("factory0", "factory0.ventures");
346 assert!(errors(&v).iter().any(|e| e.contains("CORS origin")));
347 }
348
349 #[test]
350 fn wildcard_origin_rejected() {
351 let v = Venture::new("factory0", "factory0.ventures").cors_origins(["*"]);
352 assert!(errors(&v).iter().any(|e| e.contains("wildcard")));
353 }
354
355 fn origin_is_valid(origin: &str) -> bool {
358 errors(&Venture::new("factory0", "factory0.ventures").cors_origins([origin])).is_empty()
359 }
360
361 #[test]
362 fn browser_extension_origins_are_accepted() {
363 assert!(origin_is_valid(
365 "chrome-extension://abcdefghijklmnopabcdefghijklmnop"
366 ));
367 assert!(origin_is_valid(
369 "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5f"
370 ));
371 assert!(origin_is_valid(
373 "safari-web-extension://5DE6E0F6-2B1A-4F6E-9C3D-0A1B2C3D4E5F"
374 ));
375 assert!(origin_is_valid(
376 "safari-web-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5f"
377 ));
378 }
379
380 #[test]
381 fn malformed_chrome_extension_ids_are_rejected() {
382 assert!(!origin_is_valid(
384 "chrome-extension://abcdefghijklmnopabcdefghijklmno"
385 ));
386 assert!(!origin_is_valid(
387 "chrome-extension://abcdefghijklmnopabcdefghijklmnopq"
388 ));
389 assert!(!origin_is_valid(
391 "chrome-extension://qbcdefghijklmnopabcdefghijklmnop"
392 ));
393 assert!(!origin_is_valid(
394 "chrome-extension://0bcdefghijklmnopabcdefghijklmnop"
395 ));
396 assert!(!origin_is_valid(
397 "chrome-extension://Abcdefghijklmnopabcdefghijklmnop"
398 ));
399 assert!(!origin_is_valid("chrome-extension://"));
401 assert!(!origin_is_valid(
402 "chrome-extension://abcdefghijklmnopabcdefghijklmnop:80"
403 ));
404 assert!(!origin_is_valid(
405 "chrome-extension://abcdefghijklmnopabcdefghijklmnop/x"
406 ));
407 assert!(!origin_is_valid(
408 "chrome-extension://abcdefghijklmnopabcdefghijklmnop/"
409 ));
410 }
411
412 #[test]
413 fn malformed_extension_uuids_are_rejected() {
414 assert!(!origin_is_valid(
416 "moz-extension://5DE6E0F6-2B1A-4F6E-9C3D-0A1B2C3D4E5F"
417 ));
418 assert!(!origin_is_valid(
420 "moz-extension://5de6e0f62b1a4f6e9c3d0a1b2c3d4e5f"
421 ));
422 assert!(!origin_is_valid(
424 "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5"
425 ));
426 assert!(!origin_is_valid(
428 "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5g"
429 ));
430 assert!(!origin_is_valid("moz-extension://"));
432 assert!(!origin_is_valid("safari-web-extension://"));
433 assert!(!origin_is_valid(
434 "safari-web-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5f/x"
435 ));
436 assert!(!origin_is_valid(
437 "safari-web-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5f/"
438 ));
439 }
440
441 #[test]
442 fn other_hyphenated_schemes_and_nulls_are_rejected() {
443 assert!(!origin_is_valid("foo-bar://x"));
445 assert!(!origin_is_valid(
446 "chrome-extensions://abcdefghijklmnopabcdefghijklmnop"
447 ));
448 assert!(!origin_is_valid("null"));
449 assert!(!origin_is_valid("*"));
450 assert!(origin_is_valid("https://app.example:8443"));
452 assert!(!origin_is_valid("https://app.example/"));
453 }
454}