ikigai_script/name.rs
1//! Script names and the IRIs built from them.
2//!
3//! A script is named by one segment, `urn:script:{name}`, and everything about it hangs
4//! below that name: `…:version:{digest}`, `…:compiled`, `…:result`, `…:runs` and
5//! `…:run:{id}`. The name is a segment of the IRI rather than an argument so that a
6//! capability can bind to it (`urn:cap:script:run:{name}`), which is the same reason
7//! `ikigai-ledger` names its ledgers.
8//!
9//! ```
10//! use ikigai_script::name;
11//!
12//! assert_eq!(name::script_iri("nightly"), "urn:script:nightly");
13//! assert_eq!(
14//! name::version_iri("nightly", "sha256:ab"),
15//! "urn:script:nightly:version:sha256:ab"
16//! );
17//! assert_eq!(name::run_iri("nightly", 7), "urn:script:nightly:run:7");
18//! assert!(name::validate("nightly-report_2").is_ok());
19//! assert!(name::validate("Nightly").is_err()); // lowercase only
20//! assert!(name::validate("a:b").is_err()); // one segment
21//! assert!(name::validate("eval").is_err()); // reserved: `urn:script:eval` is the paste box
22//! ```
23
24use ikigai_core::{Error, Invocation, Result};
25
26/// Every script IRI starts here.
27pub const PREFIX: &str = "urn:script:";
28
29/// The ad hoc door: run supplied code under the caller's own authority.
30pub const EVAL_IRI: &str = "urn:script:eval";
31
32/// The catalog: what is registered, its state, head version and last run.
33///
34/// ★ Not `urn:host:scripts`. `urn:host:*` is the HOST's namespace (`urn:host:info`,
35/// `urn:host:identity`, `urn:host:arrangement` in ikigai-cli), and a library binding
36/// into it would collide with whatever host mounts it. A host that wants the design's
37/// `urn:host:scripts` aliases it to this name.
38pub const CATALOG_IRI: &str = "urn:script:catalog";
39
40/// Names a script may not take, because a resource already answers there or because
41/// the name means something to the capability grammar.
42///
43/// - `eval` and `catalog` are [`EVAL_IRI`] and [`CATALOG_IRI`].
44/// - `public` is the parameter of [`crate::authority::CAP_RUN_PUBLIC`] and
45/// [`crate::authority::CAP_READ_PUBLIC`]: a script called `public` would have its
46/// per-script grant spelled exactly like the grant a host gives its anonymous
47/// principal.
48/// - `outcome` and `principal` begin IRIs this crate mints and nothing resolves:
49/// [`crate::graph::OUTCOME_OK`] (`urn:script:outcome:ok`) and the principals
50/// [`crate::authority::UNSTAMPED`] and [`crate::authority::ANONYMOUS`]
51/// (`urn:script:principal:…`). Everything below `urn:script:{name}:` is that script's,
52/// so a script called `principal` would own, by the naming rule, an IRI that names
53/// who ran it (ledger #1076).
54///
55/// ⚠ A script already stored under a name reserved later is unreachable, not migrated:
56/// every door refuses its name as `InvalidArgument`, [`crate::DirBackend`] leaves it out of
57/// the catalog, and its files stay where they were. Rename its entries in the backend to
58/// get it back.
59///
60/// ```
61/// use ikigai_script::{authority, graph, name};
62///
63/// // Every IRI this crate mints directly under `urn:script:` starts with a reserved name,
64/// // so no script can be named into one.
65/// for minted in [
66/// name::EVAL_IRI,
67/// name::CATALOG_IRI,
68/// graph::OUTCOME_OK,
69/// graph::OUTCOME_FAILED,
70/// authority::UNSTAMPED,
71/// authority::ANONYMOUS,
72/// ] {
73/// let first = minted[name::PREFIX.len()..].split(':').next().unwrap();
74/// assert!(name::RESERVED.contains(&first), "{minted}");
75/// assert!(name::validate(first).is_err(), "{minted}");
76/// }
77/// ```
78pub const RESERVED: [&str; 5] = ["eval", "catalog", "public", "outcome", "principal"];
79
80/// The longest name.
81pub const MAX_NAME: usize = 64;
82
83/// Check a script name: `[a-z0-9][a-z0-9_-]*`, at most [`MAX_NAME`] bytes, not
84/// [`RESERVED`].
85///
86/// One segment and lowercase so that a name is the same string in an IRI, in a
87/// capability scope, on a file system that folds case, and in a URL path.
88pub fn validate(name: &str) -> Result<()> {
89 let bad = |detail: String| Error::InvalidArgument {
90 name: "name".to_string(),
91 detail,
92 };
93 if name.is_empty() {
94 return Err(bad("a script name cannot be empty".to_string()));
95 }
96 if name.len() > MAX_NAME {
97 return Err(bad(format!(
98 "`{name}` is {} bytes; a script name is at most {MAX_NAME}",
99 name.len()
100 )));
101 }
102 let mut chars = name.chars();
103 let first_ok = chars
104 .next()
105 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
106 let rest_ok =
107 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
108 if !first_ok || !rest_ok {
109 return Err(bad(format!(
110 "`{name}` is not a script name: one segment of lowercase letters, digits, `-` \
111 and `_`, starting with a letter or digit"
112 )));
113 }
114 if RESERVED.contains(&name) {
115 return Err(bad(format!(
116 "`{name}` is reserved ({}); choose another name",
117 RESERVED.join(", ")
118 )));
119 }
120 Ok(())
121}
122
123/// `urn:script:{name}`: the script itself (the head).
124pub fn script_iri(name: &str) -> String {
125 format!("{PREFIX}{name}")
126}
127
128/// `urn:script:{name}:version:{digest}`: one immutable version.
129pub fn version_iri(name: &str, digest: &str) -> String {
130 format!("{PREFIX}{name}:version:{digest}")
131}
132
133/// `urn:script:{name}:run:{id}`: one recorded run.
134pub fn run_iri(name: &str, id: u64) -> String {
135 format!("{PREFIX}{name}:run:{id}")
136}
137
138/// `urn:script:{name}:{part}`: `compiled`, `result` or `runs`.
139pub fn part_iri(name: &str, part: &str) -> String {
140 format!("{PREFIX}{name}:{part}")
141}
142
143/// The golden thread a host cuts when it changes the ceiling it allows `{name}`
144/// (`<config home>/script-authority/{name}`): `urn:script:{name}:authority`.
145///
146/// Every `…:result` hangs from it, because a cached answer computed under the old
147/// ceiling must not outlive the ceiling. The library cannot see the host's file change,
148/// so cutting this thread is the host's half of the contract (a watcher on the config
149/// home, or `sink urn:kernel:cut urn:script:{name}:authority`).
150pub fn authority_thread(name: &str) -> String {
151 format!("{PREFIX}{name}:authority")
152}
153
154/// The `name` a grammar captured, checked. Every templated resource here captures it.
155pub(crate) fn from_bindings(inv: &Invocation<'_>) -> Result<String> {
156 let name = inv.bindings.get("name").ok_or_else(|| {
157 Error::Endpoint(
158 "no `name` captured: this endpoint is bound under `urn:script:{name}` and was \
159 invoked without the grammar's capture"
160 .to_string(),
161 )
162 })?;
163 validate(name)?;
164 Ok(name.to_string())
165}