Skip to main content

boatramp_core/
project.rs

1//! Project scoping for the store: the wire [`Project`] types (re-exported from
2//! [`boatramp_types::project`]) plus [`ProjectRef`], a borrowing newtype threaded as
3//! the **first argument** of every per-name `DeployStore` method. Using a distinct type
4//! (not a bare `&str`) makes the store-wide scoping change compiler-enforced — you
5//! cannot pass a site name where a project is meant — and lets the compiler enumerate
6//! every call site during the re-key.
7
8pub use boatramp_types::project::*;
9
10/// A borrowed project name scoping a store operation.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct ProjectRef<'a>(&'a str);
13
14impl<'a> ProjectRef<'a> {
15    /// The `default` project — every pre-project resource + the CLI default. The only
16    /// place the literal is written is [`DEFAULT_PROJECT`].
17    pub const DEFAULT: ProjectRef<'static> = ProjectRef(DEFAULT_PROJECT);
18
19    /// Scope to the named project.
20    pub fn new(name: &'a str) -> Self {
21        ProjectRef(name)
22    }
23
24    /// The underlying project name.
25    pub fn as_str(&self) -> &'a str {
26        self.0
27    }
28
29    /// Project-qualify a guest **data-plane** namespace `base` (a handler/function
30    /// binding scope, a SQL identity, a messaging topic, or a blob-watch storage
31    /// prefix): the bare `base` for the reserved `default` project — so a
32    /// pre-project / single-project store keeps byte-identical keys and needs no
33    /// data migration — else `"<project>/<base>"`. Project names are validated to
34    /// carry no `/` ([`validate_resource_name`]), so the single separator is
35    /// unambiguous. This is the tenant boundary for the guest **data** plane
36    /// (kv/blob/sql/messaging/logs), parallel to the `project/<proj>/…` keys the
37    /// control plane already uses.
38    pub fn qualified(&self, base: &str) -> String {
39        if self.0 == DEFAULT_PROJECT {
40            base.to_string()
41        } else {
42            format!("{}/{base}", self.0)
43        }
44    }
45}
46
47impl<'a> From<&'a str> for ProjectRef<'a> {
48    fn from(name: &'a str) -> Self {
49        ProjectRef(name)
50    }
51}
52
53impl std::fmt::Display for ProjectRef<'_> {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(self.0)
56    }
57}
58
59/// A resource name (project / site / function / compute / workload / workflow)
60/// rejected by [`validate_resource_name`].
61#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
62#[error("invalid {kind} name {value:?}: {reason}")]
63pub struct InvalidResourceName {
64    /// What kind of name failed (for the error message), e.g. `"site"`.
65    pub kind: &'static str,
66    /// The offending value.
67    pub value: String,
68    /// Why it was rejected.
69    pub reason: &'static str,
70}
71
72/// Validate a project/site/function/compute/workflow name at the create/write
73/// boundary, so a name can never escape its `project/<proj>/…` key prefix, collide
74/// with the store's fixed sub-key grammar, smuggle a (possibly percent-decoded)
75/// path separator, or break Cedar entity/target construction.
76///
77/// Rejects: the empty string, `.` / `..`, and any name containing a path separator
78/// (`/` or `\`), a `*` (the authz wildcard sentinel — a resource named `*` would
79/// alias a project/site wildcard), whitespace, or an ASCII control character. This
80/// is a *targeted* denylist of the characters that carry a security or integrity
81/// consequence, not a full slug allowlist, so it does not reject pre-existing
82/// otherwise-ordinary names.
83pub fn validate_resource_name(kind: &'static str, value: &str) -> Result<(), InvalidResourceName> {
84    let reject = |reason| {
85        Err(InvalidResourceName {
86            kind,
87            value: value.to_string(),
88            reason,
89        })
90    };
91    if value.is_empty() {
92        return reject("must not be empty");
93    }
94    if value == "." || value == ".." {
95        return reject("must not be '.' or '..'");
96    }
97    for c in value.chars() {
98        match c {
99            '/' | '\\' => return reject("must not contain a path separator ('/' or '\\')"),
100            '*' => return reject("must not contain '*'"),
101            c if c.is_whitespace() => return reject("must not contain whitespace"),
102            c if c.is_control() => return reject("must not contain control characters"),
103            _ => {}
104        }
105    }
106    Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn project_ref_default_and_wrap() {
115        assert_eq!(ProjectRef::DEFAULT.as_str(), "default");
116        assert_eq!(ProjectRef::new("acme").as_str(), "acme");
117        assert_eq!(ProjectRef::from("shop").to_string(), "shop");
118    }
119
120    #[test]
121    fn resource_name_validation_rejects_the_dangerous_shapes() {
122        for ok in ["blog", "my-site", "resize_v2", "a.b", "Blog9"] {
123            assert!(
124                validate_resource_name("site", ok).is_ok(),
125                "{ok} should pass"
126            );
127        }
128        // Validation runs on the already-percent-decoded value the handler
129        // receives, so the `%2F` → `/` path-param case arrives here as a literal
130        // `/` and is caught by the separator rule.
131        for bad in [
132            "",
133            ".",
134            "..",
135            "a/b",
136            "a\\b",
137            "blog/../evil",
138            "*",
139            "proj*",
140            "a b",
141            "tab\tname",
142            "ctl\u{0}name",
143        ] {
144            assert!(
145                validate_resource_name("site", bad).is_err(),
146                "{bad:?} should be rejected"
147            );
148        }
149    }
150}