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