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}