Skip to main content

hara_native/
package.rs

1//! Deterministic local package operations for the `hara package` command.
2//!
3//! Network reconciliation deliberately does not live here yet: package roots
4//! are only activated after a registry and identity client has verified them.
5
6use crate::kernel::{parse, Form};
7use crate::project::{self, Project};
8use crate::tap::{self, Tap};
9use sha2::{Digest, Sha256};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13pub use crate::package_catalog::{catalog_from_lock, LockedPackage};
14
15mod archive;
16mod install;
17use archive::*;
18use install::{install_archive, install_archive_at, json_string, validate_recipe};
19
20const MAX_PUBLICATION_DIAGNOSTIC_BYTES: usize = 4096;
21
22/// Capability adapter used by the Hara-owned CLI policy. These functions
23/// expose package mechanics without parsing command-line arguments or writing
24/// user-facing output.
25pub fn check_path(input: &Path) -> Result<(String, String), String> {
26    let project = read_project(input)?;
27    Ok((project.id, project.version.to_string()))
28}
29
30pub fn build_path(input: &Path, output: Option<&Path>) -> Result<PathBuf, String> {
31    build_path_with_package(input, output, None, None)
32}
33
34/// Builds one semantic package from a project profile. The profile and its
35/// selected name are kept on a cloned project model so a command-line
36/// selection never mutates project.edn on disk.
37pub fn build_path_with_package(
38    input: &Path,
39    output: Option<&Path>,
40    package_name: Option<&str>,
41    profile: Option<&Path>,
42) -> Result<PathBuf, String> {
43    let mut project = read_project(input)?;
44    if let Some(name) = package_name {
45        if name.is_empty() {
46            return Err("package selection requires a non-empty semantic name".into());
47        }
48        project.package_name = Some(name.to_owned());
49    }
50    if let Some(profile) = profile {
51        project.package_profile = Some(project_relative_path(&project, profile)?);
52    }
53    if project.package_name.is_some() && project.package_profile.is_none() {
54        let default = project.root.join("config/packages.edn");
55        if default.is_file() {
56            project.package_profile = Some(PathBuf::from("config/packages.edn"));
57        } else {
58            return Err(
59                "semantic package selection requires --profile PATH or config/packages.edn".into(),
60            );
61        }
62    }
63    let destination = output.map(Path::to_path_buf).unwrap_or_else(|| {
64        let id = project
65            .package_name
66            .as_deref()
67            .filter(|_| project.package_profile.is_some())
68            .unwrap_or(&project.id);
69        project
70            .root
71            .join("target")
72            .join(format!("{}-{}.harp", archive_name(id), project.version))
73    });
74    build_archive(&project, &destination)?;
75    Ok(destination)
76}
77
78fn project_relative_path(project: &Project, path: &Path) -> Result<PathBuf, String> {
79    let root = project.root.canonicalize().map_err(|error| {
80        format!(
81            "cannot resolve project root {}: {error}",
82            project.root.display()
83        )
84    })?;
85    let candidate = if path.is_absolute() {
86        path.to_path_buf()
87    } else {
88        // CLI callers commonly pass a workspace-relative profile
89        // (`core/config/packages.edn`), while project manifests conventionally
90        // use a project-relative profile (`config/packages.edn`). Prefer the
91        // project-relative spelling when both resolve, then accept the
92        // workspace-relative spelling as a convenience.
93        let project_relative = project.root.join(path);
94        if project_relative.exists() {
95            project_relative
96        } else {
97            std::env::current_dir()
98                .map_err(|error| format!("cannot resolve package profile path: {error}"))?
99                .join(path)
100        }
101    };
102    let resolved = candidate.canonicalize().map_err(|error| {
103        format!(
104            "cannot resolve package profile {}: {error}",
105            candidate.display()
106        )
107    })?;
108    match resolved.strip_prefix(&root) {
109        Ok(relative) if !relative.as_os_str().is_empty() => Ok(relative.to_path_buf()),
110        _ => Err("package profile must be inside the project root".to_owned()),
111    }
112}
113
114/// Maps a semantic package name such as code.test to a stable registry
115/// coordinate. A profile name remains the browser-facing selector; the
116/// derived coordinate gives native package stores a unique installation key.
117pub(crate) fn semantic_package_identity(name: &str) -> Result<String, String> {
118    if name.is_empty() {
119        return Err("semantic package name must be non-empty".into());
120    }
121    if name.contains(':') {
122        return project::normalize_coordinate(name);
123    }
124    let package = if name.contains('/') {
125        name.to_owned()
126    } else if let Some((owner, remainder)) = name.split_once('.') {
127        format!("{owner}/{remainder}")
128    } else {
129        format!("hara/{name}")
130    };
131    project::normalize_coordinate(&format!("hara:{package}"))
132}
133
134pub fn inspect_path(archive: &Path) -> Result<String, String> {
135    inspect_archive(archive)
136}
137
138pub fn install_path(input: &Path) -> Result<PathBuf, String> {
139    install_path_at(input, &install::dist_root())
140}
141
142/// Installs a package into an explicit distribution root. Embedders and
143/// tests use this form to keep package state isolated from the user's global
144/// Hara distribution.
145pub fn install_path_at(input: &Path, distribution_root: &Path) -> Result<PathBuf, String> {
146    let archive = if input.is_dir() {
147        build_path(input, None)?
148    } else {
149        input.to_path_buf()
150    };
151    install_archive_at(&archive, distribution_root)
152}
153
154/// Handles the public `hara package` command group.
155pub fn run(args: &[String]) -> Result<(), String> {
156    match args.first().map(String::as_str) {
157        Some("check") => {
158            let root = args
159                .get(1)
160                .map(PathBuf::from)
161                .unwrap_or_else(|| PathBuf::from("."));
162            let project = read_project(&root)?;
163            println!("package check: {} {}", project.id, project.version);
164            Ok(())
165        }
166        Some("build") => {
167            let parsed = parse_build_arguments(&args[1..])?;
168            let root = parsed
169                .path
170                .unwrap_or_else(|| PathBuf::from("."));
171            let output = build_path_with_package(
172                &root,
173                parsed.output.as_deref(),
174                parsed.package.as_deref(),
175                parsed.profile.as_deref(),
176            )?;
177            println!("package build: {}", output.display());
178            Ok(())
179        }
180        Some("inspect") => {
181            let archive = args
182                .get(1)
183                .ok_or_else(|| "hara package inspect requires ARCHIVE.harp".to_owned())?;
184            println!("{}", inspect_archive(Path::new(archive))?);
185            Ok(())
186        }
187        Some("profile") => {
188            let path = args
189                .get(1)
190                .map(PathBuf::from)
191                .unwrap_or_else(|| PathBuf::from("config/packages.edn"));
192            let source = fs::read_to_string(&path)
193                .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
194            let definitions = crate::package_catalog::definitions_from_packages_edn(&source)?;
195            for definition in definitions {
196                let dependencies = if definition.dependencies.is_empty() {
197                    String::new()
198                } else {
199                    format!(" depends on {}", definition.dependencies.join(", "))
200                };
201                println!("{}{}", definition.name, dependencies);
202            }
203            Ok(())
204        }
205        Some("install") => {
206            let input = args
207                .get(1)
208                .map(PathBuf::from)
209                .unwrap_or_else(|| PathBuf::from("."));
210            let archive = if input.is_dir() {
211                let project = read_project(&input)?;
212                let output = project.root.join("target").join(format!(
213                    "{}-{}.harp",
214                    archive_name(&project.id),
215                    project.version
216                ));
217                build_archive(&project, &output)?;
218                output
219            } else {
220                input
221            };
222            let installed = install_archive(&archive)?;
223            println!("package install: {}", installed.display());
224            Ok(())
225        }
226        Some("publish") => publish(&args[1..]),
227        Some("tap") => tap_command(&args[1..]),
228        Some("registry") => registry_command(&args[1..]),
229        Some("sync") | Some("add") | Some("remove") | Some("update") | Some("search")
230        | Some("info") => Err(format!(
231            "hara package {} requires a configured GitHub registry and identity client; local package commands available now: check, build, inspect",
232            args[0]
233        )),
234        Some("--help") | Some("-h") | None => {
235            println!(
236                "hara package <check|build|inspect|profile|sync|add|remove|update|publish|tap|search|info>\n\n\
237                 check [PATH]                 validate project.edn and recipe\n\
238                 build [PATH] [--package NAME] [--profile PATH] [--output PATH] build deterministic .harp\n\
239                 inspect ARCHIVE.harp         print package.edn\n\
240                 profile [PATH]               validate and list semantic packages\n\
241                 install [PATH|ARCHIVE.harp]  install into HARA_DIST_HOME or ~/.hara/dist\n\
242                 tap bootstrap official       install the official profile\n\
243                 tap init NAME --registry PATH --identity PATH --identity-root-key ED25519_HEX\n\
244                 tap add NAME --registry URL --identity URL --identity-key SHA256\n\
245                 tap mirror add NAME [--registry URL] [--identity URL]\n\
246                 tap list|remove NAME|verify NAME\n\
247                 publish [--tap official] [--dry-run] [PATH]"
248            );
249            Ok(())
250        }
251        Some(command) => Err(format!("unknown package command: {command}")),
252    }
253}
254
255#[derive(Default)]
256struct BuildArguments {
257    path: Option<PathBuf>,
258    output: Option<PathBuf>,
259    package: Option<String>,
260    profile: Option<PathBuf>,
261}
262
263fn parse_build_arguments(args: &[String]) -> Result<BuildArguments, String> {
264    let mut parsed = BuildArguments::default();
265    let mut index = 0;
266    while index < args.len() {
267        let argument = &args[index];
268        let (option, inline) = if argument.starts_with("--") {
269            argument
270                .split_once('=')
271                .map_or((argument.as_str(), None), |(option, value)| {
272                    (option, Some(value))
273                })
274        } else {
275            (argument.as_str(), None)
276        };
277        let value = |index: &mut usize, label: &str| -> Result<String, String> {
278            if let Some(value) = inline {
279                if value.is_empty() {
280                    return Err(format!("{label} requires a value"));
281                }
282                return Ok(value.to_owned());
283            }
284            *index += 1;
285            args.get(*index)
286                .filter(|value| !value.starts_with('-'))
287                .cloned()
288                .ok_or_else(|| format!("{label} requires a value"))
289        };
290        match option {
291            "--output" => parsed.output = Some(PathBuf::from(value(&mut index, "--output")?)),
292            "--package" => parsed.package = Some(value(&mut index, "--package")?),
293            "--profile" => parsed.profile = Some(PathBuf::from(value(&mut index, "--profile")?)),
294            value if value.starts_with('-') => {
295                return Err(format!("unknown package build option: {value}"))
296            }
297            value => {
298                if parsed.path.replace(PathBuf::from(value)).is_some() {
299                    return Err("package build accepts at most one project path".into());
300                }
301            }
302        }
303        index += 1;
304    }
305    Ok(parsed)
306}
307
308fn read_project(path: &Path) -> Result<Project, String> {
309    project::read(path)
310}
311
312fn registry_command(args: &[String]) -> Result<(), String> {
313    match args.first().map(String::as_str) {
314        Some("verify-request") => {
315            let request = PathBuf::from(required_option(args, "--request")?);
316            let identity = PathBuf::from(required_option(args, "--identity")?);
317            verify_registry_request_paths(&request, &identity)?;
318            println!("registry request verified: {}", request.display());
319            Ok(())
320        }
321        _ => {
322            Err("usage: hara package registry verify-request --request PATH --identity PATH".into())
323        }
324    }
325}
326
327pub fn verify_registry_request_paths(request: &Path, identity: &Path) -> Result<(), String> {
328    let policy = fs::read_to_string(identity)
329        .map_err(|error| format!("cannot read {}: {error}", identity.display()))?;
330    let Form::Map(policy) = parse(&policy)? else {
331        return Err("identity policy must be an EDN map".into());
332    };
333    let trust = policy
334        .iter()
335        .find(|(key, _)| matches!(key, Form::Keyword(name) if name == "identity/trust"))
336        .map(|(_, value)| value);
337    if !matches!(trust, Some(Form::Keyword(mode)) if mode == "github-governed") {
338        return Err("registry bootstrap verifier requires :identity/trust :github-governed".into());
339    }
340    let intent_path = fs::read_dir(request)
341        .map_err(|error| format!("cannot read {}: {error}", request.display()))?
342        .filter_map(Result::ok)
343        .map(|entry| entry.path())
344        .find(|path| {
345            path.file_name()
346                .and_then(|name| name.to_str())
347                .is_some_and(|name| name.ends_with(".publisher-intent.edn"))
348        })
349        .ok_or("request is missing publisher intent")?;
350    let intent = fs::read_to_string(&intent_path).map_err(io_error)?;
351    let Form::Map(entries) = parse(&intent)? else {
352        return Err("publisher intent must be an EDN map".into());
353    };
354    for key in [
355        "intent/format",
356        "tap",
357        "coordinate",
358        "version",
359        "repository",
360        "tag",
361        "commit",
362        "archive-sha256",
363        "identity-revision",
364    ] {
365        if !entries
366            .iter()
367            .any(|(candidate, _)| matches!(candidate, Form::Keyword(name) if name == key))
368        {
369            return Err(format!("publisher intent is missing :{key}"));
370        }
371    }
372    Ok(())
373}
374
375pub fn tap_command(args: &[String]) -> Result<(), String> {
376    let root = tap::config_root();
377    match args.first().map(String::as_str) {
378        Some("add") => {
379            let name = args
380                .get(1)
381                .ok_or_else(|| "tap add requires NAME".to_owned())?;
382            let registry = option_values(args, "--registry");
383            let identity = option_values(args, "--identity");
384            let identity_key = option_value(args, "--identity-key")?;
385            tap::add(
386                &root,
387                Tap {
388                    name: name.clone(),
389                    registry,
390                    identity,
391                    identity_key,
392                    trust: tap::TrustMode::SignedRoot,
393                },
394            )?;
395            println!("trusted tap {name}");
396            Ok(())
397        }
398        Some("bootstrap") => {
399            let profile = args
400                .get(1)
401                .ok_or_else(|| "tap bootstrap requires PROFILE".to_owned())?;
402            let tap = tap::bootstrap(&root, profile)?;
403            println!("bootstrapped tap {} (GitHub-governed)", tap.name);
404            Ok(())
405        }
406        Some("mirror") if args.get(1).map(String::as_str) == Some("add") => {
407            let name = args
408                .get(2)
409                .ok_or_else(|| "tap mirror add requires NAME".to_owned())?;
410            let tap = tap::add_mirror(
411                &root,
412                name,
413                optional_option(args, "--registry"),
414                optional_option(args, "--identity"),
415            )?;
416            println!(
417                "updated tap {} registry={} identity={}",
418                tap.name,
419                tap.registry.join(","),
420                tap.identity.join(",")
421            );
422            Ok(())
423        }
424        Some("init") => {
425            let name = args
426                .get(1)
427                .ok_or_else(|| "tap init requires NAME".to_owned())?;
428            let registry = PathBuf::from(required_option(args, "--registry")?);
429            let identity = PathBuf::from(required_option(args, "--identity")?);
430            let root_key = required_option(args, "--identity-root-key")?;
431            let initialized = tap::initialize(name, &registry, &identity, &root_key)?;
432            tap::add(&root, initialized.tap)?;
433            println!("initialized tap {name}");
434            println!("identity-root fingerprint: {}", initialized.fingerprint);
435            println!("scaffolded registry: {}", registry.display());
436            println!("scaffolded identity: {}", identity.display());
437            Ok(())
438        }
439        Some("remove") => {
440            let name = args
441                .get(1)
442                .ok_or_else(|| "tap remove requires NAME".to_owned())?;
443            tap::remove(&root, name)?;
444            println!("removed tap {name}");
445            Ok(())
446        }
447        Some("list") => {
448            for tap in tap::load(&root)?.values() {
449                println!(
450                    "{} registry={} identity={}",
451                    tap.name,
452                    tap.registry.join(","),
453                    tap.identity.join(",")
454                );
455            }
456            Ok(())
457        }
458        Some("verify") => {
459            let name = args
460                .get(1)
461                .ok_or_else(|| "tap verify requires NAME".to_owned())?;
462            let tap = tap::trusted(&root, name)?;
463            let scratch = scratch("verify")?;
464            let result = tap::fetch_verified_policy(&tap, &scratch);
465            let _ = fs::remove_dir_all(&scratch);
466            let policy = result?;
467            println!("tap verify: {} identity={}", tap.name, policy.revision);
468            Ok(())
469        }
470        _ => {
471            Err("usage: hara package tap <bootstrap|init|add|mirror add|remove|list|verify>".into())
472        }
473    }
474}
475
476fn publish(args: &[String]) -> Result<(), String> {
477    let tap_name = optional_option(args, "--tap")
478        .map(|name| {
479            if name == "official" {
480                "hara".into()
481            } else {
482                name
483            }
484        })
485        .unwrap_or_else(|| "hara".into());
486    let dry_run = args.iter().any(|arg| arg == "--dry-run");
487    let path = args
488        .iter()
489        .skip(1)
490        .find(|arg| !arg.starts_with('-') && *arg != &tap_name)
491        .map(PathBuf::from)
492        .unwrap_or_else(|| PathBuf::from("."));
493    println!("{}", publish_path(&path, &tap_name, dry_run)?);
494    Ok(())
495}
496
497pub fn publish_path(path: &Path, tap_name: &str, dry_run: bool) -> Result<String, String> {
498    publish_path_with_signer(path, tap_name, dry_run, false, tap::sign)
499}
500
501/// Publish a package through a caller-owned detached-intent signer. Embedders
502/// use this form when the signer is part of the host executable rather than a
503/// child process named by `HARA_SIGNER`.
504pub fn publish_path_with_signer<F>(
505    path: &Path,
506    tap_name: &str,
507    dry_run: bool,
508    skip_signed_tag: bool,
509    signer: F,
510) -> Result<String, String>
511where
512    F: Fn(&[u8]) -> Result<(String, String), String>,
513{
514    publish_path_with_signer_and_identity(path, tap_name, dry_run, skip_signed_tag, signer, None)
515}
516
517/// Integrated hosts pass their public publisher key so a missing policy grant
518/// can enter the browser-backed enrollment flow and successful submissions can
519/// carry an identity authorization.  The legacy external signer path remains
520/// available for embedding hosts that have not adopted that flow yet.
521pub fn publish_path_with_signer_and_identity<F>(
522    path: &Path,
523    tap_name: &str,
524    dry_run: bool,
525    skip_signed_tag: bool,
526    signer: F,
527    publisher_public_key: Option<&str>,
528) -> Result<String, String>
529where
530    F: Fn(&[u8]) -> Result<(String, String), String>,
531{
532    let tap_name = if tap_name == "official" {
533        "hara"
534    } else {
535        tap_name
536    };
537    let project = read_project(path)?;
538    let coordinate = project::normalize_coordinate(&project.id)?;
539    let (coordinate_tap, _) = split_coordinate(&coordinate)?;
540    if coordinate_tap != tap_name {
541        return Err(format!(
542            "project id {} belongs to tap {coordinate_tap}, not {tap_name}",
543            project.id
544        ));
545    }
546    let trusted_tap = tap::trusted_or_builtin(&tap::config_root(), &tap_name)?;
547    let scratch = scratch("publish")?;
548    let result = publish_inner(
549        &project,
550        &trusted_tap,
551        dry_run,
552        skip_signed_tag,
553        &scratch,
554        &signer,
555        publisher_public_key,
556    );
557    let _ = fs::remove_dir_all(&scratch);
558    result
559}
560
561fn publish_inner<F>(
562    project: &Project,
563    trusted_tap: &Tap,
564    dry_run: bool,
565    skip_signed_tag: bool,
566    scratch_root: &Path,
567    signer: &F,
568    publisher_public_key: Option<&str>,
569) -> Result<String, String>
570where
571    F: Fn(&[u8]) -> Result<(String, String), String>,
572{
573    let policy = tap::fetch_verified_policy(trusted_tap, scratch_root)?;
574    let tag = project.release_tag.clone();
575    let (source_reference, commit) = source_release(&project.root, &tag, skip_signed_tag)?;
576    let repository = tap::git(&project.root, ["config", "--get", "remote.origin.url"])?;
577    let recipe = validate_recipe(project)?;
578    build_archive(project, &scratch_root.join("publish.harp"))?;
579    let project_sha256 = file_sha256(&project.root.join("project.edn"))?;
580    let recipe_sha256 = file_sha256(&recipe)?;
581    let coordinate = project::normalize_coordinate(&project.id)?;
582    let intent = tap::canonical_recipe_intent(
583        &coordinate,
584        &project.version.to_string(),
585        &repository,
586        &source_reference,
587        &commit,
588        &project_sha256,
589        &recipe_sha256,
590        &trusted_tap.name,
591        &policy.revision,
592    );
593    let (key_id, signature) = signer(intent.as_bytes())?;
594    if let Err(error) = tap::authorize(&policy, &key_id, &coordinate, intent.as_bytes(), &signature)
595    {
596        if !dry_run {
597            if let Some(public_key) = publisher_public_key {
598                crate::identity_tool::request_publisher_grant_with_signer(
599                    &coordinate,
600                    &intent,
601                    &policy.revision,
602                    public_key,
603                    signer,
604                )?;
605            }
606        }
607        return Err(error);
608    }
609    if dry_run {
610        let status = if skip_signed_tag {
611            "untagged-source publish preflight (remote default head verified)"
612        } else {
613            "publish recipe verified"
614        };
615        return Ok(format!(
616            "{status}: {} {} tap={} recipe=sha256:{}",
617            coordinate, project.version, trusted_tap.name, recipe_sha256,
618        ));
619    }
620    let endpoint = trusted_tap
621        .registry
622        .first()
623        .ok_or("official tap has no publication endpoint")?;
624    let authorization = match publisher_public_key {
625        Some(public_key) => crate::identity_tool::request_publication_authorization_with_signer(
626            &coordinate,
627            &intent,
628            &policy.revision,
629            public_key,
630            signer,
631        )?,
632        None => "null".into(),
633    };
634    let body = format!(
635        "{{\"intent\":{},\"key_id\":\"{}\",\"signature\":\"{}\",\"authorization\":{}}}",
636        json_string(&intent),
637        key_id,
638        signature,
639        authorization,
640    );
641    let output = std::process::Command::new("curl")
642        .args([
643            "--fail-with-body",
644            "--silent",
645            "--show-error",
646            "-H",
647            "content-type: application/json",
648            "--data-binary",
649            &body,
650            &format!("{}/v1/publications", endpoint.trim_end_matches('/')),
651        ])
652        .output()
653        .map_err(|error| format!("cannot start publication client: {error}"))?;
654    if !output.status.success() {
655        return Err(publication_request_error(&output.stderr, &output.stdout));
656    }
657    Ok(format!(
658        "publish requested: {}",
659        String::from_utf8_lossy(&output.stdout).trim()
660    ))
661}
662
663fn publication_request_error(stderr: &[u8], response: &[u8]) -> String {
664    let transport = publication_diagnostic(stderr);
665    let body = publication_diagnostic(response);
666    match (transport.is_empty(), body.is_empty()) {
667        (true, true) => "publication request failed".into(),
668        (false, true) => format!("publication request failed: {transport}"),
669        (true, false) => format!("publication request failed: {body}"),
670        (false, false) => format!("publication request failed: {transport}: {body}"),
671    }
672}
673
674fn publication_diagnostic(bytes: &[u8]) -> String {
675    let length = bytes.len().min(MAX_PUBLICATION_DIAGNOSTIC_BYTES);
676    let text = String::from_utf8_lossy(&bytes[..length]).trim().to_owned();
677    if bytes.len() > length && !text.is_empty() {
678        format!("{text}…")
679    } else {
680        text
681    }
682}
683
684/// Resolves the commit that a publication intent names. Untagged publication
685/// requires a clean checkout whose HEAD is exactly the origin default branch.
686pub fn source_release_commit(
687    root: &Path,
688    tag: &str,
689    skip_signed_tag: bool,
690) -> Result<String, String> {
691    source_release(root, tag, skip_signed_tag).map(|(_, commit)| commit)
692}
693
694fn source_release(
695    root: &Path,
696    tag: &str,
697    skip_signed_tag: bool,
698) -> Result<(String, String), String> {
699    if !skip_signed_tag {
700        tap::git(root, ["tag", "-v", tag])
701            .map_err(|error| format!("publish requires a valid signed tag {tag}: {error}"))?;
702        return Ok((tag.into(), tap::git(root, ["rev-list", "-n", "1", tag])?));
703    }
704
705    let status = tap::git(root, ["status", "--porcelain", "--untracked-files=all"])?;
706    if !status.is_empty() {
707        return Err("publish without a signed tag requires a clean worktree".into());
708    }
709    let commit = tap::git(root, ["rev-parse", "HEAD"])?;
710    let remote_head =
711        tap::git(root, ["ls-remote", "--symref", "origin", "HEAD"]).map_err(|error| {
712            format!("publish without a signed tag cannot resolve origin default branch: {error}")
713        })?;
714    let branch = remote_head
715        .lines()
716        .find_map(|line| {
717            let (reference, name) = line.split_once('\t')?;
718            if name == "HEAD" {
719                reference.strip_prefix("ref: refs/heads/")
720            } else {
721                None
722            }
723        })
724        .ok_or("publish without a signed tag cannot determine origin default branch")?;
725    let remote_ref = format!("refs/heads/{branch}");
726    let remote_commit = tap::git(root, ["ls-remote", "origin", remote_ref.as_str()])
727        .map_err(|error| {
728            format!(
729                "publish without a signed tag cannot read origin default branch {branch}: {error}"
730            )
731        })?
732        .split_whitespace()
733        .next()
734        .ok_or_else(|| {
735            format!("publish without a signed tag cannot read origin default branch {branch}")
736        })?
737        .to_owned();
738    if remote_commit != commit {
739        return Err(format!(
740            "publish without a signed tag requires HEAD {commit} to match origin remote default branch {branch} at {remote_commit}"
741        ));
742    }
743    Ok((format!("untagged:{branch}"), commit))
744}
745
746fn option_value(args: &[String], flag: &str) -> Result<String, String> {
747    let index = args
748        .iter()
749        .position(|arg| arg == flag)
750        .ok_or_else(|| format!("publish requires {flag}"))?;
751    args.get(index + 1)
752        .cloned()
753        .ok_or_else(|| format!("{flag} requires a value"))
754}
755fn required_option(args: &[String], flag: &str) -> Result<String, String> {
756    let index = args
757        .iter()
758        .position(|arg| arg == flag)
759        .ok_or_else(|| format!("tap init requires {flag}"))?;
760    args.get(index + 1)
761        .cloned()
762        .ok_or_else(|| format!("{flag} requires a value"))
763}
764fn option_values(args: &[String], flag: &str) -> Vec<String> {
765    args.iter()
766        .enumerate()
767        .filter(|(_, value)| value.as_str() == flag)
768        .filter_map(|(index, _)| args.get(index + 1).cloned())
769        .collect()
770}
771fn optional_option(args: &[String], flag: &str) -> Option<String> {
772    args.iter()
773        .position(|arg| arg == flag)
774        .and_then(|index| args.get(index + 1).cloned())
775}
776fn split_coordinate(value: &str) -> Result<(&str, &str), String> {
777    let (tap, package) = value
778        .split_once(':')
779        .ok_or_else(|| format!("package coordinate must use TAP:owner/name: {value}"))?;
780    if tap.is_empty() || package.is_empty() || package.contains(':') {
781        return Err(format!("invalid tap-qualified package coordinate: {value}"));
782    }
783    Ok((tap, package))
784}
785fn scratch(label: &str) -> Result<PathBuf, String> {
786    let root = std::env::temp_dir().join(format!("hara-{label}-{}", std::process::id()));
787    if root.exists() {
788        fs::remove_dir_all(&root).map_err(io_error)?;
789    }
790    fs::create_dir_all(&root).map_err(io_error)?;
791    Ok(root)
792}
793fn file_sha256(path: &Path) -> Result<String, String> {
794    Ok(hex(&Sha256::digest(fs::read(path).map_err(io_error)?)))
795}
796
797#[cfg(test)]
798mod tests;