Skip to main content

cratefield_core/
venture.rs

1//! The venture descriptor: who this backend belongs to (issue #2).
2
3use crate::config::ConfigError;
4
5/// Deployment environment. Mirrors the `ENV` config key; `Production`
6/// drives the mandatory-captcha rule (architecture section 11).
7///
8/// Ordered least to most protected, so [`VentureEnv::strictest`] can take
9/// the safer of two answers (issue #143).
10#[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    /// The safer of two answers about one deployment (issue #143).
28    ///
29    /// A venture carries a **compiled** environment — a builder default
30    /// the operator cannot change without a rebuild — while the
31    /// deployment carries an `ENV` binding it can. They disagreed
32    /// silently, and the disagreement always resolved toward the weaker
33    /// answer: `ventures/cratefield-waitlist` ships `ENV = "production"`
34    /// and never calls [`Venture::env`], so every production-only rule
35    /// read `Development` and did not apply to the one venture actually
36    /// in production. Neither source may downgrade the other: if either
37    /// says `Production`, the deployment is production.
38    #[must_use]
39    pub fn strictest(self, other: Self) -> Self {
40        self.max(other)
41    }
42
43    /// Parses the `ENV` config value; anything unknown is `None`.
44    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/// Venture branding for mail templates (issue #12). Defaults are
55/// text-only: factory-zero orange accent, no logo, no footer line.
56#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub struct Brand {
58    /// Accent colour as a `#rrggbb` string (links and rule lines).
59    pub accent: String,
60    /// Optional logo image URL; mails must read fine with images off.
61    pub logo_url: Option<String>,
62    /// Optional footer line (e.g. a company address).
63    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/// Identity and CORS configuration for the venture this harness serves.
77///
78/// ```
79/// use cratefield_core::Venture;
80///
81/// let v = Venture::new("factory0", "factory0.ventures")
82///     .public_url("https://factory0.ventures")
83///     .cors_origins(["https://factory0.ventures"]);
84/// assert_eq!(v.name, "factory0");
85/// assert_eq!(v.problem_type_base(), "https://factory0.ventures/problems/");
86/// ```
87#[derive(Debug, Clone)]
88pub struct Venture {
89    /// Kebab-case venture name (`factory0`).
90    pub name: String,
91    /// Apex domain (`factory0.ventures`); the API serves `api.<domain>`.
92    pub domain: String,
93    /// Absolute public URL the API is linked from.
94    pub public_url: String,
95    /// CORS allowlist. Never `*` (architecture section 6).
96    pub cors_origins: Vec<String>,
97    /// Deployment environment.
98    pub env: VentureEnv,
99    /// Mail branding (issue #12).
100    pub brand: Brand,
101    /// Explicit [`Venture::problem_type_base`] override, for a venture that
102    /// serves its problems from somewhere other than `<public_url>/problems/`.
103    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    /// Overrides the base every problem `type` of this venture is served
127    /// under (RFC 9457): the full base URI, ending in `/`. Unset, the base
128    /// derives from [`Venture::public_url`] — see
129    /// [`Venture::problem_type_base`].
130    #[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    /// The base URI every problem `type` of this venture is served under:
137    /// the [`Venture::problem_base`] override when set, else
138    /// `<public_url>/problems/`, else `about:blank`.
139    ///
140    /// A venture with no public URL has no URI of its own to name, and it
141    /// must never name another venture's domain: RFC 9457 §4.2.1 reserves
142    /// `about:blank` for exactly this, so that is the fallback. Callers
143    /// branch on the slug — the part after the base; under the default base
144    /// everything after `/problems/`, `auth/…` namespaces included — which
145    /// is the stable part
146    /// (docs/ERRORS.md).
147    ///
148    /// ```
149    /// use cratefield_core::Venture;
150    ///
151    /// // The default: under the venture's own public URL.
152    /// let v = Venture::new("acme", "acme.example").public_url("https://acme.example/");
153    /// assert_eq!(v.problem_type_base(), "https://acme.example/problems/");
154    ///
155    /// // An explicit override wins, normalized to one trailing slash.
156    /// let v = v.problem_base("https://errors.acme.example/types/");
157    /// assert_eq!(v.problem_type_base(), "https://errors.acme.example/types/");
158    /// let v = v.problem_base("https://errors.acme.example/types");
159    /// assert_eq!(v.problem_type_base(), "https://errors.acme.example/types/");
160    ///
161    /// // No public URL: the RFC 9457 default, never someone else's domain.
162    /// let v = Venture::new("acme", "acme.example").public_url("  ");
163    /// assert_eq!(v.problem_type_base(), "about:blank");
164    /// ```
165    #[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            // One trailing slash, so the slug is appended, not glued on.
177            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    /// Mail branding for templates (accent, logo, footer).
199    #[must_use]
200    pub fn brand(mut self, brand: Brand) -> Self {
201        self.brand = brand;
202        self
203    }
204
205    /// Appends every rule violation to `errors` (issue #2: collect all
206    /// problems, report them together).
207    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    // scheme://host[:port] with no path, query or fragment.
257    let Some((scheme, rest)) = origin.split_once("://") else {
258        return false;
259    };
260    if scheme.is_empty() {
261        return false;
262    }
263    // A browser extension's origin carries a hyphen in the scheme, so the
264    // alphanumeric-scheme rule below rejects it (issue #579). Admit exactly
265    // three such schemes, as **exact-match origins only**: the id is checked
266    // in full, so there is no wildcard, bare scheme, port, path or trailing
267    // slash — the same shapes an `https` origin may not have. Any other
268    // hyphenated scheme (`foo-bar://…`, `chrome-extensions://…`) still falls
269    // through to the alphanumeric check and is rejected.
270    match scheme {
271        "chrome-extension" => return is_chrome_extension_id(rest),
272        // Firefox emits lowercase ids.
273        "moz-extension" => return is_extension_uuid(rest, false),
274        // Safari emits uppercase ids; either case is accepted for this one
275        // scheme (Firefox's stays lowercase-only, so the two are documented
276        // distinct).
277        "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
294/// A Chrome extension id: exactly 32 characters, each `a`–`p` (digits and
295/// letters above `p` never occur in a real id).
296fn is_chrome_extension_id(id: &str) -> bool {
297    id.len() == 32 && id.chars().all(|c| ('a'..='p').contains(&c))
298}
299
300/// A canonical RFC 4122 UUID, `8-4-4-4-12` hex. `any_case` also admits
301/// uppercase hex, which Safari emits and Firefox does not.
302fn 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    /// A single-origin venture is valid exactly when `is_valid_origin` passes
356    /// (the other fields are always valid here).
357    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        // Chrome: 32 characters, each a-p.
364        assert!(origin_is_valid(
365            "chrome-extension://abcdefghijklmnopabcdefghijklmnop"
366        ));
367        // Firefox emits a lowercase UUID.
368        assert!(origin_is_valid(
369            "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5f"
370        ));
371        // Safari emits uppercase; either case is accepted for this scheme.
372        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        // Short and long by one character.
383        assert!(!origin_is_valid(
384            "chrome-extension://abcdefghijklmnopabcdefghijklmno"
385        ));
386        assert!(!origin_is_valid(
387            "chrome-extension://abcdefghijklmnopabcdefghijklmnopq"
388        ));
389        // 'q' is past `p`; a digit and an uppercase letter are out of range.
390        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        // No bare scheme, port, path or trailing slash.
400        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        // Firefox ids are lowercase-only; the uppercase form is not accepted.
415        assert!(!origin_is_valid(
416            "moz-extension://5DE6E0F6-2B1A-4F6E-9C3D-0A1B2C3D4E5F"
417        ));
418        // No dashes at all.
419        assert!(!origin_is_valid(
420            "moz-extension://5de6e0f62b1a4f6e9c3d0a1b2c3d4e5f"
421        ));
422        // A group one character short.
423        assert!(!origin_is_valid(
424            "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5"
425        ));
426        // Non-hex trailing character.
427        assert!(!origin_is_valid(
428            "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5g"
429        ));
430        // No bare scheme, path or trailing slash.
431        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        // Any other hyphenated scheme, including a near-miss of a known one.
444        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        // `https` rules are unchanged: no path, port stays allowed.
451        assert!(origin_is_valid("https://app.example:8443"));
452        assert!(!origin_is_valid("https://app.example/"));
453    }
454}