1use crate::kernel::{parse, Form};
8use ed25519_dalek::{Signature, Verifier, VerifyingKey};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11use std::env;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Tap {
18 pub name: String,
19 pub registry: Vec<String>,
20 pub identity: Vec<String>,
21 pub identity_key: String,
23 pub trust: TrustMode,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TrustMode {
28 SignedRoot,
29 GithubGoverned,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct IdentityPolicy {
34 pub revision: String,
35 pub publisher_keys: BTreeMap<String, PublisherKey>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct PublisherKey {
40 pub public_key: String,
41 pub coordinates: Vec<String>,
42 pub namespace_owners: Vec<String>,
45 pub revoked: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct InitializedTap {
50 pub tap: Tap,
51 pub fingerprint: String,
52}
53
54pub fn config_root() -> PathBuf {
55 if let Some(root) = env::var_os("HARA_CONFIG_HOME") {
56 return PathBuf::from(root);
57 }
58 if let Some(root) = env::var_os("XDG_CONFIG_HOME") {
59 return PathBuf::from(root).join("hara");
60 }
61 env::var_os("HOME")
62 .map(PathBuf::from)
63 .unwrap_or_else(|| PathBuf::from("."))
64 .join(".config/hara")
65}
66
67pub fn add(root: &Path, tap: Tap) -> Result<(), String> {
68 validate_tap(&tap)?;
69 let mut taps = load(root)?;
70 taps.insert(tap.name.clone(), tap);
71 save(root, &taps)
72}
73
74pub fn bootstrap(root: &Path, profile: &str) -> Result<Tap, String> {
78 bootstrap_with_official_root(root, profile, &official_root_fingerprint()?)
79}
80
81pub fn bootstrap_with_official_root(
84 root: &Path,
85 profile: &str,
86 identity_key: &str,
87) -> Result<Tap, String> {
88 validate_sha256_fingerprint(identity_key)?;
89 let tap = match profile {
90 "hara" | "official" => Tap {
91 name: "hara".into(),
92 registry: vec!["https://packages.hara-lang.org".into()],
93 identity: vec!["https://github.com/hara-lang/hara-identity.git".into()],
94 identity_key: identity_key.into(),
95 trust: TrustMode::SignedRoot,
96 },
97 _ => return Err(format!("unknown built-in tap profile: {profile}")),
98 };
99 add(root, tap.clone())?;
100 Ok(tap)
101}
102
103pub fn add_mirror(
104 root: &Path,
105 name: &str,
106 registry: Option<String>,
107 identity: Option<String>,
108) -> Result<Tap, String> {
109 if registry.is_none() && identity.is_none() {
110 return Err("mirror add requires --registry and/or --identity".into());
111 }
112 let mut taps = load(root)?;
113 let tap = taps
114 .get_mut(name)
115 .ok_or_else(|| format!("tap is not trusted: {name}"))?;
116 if let Some(url) = registry {
117 if !tap.registry.contains(&url) {
118 tap.registry.push(url);
119 }
120 }
121 if let Some(url) = identity {
122 if !tap.identity.contains(&url) {
123 tap.identity.push(url);
124 }
125 }
126 let updated = tap.clone();
127 save(root, &taps)?;
128 Ok(updated)
129}
130
131pub fn remove(root: &Path, name: &str) -> Result<(), String> {
132 let mut taps = load(root)?;
133 if taps.remove(name).is_none() {
134 return Err(format!("tap is not trusted: {name}"));
135 }
136 save(root, &taps)
137}
138
139pub fn load(root: &Path) -> Result<BTreeMap<String, Tap>, String> {
140 let path = root.join("taps.edn");
141 if !path.exists() {
142 return Ok(BTreeMap::new());
143 }
144 let source = fs::read_to_string(&path)
145 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
146 let document = parse(&source).map_err(|error| format!("{}: {error}", path.display()))?;
147 let entries = map(&document, "taps.edn must be an EDN map")?;
148 let Some(taps) = lookup(entries, "taps") else {
149 return Ok(BTreeMap::new());
150 };
151 let taps = map(taps, "taps.edn :taps must be an EDN map")?;
152 let mut output = BTreeMap::new();
153 for (key, value) in taps {
154 let name = scalar(key, "tap name")?;
155 let values = map(value, "tap declaration must be an EDN map")?;
156 let tap = Tap {
157 name: name.clone(),
158 registry: strings(required(values, "registry")?, "tap :registry")?,
159 identity: strings(required(values, "identity")?, "tap :identity")?,
160 identity_key: string(required(values, "identity-key")?, "tap :identity-key")?,
161 trust: match lookup(values, "trust") {
162 Some(Form::Keyword(value)) if value == "github-governed" => {
163 TrustMode::GithubGoverned
164 }
165 Some(Form::Keyword(value)) if value == "signed-root" => TrustMode::SignedRoot,
166 Some(_) => return Err("tap :trust must be :signed-root or :github-governed".into()),
167 None => TrustMode::SignedRoot,
168 },
169 };
170 validate_tap(&tap)?;
171 output.insert(name, tap);
172 }
173 Ok(output)
174}
175
176pub fn trusted(root: &Path, name: &str) -> Result<Tap, String> {
177 load(root)?
178 .remove(name)
179 .ok_or_else(|| format!("tap is not trusted: {name}; add it with `hara package tap add`"))
180}
181
182pub fn trusted_or_builtin(root: &Path, name: &str) -> Result<Tap, String> {
183 if matches!(name, "hara" | "official") {
184 return Ok(Tap {
185 name: "hara".into(),
186 registry: vec!["https://packages.hara-lang.org".into()],
187 identity: vec!["https://github.com/hara-lang/hara-identity.git".into()],
188 identity_key: official_root_fingerprint()?,
189 trust: TrustMode::SignedRoot,
190 });
191 }
192 trusted(root, name)
193}
194
195pub fn verify_trusted(root: &Path, name: &str) -> Result<IdentityPolicy, String> {
198 let tap = trusted(root, name)?;
199 let scratch = env::temp_dir().join(format!(
200 "hara-tap-verify-{}-{}",
201 std::process::id(),
202 std::time::SystemTime::now()
203 .duration_since(std::time::UNIX_EPOCH)
204 .map_err(|error| error.to_string())?
205 .as_nanos()
206 ));
207 fs::create_dir_all(&scratch).map_err(io)?;
208 let result = fetch_verified_policy(&tap, &scratch);
209 let _ = fs::remove_dir_all(&scratch);
210 result
211}
212
213pub fn initialize(
218 name: &str,
219 registry: &Path,
220 identity: &Path,
221 root_key: &str,
222) -> Result<InitializedTap, String> {
223 if !valid_name(name) {
224 return Err("tap name must contain only lowercase letters, numbers, and hyphens".into());
225 }
226 let root_key = read_hex(root_key, "identity root public key")?;
227 if root_key.len() != 32 {
228 return Err("identity root public key must be 32-byte Ed25519 hex".into());
229 }
230 empty_directory(registry, "registry")?;
231 empty_directory(identity, "identity")?;
232 let policy = format!(
233 "{{:identity/format \"0.0.0-alpha\"\n :identity/root-key \"{}\"\n :publisher-keys {{}}}}\n",
234 hex(&root_key)
235 );
236 let (_, signature) = sign(policy.as_bytes())?;
237 verify(&root_key, policy.as_bytes(), &signature)?;
238 initialize_signed(name, registry, identity, &root_key, &policy, &signature)
239}
240
241pub fn initialize_signed(
244 name: &str,
245 registry: &Path,
246 identity: &Path,
247 root_key: &[u8],
248 policy: &str,
249 signature: &str,
250) -> Result<InitializedTap, String> {
251 if !valid_name(name) {
252 return Err("tap name must contain only lowercase letters, numbers, and hyphens".into());
253 }
254 if root_key.len() != 32 {
255 return Err("identity root public key must be 32 bytes".into());
256 }
257 verify(root_key, policy.as_bytes(), signature)?;
258 fs::write(identity.join("identity.edn"), policy).map_err(io)?;
259 fs::write(identity.join("identity.edn.sig"), format!("{signature}\n")).map_err(io)?;
260 fs::write(identity.join("README.md"), identity_readme(name)).map_err(io)?;
261 fs::create_dir_all(registry.join("requests")).map_err(io)?;
262 fs::write(registry.join("requests/.gitkeep"), "").map_err(io)?;
263 fs::write(
264 registry.join("registry.edn"),
265 registry_document(name, identity, &root_key),
266 )
267 .map_err(io)?;
268 fs::write(registry.join("README.md"), registry_readme(name)).map_err(io)?;
269 fs::create_dir_all(registry.join(".github/workflows")).map_err(io)?;
270 fs::write(
271 registry.join(".github/workflows/verify-request.yml"),
272 registry_workflow(),
273 )
274 .map_err(io)?;
275 let fingerprint = format!("sha256:{}", sha256_hex(&root_key));
276 let tap = Tap {
277 name: name.into(),
278 registry: vec![registry.to_string_lossy().into_owned()],
279 identity: vec![identity.to_string_lossy().into_owned()],
280 identity_key: fingerprint.clone(),
281 trust: TrustMode::SignedRoot,
282 };
283 Ok(InitializedTap { tap, fingerprint })
284}
285
286pub fn fetch_verified_policy(tap: &Tap, scratch: &Path) -> Result<IdentityPolicy, String> {
289 let checkout = scratch.join("identity");
290 clone_first(&tap.identity, &checkout, "identity")?;
291 let bytes = fs::read(checkout.join("identity.edn"))
292 .map_err(|error| format!("identity policy is missing identity.edn: {error}"))?;
293 let text = std::str::from_utf8(&bytes).map_err(|_| "identity.edn must be UTF-8")?;
294 let document = parse(text).map_err(|error| format!("identity.edn: {error}"))?;
295 let entries = map(&document, "identity.edn must be an EDN map")?;
296 match tap.trust {
297 TrustMode::SignedRoot => {
298 let signature = fs::read_to_string(checkout.join("identity.edn.sig"))
299 .map_err(|error| format!("identity policy is missing identity.edn.sig: {error}"))?;
300 let root_public = read_hex(
301 &string(
302 required(entries, "identity/root-key")?,
303 "identity :identity/root-key",
304 )?,
305 "identity root key",
306 )?;
307 if sha256_hex(&root_public) != tap.identity_key.trim_start_matches("sha256:") {
308 return Err(
309 "identity policy root key does not match the locally pinned tap fingerprint"
310 .into(),
311 );
312 }
313 verify(&root_public, &bytes, signature.trim())?;
314 }
315 TrustMode::GithubGoverned => verify_official_hara_policy(tap, entries)?,
316 }
317 let revision = git(&checkout, ["rev-parse", "HEAD"])?;
318 let keys = lookup(entries, "publisher-keys")
319 .or_else(|| lookup(entries, "keys"))
320 .ok_or("identity policy is missing :publisher-keys or :keys")?;
321 let keys = map(keys, "identity publisher keys must be an EDN map")?;
322 let mut publisher_keys = BTreeMap::new();
323 for (id, value) in keys {
324 let id = scalar(id, "publisher key id")?;
325 let entry = map(value, "publisher key must be an EDN map")?;
326 publisher_keys.insert(
327 id,
328 PublisherKey {
329 public_key: string(required(entry, "public-key")?, "publisher :public-key")?,
330 coordinates: lookup(entry, "coordinates")
331 .map(|value| strings(value, "publisher :coordinates"))
332 .transpose()?
333 .unwrap_or_default(),
334 namespace_owners: lookup(entry, "namespace-owners")
335 .map(|value| strings(value, "publisher :namespace-owners"))
336 .transpose()?
337 .unwrap_or_default(),
338 revoked: matches!(lookup(entry, "revoked"), Some(Form::Bool(true))),
339 },
340 );
341 }
342 Ok(IdentityPolicy {
343 revision,
344 publisher_keys,
345 })
346}
347
348pub fn authorize(
349 policy: &IdentityPolicy,
350 key_id: &str,
351 coordinate: &str,
352 intent: &[u8],
353 signature: &str,
354) -> Result<(), String> {
355 let key = policy
356 .publisher_keys
357 .get(key_id)
358 .ok_or_else(|| format!("identity policy does not authorize publisher key: {key_id}"))?;
359 if key.revoked {
360 return Err(format!("publisher key is revoked: {key_id}"));
361 }
362 if !publisher_scope_matches(key, coordinate) {
363 return Err(format!(
364 "publisher key {key_id} is not authorized for {coordinate}"
365 ));
366 }
367 verify(
368 &read_hex(&key.public_key, "publisher public key")?,
369 intent,
370 signature,
371 )
372}
373
374fn publisher_scope_matches(key: &PublisherKey, coordinate: &str) -> bool {
375 key.coordinates
376 .iter()
377 .any(|candidate| candidate == coordinate)
378 || key
379 .namespace_owners
380 .iter()
381 .any(|owner| !owner.is_empty() && coordinate.starts_with(&format!("hara:{owner}/")))
382}
383
384pub fn sign(intent: &[u8]) -> Result<(String, String), String> {
387 let signer = env::var("HARA_SIGNER")
388 .map_err(|_| "HARA_SIGNER must name an external signer command".to_owned())?;
389 let mut child = Command::new(signer)
390 .stdin(Stdio::piped())
391 .stdout(Stdio::piped())
392 .spawn()
393 .map_err(|error| format!("cannot start HARA_SIGNER: {error}"))?;
394 use std::io::Write;
395 child
396 .stdin
397 .as_mut()
398 .ok_or("cannot open signer stdin")?
399 .write_all(intent)
400 .map_err(|error| format!("cannot write publisher intent to signer: {error}"))?;
401 let output = child
402 .wait_with_output()
403 .map_err(|error| format!("cannot wait for signer: {error}"))?;
404 if !output.status.success() {
405 return Err(format!("external signer failed with {}", output.status));
406 }
407 let response =
408 parse(std::str::from_utf8(&output.stdout).map_err(|_| "signer response must be UTF-8")?)
409 .map_err(|error| format!("signer response: {error}"))?;
410 let response = map(&response, "signer response must be an EDN map")?;
411 Ok((
412 string(required(response, "key/id")?, "signer :key/id")?,
413 string(required(response, "signature")?, "signer :signature")?,
414 ))
415}
416
417pub fn canonical_intent(
418 coordinate: &str,
419 version: &str,
420 repository: &str,
421 tag: &str,
422 commit: &str,
423 archive_sha256: &str,
424 tap: &str,
425 identity_revision: &str,
426) -> String {
427 format!("{{:intent/format \"0.0.0-alpha\" :tap \"{tap}\" :coordinate \"{coordinate}\" :version \"{version}\" :repository \"{repository}\" :tag \"{tag}\" :commit \"{commit}\" :archive-sha256 \"sha256:{archive_sha256}\" :identity-revision \"{identity_revision}\"}}\n")
428}
429
430pub fn canonical_recipe_intent(
431 coordinate: &str,
432 version: &str,
433 repository: &str,
434 tag: &str,
435 commit: &str,
436 project_sha256: &str,
437 recipe_sha256: &str,
438 tap: &str,
439 identity_revision: &str,
440) -> String {
441 format!("{{:intent/format \"0.0.0-alpha\" :tap \"{tap}\" :coordinate \"{coordinate}\" :version \"{version}\" :repository \"{repository}\" :tag \"{tag}\" :commit \"{commit}\" :project-sha256 \"sha256:{project_sha256}\" :recipe-sha256 \"sha256:{recipe_sha256}\" :identity-revision \"{identity_revision}\"}}\n")
442}
443
444pub fn git(
445 root: &Path,
446 arguments: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
447) -> Result<String, String> {
448 let output = Command::new("git")
449 .arg("-C")
450 .arg(root)
451 .args(arguments)
452 .output()
453 .map_err(|error| format!("cannot run git: {error}"))?;
454 if !output.status.success() {
455 return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned());
456 }
457 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
458}
459
460pub fn clone_first(mirrors: &[String], destination: &Path, label: &str) -> Result<(), String> {
461 let mut errors = Vec::new();
462 for mirror in mirrors {
463 if destination.exists() {
464 let _ = fs::remove_dir_all(destination);
465 }
466 let output = Command::new("git")
467 .args(["clone", "--depth", "1", mirror])
468 .arg(destination)
469 .output()
470 .map_err(|error| format!("cannot run git: {error}"))?;
471 if output.status.success() {
472 return Ok(());
473 }
474 errors.push(format!(
475 "{mirror}: {}",
476 String::from_utf8_lossy(&output.stderr).trim()
477 ));
478 }
479 Err(format!(
480 "cannot fetch {label} from any configured mirror: {}",
481 errors.join("; ")
482 ))
483}
484
485fn save(root: &Path, taps: &BTreeMap<String, Tap>) -> Result<(), String> {
486 fs::create_dir_all(root)
487 .map_err(|error| format!("cannot create {}: {error}", root.display()))?;
488 let mut text = String::from("{:tap-store/format \"0.0.0-alpha\"\n :taps {\n");
489 for (name, tap) in taps {
490 let trust = match tap.trust {
491 TrustMode::SignedRoot => "signed-root",
492 TrustMode::GithubGoverned => "github-governed",
493 };
494 text.push_str(&format!(
495 " \"{name}\" {{:registry {} :identity {} :identity-key \"{}\" :trust :{trust}}}\n",
496 vector(&tap.registry),
497 vector(&tap.identity),
498 tap.identity_key
499 ));
500 }
501 text.push_str(" }}\n");
502 fs::write(root.join("taps.edn"), text)
503 .map_err(|error| format!("cannot write tap store: {error}"))
504}
505
506fn empty_directory(path: &Path, label: &str) -> Result<(), String> {
507 if path.exists() {
508 let mut entries = fs::read_dir(path).map_err(io)?;
509 if entries.next().is_some() {
510 return Err(format!(
511 "{label} directory must be empty: {}",
512 path.display()
513 ));
514 }
515 } else {
516 fs::create_dir_all(path).map_err(io)?;
517 }
518 Ok(())
519}
520
521fn identity_readme(name: &str) -> String {
522 format!("# {name} identity policy\n\nThis repository contains public keys and signed policy only. Do not add private keys.\n\n`identity.edn` is signed by the root key declared in the document. Add publisher grants under `:publisher-keys`, then re-sign the exact file through the external identity signer.\n")
523}
524fn registry_document(name: &str, identity: &Path, root_key: &[u8]) -> String {
525 format!("{{:registry/format \"0.0.0-alpha\"\n :tap \"{name}\"\n :identity {{:repository \"{}\" :root-key-sha256 \"sha256:{}\"}}\n :packages {{}}}}\n", identity.display(), sha256_hex(root_key))
526}
527fn registry_readme(name: &str) -> String {
528 format!("# {name} package registry\n\nPublication requests are submitted below `requests/` as a canonical publisher intent plus detached signature. Protect `main` and require CI review. CI must verify the paired identity policy, validate the signed source tag, rebuild the HARP archive, and create its own registry attestation before merging a release record.\n")
529}
530fn registry_workflow() -> &'static str {
531 "name: Verify package request\non:\n pull_request:\n paths: [\"requests/**\"]\npermissions:\n contents: read\njobs:\n verify:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - name: Verify signed request\n run: |\n echo 'Install a pinned hara CLI and invoke your registry verifier here.'\n echo 'Do not expose publishing or signing credentials to this job.'\n exit 1\n"
532}
533
534fn vector(values: &[String]) -> String {
535 format!(
536 "[{}]",
537 values
538 .iter()
539 .map(|value| format!("\"{value}\""))
540 .collect::<Vec<_>>()
541 .join(" ")
542 )
543}
544fn validate_tap(tap: &Tap) -> Result<(), String> {
545 if !valid_name(&tap.name) || tap.registry.is_empty() || tap.identity.is_empty() {
546 return Err(
547 "tap requires a lowercase name plus at least one registry and identity mirror".into(),
548 );
549 }
550 match tap.trust {
551 TrustMode::SignedRoot
552 if read_hex(
553 tap.identity_key.trim_start_matches("sha256:"),
554 "tap identity key fingerprint",
555 )?
556 .len()
557 != 32 =>
558 {
559 Err("tap identity key fingerprint must be SHA-256 hex".into())
560 }
561 TrustMode::GithubGoverned
562 if tap.name != "hara"
563 || !tap
564 .registry
565 .iter()
566 .any(|url| url.contains("github.com/hara-lang/hara-packages"))
567 || !tap
568 .identity
569 .iter()
570 .any(|url| url.contains("github.com/hara-lang/hara-identity")) =>
571 {
572 Err("github-governed trust is reserved for the built-in hara profile".into())
573 }
574 _ => Ok(()),
575 }
576}
577fn verify_official_hara_policy(tap: &Tap, entries: &[(Form, Form)]) -> Result<(), String> {
578 if tap.name != "hara"
579 || !matches!(lookup(entries, "identity/name"), Some(Form::String(value)) if value == "hara")
580 || !matches!(lookup(entries, "identity/trust"), Some(Form::Keyword(value)) if value == "github-governed")
581 {
582 return Err("GitHub-governed trust only accepts the canonical hara identity policy".into());
583 }
584 Ok(())
585}
586fn valid_name(value: &str) -> bool {
587 !value.is_empty()
588 && value
589 .chars()
590 .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-')
591}
592
593fn official_root_fingerprint() -> Result<String, String> {
594 let value = match option_env!("HARA_OFFICIAL_ROOT_SHA256") {
595 Some(value) => value.to_owned(),
596 None => env::var("HARA_OFFICIAL_ROOT_SHA256").map_err(|_| {
597 "official Hara tap root fingerprint is not configured; set HARA_OFFICIAL_ROOT_SHA256 when building or running the bootstrap client"
598 })?,
599 };
600 validate_sha256_fingerprint(&value)
601}
602fn validate_sha256_fingerprint(value: &str) -> Result<String, String> {
603 let hex = value.trim_start_matches("sha256:");
604 if read_hex(hex, "official tap root fingerprint")?.len() != 32 {
605 return Err("official tap root fingerprint must be SHA-256 hex".into());
606 }
607 Ok(format!("sha256:{hex}"))
608}
609fn verify(public_key: &[u8], message: &[u8], signature: &str) -> Result<(), String> {
610 let key = VerifyingKey::from_bytes(
611 &public_key
612 .try_into()
613 .map_err(|_| "Ed25519 public key must be 32 bytes")?,
614 )
615 .map_err(|error| format!("invalid Ed25519 public key: {error}"))?;
616 let signature = Signature::from_bytes(
617 &read_hex(signature, "Ed25519 signature")?
618 .try_into()
619 .map_err(|_| "Ed25519 signature must be 64 bytes")?,
620 );
621 key.verify(message, &signature)
622 .map_err(|_| "Ed25519 signature verification failed".into())
623}
624fn read_hex(value: &str, label: &str) -> Result<Vec<u8>, String> {
625 let value = value.trim().trim_start_matches("sha256:");
626 if value.len() % 2 != 0 {
627 return Err(format!("{label} must be hexadecimal"));
628 }
629 (0..value.len())
630 .step_by(2)
631 .map(|index| {
632 u8::from_str_radix(&value[index..index + 2], 16)
633 .map_err(|_| format!("{label} must be hexadecimal"))
634 })
635 .collect()
636}
637fn sha256_hex(bytes: &[u8]) -> String {
638 Sha256::digest(bytes)
639 .iter()
640 .map(|byte| format!("{byte:02x}"))
641 .collect()
642}
643fn hex(bytes: &[u8]) -> String {
644 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
645}
646fn io(error: std::io::Error) -> String {
647 error.to_string()
648}
649fn map<'a>(form: &'a Form, message: &str) -> Result<&'a Vec<(Form, Form)>, String> {
650 if let Form::Map(entries) = form {
651 Ok(entries)
652 } else {
653 Err(message.into())
654 }
655}
656fn lookup<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
657 entries
658 .iter()
659 .find(|(candidate, _)| matches!(candidate, Form::Keyword(value) if value == key))
660 .map(|(_, value)| value)
661}
662fn required<'a>(entries: &'a [(Form, Form)], key: &str) -> Result<&'a Form, String> {
663 lookup(entries, key).ok_or_else(|| format!("missing required key :{key}"))
664}
665fn scalar(form: &Form, label: &str) -> Result<String, String> {
666 match form {
667 Form::String(value) | Form::Symbol(value) => Ok(value.clone()),
668 _ => Err(format!("{label} must be a string or symbol")),
669 }
670}
671fn string(form: &Form, label: &str) -> Result<String, String> {
672 match form {
673 Form::String(value) => Ok(value.clone()),
674 _ => Err(format!("{label} must be a string")),
675 }
676}
677fn strings(form: &Form, label: &str) -> Result<Vec<String>, String> {
678 match form {
679 Form::Vector(values) => values.iter().map(|value| string(value, label)).collect(),
680 _ => Err(format!("{label} must be a vector of strings")),
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use super::{canonical_recipe_intent, publisher_scope_matches, PublisherKey};
687
688 fn key(coordinates: Vec<&str>, namespace_owners: Vec<&str>) -> PublisherKey {
689 PublisherKey {
690 public_key: "00".repeat(32),
691 coordinates: coordinates.into_iter().map(str::to_owned).collect(),
692 namespace_owners: namespace_owners.into_iter().map(str::to_owned).collect(),
693 revoked: false,
694 }
695 }
696
697 #[test]
698 fn publisher_scope_accepts_exact_coordinates_and_complete_owner_segments() {
699 assert!(publisher_scope_matches(
700 &key(vec!["hara:hara-native/smoke-answer"], vec![]),
701 "hara:hara-native/smoke-answer"
702 ));
703 assert!(publisher_scope_matches(
704 &key(vec![], vec!["hoebat"]),
705 "hara:hoebat/widgets"
706 ));
707 assert!(!publisher_scope_matches(
708 &key(vec![], vec!["hoebat"]),
709 "hara:hoebat-tools/widgets"
710 ));
711 }
712
713 #[test]
714 fn recipe_intent_binds_project_and_recipe_bytes_independently() {
715 let intent = canonical_recipe_intent(
716 "hara:hara-native/smoke-answer",
717 "0.1.0",
718 "git@github.com:hara-lang/hara-native.git",
719 "0.1.0",
720 &"a".repeat(40),
721 &"b".repeat(64),
722 &"c".repeat(64),
723 "hara",
724 &"d".repeat(40),
725 );
726 assert!(intent.contains(&format!(":project-sha256 \"sha256:{}\"", "b".repeat(64))));
727 assert!(intent.contains(&format!(":recipe-sha256 \"sha256:{}\"", "c".repeat(64))));
728 }
729}