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}