Skip to main content

hara_native/
tap.rs

1//! Federated package tap trust primitives.
2//!
3//! A tap is an independently operated registry/identity pair.  The local tap
4//! store contains only public, out-of-band trust anchors; it never contains a
5//! publisher private key.
6
7use 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    /// SHA-256 fingerprint of the policy-signing Ed25519 public key.
22    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    /// GitHub-owner namespaces granted by the root policy, represented as the
43    /// owner component of `hara:<owner>/<package>` rather than a glob.
44    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
74/// Installs the only built-in bootstrap profile. It intentionally names the
75/// GitHub-governed official Hara repositories; arbitrary taps remain signed
76/// root-key taps added explicitly by the user.
77pub fn bootstrap(root: &Path, profile: &str) -> Result<Tap, String> {
78    bootstrap_with_official_root(root, profile, &official_root_fingerprint()?)
79}
80
81/// Bootstrap entry point for callers that already obtained the official root
82/// fingerprint from an authenticated distribution channel.
83pub 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
195/// Verifies the currently trusted identity policy for a tap without exposing
196/// command-line parsing or temporary-directory policy to the Hara CLI layer.
197pub 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
213/// Creates the two local repositories that make up a new tap.
214///
215/// The caller supplies only public key material.  `HARA_SIGNER` signs the
216/// initial identity policy, so no private key can enter either repository.
217pub 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
241/// Writes an already signed initial policy. This is public for embedders that
242/// use a signer API rather than the `HARA_SIGNER` command protocol.
243pub 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
286/// Fetches identity policy from any configured mirror and verifies the policy
287/// signature against the local, out-of-band fingerprint before reading grants.
288pub 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
384/// The external signer receives canonical intent bytes on stdin and returns
385/// `{:key/id "..." :signature "<hex-ed25519-signature>"}` on stdout.
386pub 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}