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/// Maximum length, in bytes, of a project/site/function/compute/workflow name.
73/// Matches the tightest SQL identifier limit (Postgres `NAMEDATALEN - 1 = 63`)
74/// so a name can be folded into a per-tenant database identifier without forcing
75/// pathological truncation. Longer than any realistic human-chosen name.
76pub const MAX_RESOURCE_NAME_LEN: usize = 63;
77
78/// Validate a project/site/function/compute/workflow name at the create/write
79/// boundary, so a name can never escape its `project/<proj>/…` key prefix, collide
80/// with the store's fixed sub-key grammar, smuggle a (possibly percent-decoded)
81/// path separator, or break Cedar entity/target construction.
82///
83/// Rejects: the empty string, names longer than [`MAX_RESOURCE_NAME_LEN`] bytes,
84/// `.` / `..`, and any name containing a path separator (`/` or `\`), a `*` (the
85/// authz wildcard sentinel — a resource named `*` would alias a project/site
86/// wildcard), whitespace, or an ASCII control character. This is a *targeted*
87/// denylist of the characters that carry a security or integrity consequence,
88/// plus a length bound, not a full slug allowlist, so it does not reject
89/// pre-existing otherwise-ordinary names.
90///
91/// The length bound is defense-in-depth for per-tenant database provisioning: it
92/// stops a caller from forcing pathological truncation when a name is folded into
93/// a SQL identifier (see `boatramp-storage`'s `sanitize_ident`). Injectivity there
94/// no longer depends on it (a wide, always-on digest carries it), but a bound
95/// keeps derived identifiers readable and keys short.
96pub fn validate_resource_name(kind: &'static str, value: &str) -> Result<(), InvalidResourceName> {
97    let reject = |reason| {
98        Err(InvalidResourceName {
99            kind,
100            value: value.to_string(),
101            reason,
102        })
103    };
104    if value.is_empty() {
105        return reject("must not be empty");
106    }
107    if value.len() > MAX_RESOURCE_NAME_LEN {
108        return reject("must not exceed 63 bytes");
109    }
110    if value == "." || value == ".." {
111        return reject("must not be '.' or '..'");
112    }
113    for c in value.chars() {
114        match c {
115            '/' | '\\' => return reject("must not contain a path separator ('/' or '\\')"),
116            '*' => return reject("must not contain '*'"),
117            c if c.is_whitespace() => return reject("must not contain whitespace"),
118            c if c.is_control() => return reject("must not contain control characters"),
119            _ => {}
120        }
121    }
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn project_ref_default_and_wrap() {
131        assert_eq!(ProjectRef::DEFAULT.as_str(), "default");
132        assert_eq!(ProjectRef::new("acme").as_str(), "acme");
133        assert_eq!(ProjectRef::from("shop").to_string(), "shop");
134    }
135
136    #[test]
137    fn resource_name_validation_rejects_the_dangerous_shapes() {
138        for ok in ["blog", "my-site", "resize_v2", "a.b", "Blog9"] {
139            assert!(
140                validate_resource_name("site", ok).is_ok(),
141                "{ok} should pass"
142            );
143        }
144        // Validation runs on the already-percent-decoded value the handler
145        // receives, so the `%2F` → `/` path-param case arrives here as a literal
146        // `/` and is caught by the separator rule.
147        for bad in [
148            "",
149            ".",
150            "..",
151            "a/b",
152            "a\\b",
153            "blog/../evil",
154            "*",
155            "proj*",
156            "a b",
157            "tab\tname",
158            "ctl\u{0}name",
159        ] {
160            assert!(
161                validate_resource_name("site", bad).is_err(),
162                "{bad:?} should be rejected"
163            );
164        }
165    }
166
167    #[test]
168    fn resource_name_length_bound() {
169        // Exactly at the bound passes; one over is rejected.
170        let at = "a".repeat(MAX_RESOURCE_NAME_LEN);
171        let over = "a".repeat(MAX_RESOURCE_NAME_LEN + 1);
172        assert!(
173            validate_resource_name("site", &at).is_ok(),
174            "{}-char name should pass",
175            MAX_RESOURCE_NAME_LEN
176        );
177        assert!(
178            validate_resource_name("site", &over).is_err(),
179            "{}-char name should be rejected",
180            MAX_RESOURCE_NAME_LEN + 1
181        );
182    }
183}