From 3378c2ab9e2d24b5198a7baeb237efe38cc789f1 Mon Sep 17 00:00:00 2001 From: eareimu Date: Sun, 21 Jun 2026 20:36:09 +0800 Subject: [PATCH 01/21] fix(release): update gmutils homebrew formula --- .github/workflows/release.yml | 7 +++---- xtask/src/main.rs | 13 +++++++++++++ xtask/src/publish/s3/brew.rs | 3 ++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7568ea..0e4a0b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -621,14 +621,13 @@ jobs: set -euo pipefail tap_dir="$GITHUB_WORKSPACE/homebrew-tap" formula_source="$PWD/target/common/brew/$FORMULA_NAME" - formula_dest="$tap_dir/Formula/$FORMULA_NAME" + formula_dest="$tap_dir/$FORMULA_NAME" test -f "$formula_source" - mkdir -p "$tap_dir/Formula" cp "$formula_source" "$formula_dest" cd "$tap_dir" - if git diff --quiet -- "Formula/$FORMULA_NAME"; then + if [[ -z "$(git status --porcelain -- "$FORMULA_NAME")" ]]; then echo "homebrew tap formula is unchanged" exit 0 fi @@ -637,7 +636,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$branch" - git add "Formula/$FORMULA_NAME" + git add "$FORMULA_NAME" git commit -m "brew: update $FORMULA_NAME" git push origin "$branch" gh pr create \ diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d525d51..ce8b0e0 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -437,6 +437,19 @@ mod tests { ); } + #[test] + fn release_workflow_homebrew_tap_updates_root_formula() { + assert!( + RELEASE_WORKFLOW + .contains("BREW_PUBLIC_BASE_URL: https://download.dhttp.net/brew/gmutils") + ); + assert!(!RELEASE_WORKFLOW.contains("download.genmeta.net")); + assert!(RELEASE_WORKFLOW.contains("formula_dest=\"$tap_dir/$FORMULA_NAME\"")); + assert!(RELEASE_WORKFLOW.contains("git status --porcelain -- \"$FORMULA_NAME\"")); + assert!(RELEASE_WORKFLOW.contains("git add \"$FORMULA_NAME\"")); + assert!(!RELEASE_WORKFLOW.contains("Formula/$FORMULA_NAME")); + } + #[test] fn public_package_manifests_declare_apache_2_license() { let workspace_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/xtask/src/publish/s3/brew.rs b/xtask/src/publish/s3/brew.rs index bfd9b99..4c6d8f2 100644 --- a/xtask/src/publish/s3/brew.rs +++ b/xtask/src/publish/s3/brew.rs @@ -20,7 +20,7 @@ const INSTALL_CONTENT: &str = r##" def install end test do - system "#{bin}/genmeta", "-V" + system "#{bin}/genmeta", "version" end"##; #[derive(Debug, Snafu)] @@ -251,5 +251,6 @@ mod tests { assert!(formula.contains("license \"Apache-2.0\"")); assert!(formula.contains("url \"https://download.example/brew/gmutils/gmutils-0.5.2-aarch64-apple-darwin.tar.gz\"")); assert!(formula.contains("sha256 \"arm-sha\"")); + assert!(formula.contains(r##"system "#{bin}/genmeta", "version""##)); } } From 02e8233160a55d0224d76cdae1576f1ee1eece91 Mon Sep 17 00:00:00 2001 From: eareimu Date: Sun, 21 Jun 2026 23:08:06 +0800 Subject: [PATCH 02/21] feat(release): normalize xtask packaging contract --- .github/workflows/release.yml | 66 +++---- xtask/release.toml | 45 +++++ xtask/src/container.rs | 156 +++++++++++---- xtask/src/deb.rs | 48 ++--- xtask/src/main.rs | 50 ++--- xtask/src/package.rs | 6 +- xtask/src/publish/s3.rs | 229 ++++++++++++--------- xtask/src/publish/s3/brew.rs | 176 +++++++++++------ xtask/src/publish/s3/deb.rs | 4 +- xtask/src/publish/s3/rpm.rs | 4 +- xtask/src/publish/s3/scoop.rs | 49 +++-- xtask/src/release_contract.rs | 360 ++++++++++++++++++++++++++++++++++ xtask/src/rpm.rs | 47 ++--- xtask/src/template.rs | 81 ++++++++ xtask/templates/gmutils.rb.in | 17 ++ 15 files changed, 995 insertions(+), 343 deletions(-) create mode 100644 xtask/release.toml create mode 100644 xtask/src/release_contract.rs create mode 100644 xtask/src/template.rs create mode 100644 xtask/templates/gmutils.rb.in diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e4a0b9..03c89e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ concurrency: env: CARGO_TERM_COLOR: always XTASK_RELEASE_S3_ENDPOINT_URL: ${{ vars.XTASK_RELEASE_S3_ENDPOINT_URL }} + DHTTP_ROOT_CA: ${{ vars.DHTTP_ROOT_CA }} DHTTP_STUN_SERVER: ${{ vars.DHTTP_STUN_SERVER }} DHTTP_H3_DNS_SERVER: ${{ vars.DHTTP_H3_DNS_SERVER }} DHTTP_HTTP_DNS_SERVER: ${{ vars.DHTTP_HTTP_DNS_SERVER }} @@ -89,10 +90,6 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 240 env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - APT_SUITE: genmeta - APT_PREFIX: ppa/genmeta LINUX_TARGETS: x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu armv7-unknown-linux-gnueabihf i686-unknown-linux-gnu defaults: run: @@ -139,6 +136,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -191,7 +189,7 @@ jobs: XTASK_RELEASE_S3_SECRET_ACCESS_KEY: ${{ secrets.XTASK_RELEASE_S3_SECRET_ACCESS_KEY }} XTASK_RELEASE_APT_SIGNING_KEY: ${{ secrets.XTASK_RELEASE_APT_SIGNING_KEY }} XTASK_RELEASE_APT_SIGNING_PASSPHRASE: ${{ secrets.XTASK_RELEASE_APT_SIGNING_PASSPHRASE }} - APT_SIGNING_FINGERPRINT: ${{ steps.apt_key.outputs.fingerprint }} + XTASK_RELEASE_APT_SIGNING_FINGERPRINT: ${{ steps.apt_key.outputs.fingerprint }} run: | set -euo pipefail export XTASK_RELEASE_APT_SIGNING_PASSPHRASE @@ -203,10 +201,7 @@ jobs: if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - deb --prefix "$APT_PREFIX" --suite "$APT_SUITE" --fingerprint "$APT_SIGNING_FINGERPRINT" + "${publish_cmd[@]}" deb - name: Upload deb packages to GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') @@ -247,9 +242,6 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 240 env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - RPM_PREFIX: rpm/gmutils # Fedora 40 RPM repositories no longer provide armhfp/armv7hl metadata. RPM_TARGETS: x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu i686-unknown-linux-gnu defaults: @@ -294,6 +286,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -338,10 +331,7 @@ jobs: if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - rpm --prefix "$RPM_PREFIX" + "${publish_cmd[@]}" rpm - name: Upload rpm packages to GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') @@ -381,11 +371,6 @@ jobs: name: Linux scoop packages and S3 publish runs-on: ubuntu-24.04 timeout-minutes: 240 - env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - SCOOP_PREFIX: scoop/gmutils - SCOOP_PUBLIC_BASE_URL: https://download.dhttp.net/scoop/gmutils defaults: run: shell: bash @@ -436,6 +421,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -473,10 +459,7 @@ jobs: if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - scoop --prefix "$SCOOP_PREFIX" --public-base-url "$SCOOP_PUBLIC_BASE_URL" + "${publish_cmd[@]}" scoop - name: Upload scoop packages to GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') @@ -516,13 +499,6 @@ jobs: name: Homebrew package and S3 publish runs-on: macos-15 timeout-minutes: 120 - env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - BREW_PREFIX: brew/gmutils - BREW_PUBLIC_BASE_URL: https://download.dhttp.net/brew/gmutils - HOMEBREW_TAP_REPOSITORY: genmeta/homebrew-genmeta - HOMEBREW_TAP_BASE_BRANCH: main defaults: run: shell: bash @@ -534,6 +510,20 @@ jobs: path: gmutils persist-credentials: false + - name: Read Homebrew tap destination + id: homebrew_destination + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import tomllib + from pathlib import Path + + destination = tomllib.loads(Path("xtask/release.toml").read_text()) + tap = destination["destination"]["brew"]["tap"] + print(f"repository={tap['repository']}") + print(f"base_branch={tap['base_branch']}") + PY + - name: Validate release configuration env: XTASK_RELEASE_S3_ACCESS_KEY_ID: ${{ secrets.XTASK_RELEASE_S3_ACCESS_KEY_ID }} @@ -544,6 +534,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -565,8 +556,8 @@ jobs: if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') uses: actions/checkout@v6 with: - repository: ${{ env.HOMEBREW_TAP_REPOSITORY }} - ref: ${{ env.HOMEBREW_TAP_BASE_BRANCH }} + repository: ${{ steps.homebrew_destination.outputs.repository }} + ref: ${{ steps.homebrew_destination.outputs.base_branch }} path: homebrew-tap token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} @@ -607,15 +598,14 @@ jobs: if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - brew --prefix "$BREW_PREFIX" --public-base-url "$BREW_PUBLIC_BASE_URL" + "${publish_cmd[@]}" brew - name: Create Homebrew tap pull request if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + HOMEBREW_TAP_REPOSITORY: ${{ steps.homebrew_destination.outputs.repository }} + HOMEBREW_TAP_BASE_BRANCH: ${{ steps.homebrew_destination.outputs.base_branch }} FORMULA_NAME: gmutils.rb run: | set -euo pipefail diff --git a/xtask/release.toml b/xtask/release.toml new file mode 100644 index 0000000..2d875a7 --- /dev/null +++ b/xtask/release.toml @@ -0,0 +1,45 @@ +[cargo] +manifest = "genmeta/Cargo.toml" + +[package] +name = "gmutils" + +[homebrew.template] +path = "xtask/templates/gmutils.rb.in" + +[build.env] +required = [ + "DHTTP_ROOT_CA", + "DHTTP_STUN_SERVER", + "DHTTP_H3_DNS_SERVER", + "DHTTP_HTTP_DNS_SERVER", + "DHTTP_MDNS_SERVICE", + "DHTTP_CERT_SERVER_URL", +] + +[destination.s3] +bucket = "download" +endpoint.env = "XTASK_RELEASE_S3_ENDPOINT_URL" +access_key_id.env = "XTASK_RELEASE_S3_ACCESS_KEY_ID" +secret_access_key.env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY" + +[destination.brew] +prefix = "brew/gmutils" +public_base_url = "https://download.dhttp.net/brew/gmutils" +tap.repository = "genmeta/homebrew-genmeta" +tap.base_branch = "main" +tap.token.env = "HOMEBREW_TAP_GITHUB_TOKEN" + +[destination.deb] +prefix = "ppa/genmeta" +suite = "genmeta" +signing.key.env = "XTASK_RELEASE_APT_SIGNING_KEY" +signing.passphrase.env = "XTASK_RELEASE_APT_SIGNING_PASSPHRASE" +fingerprint.env = "XTASK_RELEASE_APT_SIGNING_FINGERPRINT" + +[destination.rpm] +prefix = "rpm/gmutils" + +[destination.scoop] +prefix = "scoop/gmutils" +public_base_url = "https://download.dhttp.net/scoop/gmutils" diff --git a/xtask/src/container.rs b/xtask/src/container.rs index 10dc0e4..2f40899 100644 --- a/xtask/src/container.rs +++ b/xtask/src/container.rs @@ -22,6 +22,7 @@ pub(crate) const RUSTUP_HOME: &str = "/opt/rustup"; pub(crate) const ZIG_GLIBC_VERSION: &str = "2.28"; pub(crate) const DHTTP_BOOTSTRAP_ROOT_CA_TARGET: &str = "/dhttp-bootstrap/root.crt"; +pub(crate) const SOURCES_ROOT: &str = "/sources"; const DHTTP_ROOT_CA: &str = "DHTTP_ROOT_CA"; const DHTTP_STUN_SERVER: &str = "DHTTP_STUN_SERVER"; @@ -266,15 +267,59 @@ pub(crate) fn cargo_cache_mounts() -> Vec { mounts } -/// Resolved sibling bind-mount: canonical host path + basename used as the -/// container target path (`/{basename}`). +/// Resolved source checkout bind-mount. The CLI still calls non-primary +/// checkouts `--sibling`, but the container layout is source-centric: every +/// checkout, including the primary repo, lives under `/sources/`. #[derive(Clone)] -pub(crate) struct Sibling { +pub(crate) struct SourceCheckout { pub(crate) host: std::path::PathBuf, - pub(crate) basename: String, + pub(crate) name: String, + pub(crate) container: String, } -pub(crate) fn resolve_siblings(paths: &[std::path::PathBuf]) -> Result, Whatever> { +#[derive(Clone)] +pub(crate) struct ContainerSourceLayout { + pub(crate) primary: SourceCheckout, + pub(crate) overrides: Vec, +} + +impl ContainerSourceLayout { + pub(crate) fn all(&self) -> impl Iterator { + std::iter::once(&self.primary).chain(self.overrides.iter()) + } +} + +pub(crate) fn source_layout( + primary_name: &str, + paths: &[std::path::PathBuf], +) -> Result { + let host = std::env::current_dir().whatever_context("failed to get current directory")?; + let primary = SourceCheckout { + host, + name: primary_name.to_string(), + container: format!("{SOURCES_ROOT}/{primary_name}"), + }; + Ok(ContainerSourceLayout { + primary, + overrides: resolve_source_overrides(paths)?, + }) +} + +pub(crate) fn source_mounts(layout: &ContainerSourceLayout) -> Vec { + layout + .all() + .map(|source| Mount { + target: Some(source.container.clone()), + source: Some(source.host.to_string_lossy().into_owned()), + typ: Some(MountType::BIND), + ..Default::default() + }) + .collect() +} + +pub(crate) fn resolve_source_overrides( + paths: &[std::path::PathBuf], +) -> Result, Whatever> { let mut out = Vec::with_capacity(paths.len()); for raw in paths { let host = raw @@ -283,7 +328,7 @@ pub(crate) fn resolve_siblings(paths: &[std::path::PathBuf]) -> Result Result Option { +pub(crate) fn cargo_config_from_siblings(siblings: &[SourceCheckout]) -> Option { let mut config = String::new(); let mut registry_patches = Vec::new(); if has_sibling(siblings, "dhttp") { let packages = [ - ("dhttp", "/dhttp/dhttp"), - ("dhttp-access", "/dhttp/access"), - ("dhttp-home", "/dhttp/home"), - ("dhttp-identity", "/dhttp/identity"), + ("dhttp", "/sources/dhttp/dhttp"), + ("dhttp-access", "/sources/dhttp/access"), + ("dhttp-home", "/sources/dhttp/home"), + ("dhttp-identity", "/sources/dhttp/identity"), ]; registry_patches.extend(packages); push_patch_section( @@ -318,7 +368,7 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "ddns") { - let packages = [("dyns", "/ddns")]; + let packages = [("dyns", "/sources/ddns")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -328,13 +378,13 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "h3x") { - let packages = [("h3x", "/h3x")]; + let packages = [("h3x", "/sources/h3x")]; registry_patches.extend(packages); push_patch_section(&mut config, "https://github.com/genmeta/h3x.git", &packages); } if has_sibling(siblings, "dssh") { - let packages = [("dshell", "/dssh")]; + let packages = [("dshell", "/sources/dssh")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -344,7 +394,7 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "dquic") { - let packages = [("dquic", "/dquic/dquic")]; + let packages = [("dquic", "/sources/dquic/dquic")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -354,7 +404,7 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "rankey") { - let packages = [("rankey", "/rankey")]; + let packages = [("rankey", "/sources/rankey")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -373,8 +423,8 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } } -fn has_sibling(siblings: &[Sibling], basename: &str) -> bool { - siblings.iter().any(|sibling| sibling.basename == basename) +fn has_sibling(siblings: &[SourceCheckout], name: &str) -> bool { + siblings.iter().any(|sibling| sibling.name == name) } fn push_registry_patch_section(config: &mut String, packages: &[(&str, &str)]) { @@ -531,53 +581,81 @@ mod tests { #[test] fn cargo_config_from_siblings_patches_genmeta_git_and_registry_sources() { let siblings = vec![ - Sibling { + SourceCheckout { host: "/host/reimu/dhttp".into(), - basename: "dhttp".to_string(), + name: "dhttp".to_string(), + container: "/sources/dhttp".to_string(), }, - Sibling { + SourceCheckout { host: "/host/reimu/ddns".into(), - basename: "ddns".to_string(), + name: "ddns".to_string(), + container: "/sources/ddns".to_string(), }, - Sibling { + SourceCheckout { host: "/host/reimu/dquic".into(), - basename: "dquic".to_string(), + name: "dquic".to_string(), + container: "/sources/dquic".to_string(), }, - Sibling { + SourceCheckout { host: "/host/reimu/rankey".into(), - basename: "rankey".to_string(), + name: "rankey".to_string(), + container: "/sources/rankey".to_string(), }, ]; let config = cargo_config_from_siblings(&siblings).expect("config should be rendered"); assert!(config.contains("[patch.crates-io]")); - assert!(config.contains("dhttp = { path = \"/dhttp/dhttp\" }")); - assert!(config.contains("dhttp-identity = { path = \"/dhttp/identity\" }")); - assert!(config.contains("dyns = { path = \"/ddns\" }")); - assert!(config.contains("dquic = { path = \"/dquic/dquic\" }")); - assert!(config.contains("rankey = { path = \"/rankey\" }")); + assert!(config.contains("dhttp = { path = \"/sources/dhttp/dhttp\" }")); + assert!(config.contains("dhttp-identity = { path = \"/sources/dhttp/identity\" }")); + assert!(config.contains("dyns = { path = \"/sources/ddns\" }")); + assert!(config.contains("dquic = { path = \"/sources/dquic/dquic\" }")); + assert!(config.contains("rankey = { path = \"/sources/rankey\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/dhttp.git\"]")); - assert!(config.contains("dhttp = { path = \"/dhttp/dhttp\" }")); - assert!(config.contains("dhttp-identity = { path = \"/dhttp/identity\" }")); + assert!(config.contains("dhttp = { path = \"/sources/dhttp/dhttp\" }")); + assert!(config.contains("dhttp-identity = { path = \"/sources/dhttp/identity\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/ddns.git\"]")); - assert!(config.contains("dyns = { path = \"/ddns\" }")); + assert!(config.contains("dyns = { path = \"/sources/ddns\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/dquic.git\"]")); - assert!(config.contains("dquic = { path = \"/dquic/dquic\" }")); + assert!(config.contains("dquic = { path = \"/sources/dquic/dquic\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/rankey.git\"]")); - assert!(config.contains("rankey = { path = \"/rankey\" }")); + assert!(config.contains("rankey = { path = \"/sources/rankey\" }")); assert!(!config.contains("ddns =")); } #[test] fn cargo_config_from_siblings_returns_none_without_supported_siblings() { - let siblings = vec![Sibling { + let siblings = vec![SourceCheckout { host: "/host/reimu/other".into(), - basename: "other".to_string(), + name: "other".to_string(), + container: "/sources/other".to_string(), }]; assert!(cargo_config_from_siblings(&siblings).is_none()); } + #[test] + fn source_layout_mounts_primary_and_overrides_under_sources() { + let layout = ContainerSourceLayout { + primary: SourceCheckout { + host: "/host/reimu/gmutils".into(), + name: "gmutils".to_string(), + container: "/sources/gmutils".to_string(), + }, + overrides: vec![SourceCheckout { + host: "/host/reimu/dhttp".into(), + name: "dhttp".to_string(), + container: "/sources/dhttp".to_string(), + }], + }; + + let paths = layout + .all() + .map(|source| source.container.as_str()) + .collect::>(); + + assert_eq!(paths, ["/sources/gmutils", "/sources/dhttp"]); + } + #[test] fn deb_rules_do_not_forward_legacy_root_ca_env() { let rules = include_str!("../deb/rules"); diff --git a/xtask/src/deb.rs b/xtask/src/deb.rs index ccc90ba..5874dd8 100644 --- a/xtask/src/deb.rs +++ b/xtask/src/deb.rs @@ -2,7 +2,7 @@ use bollard::{ Docker, - models::{ContainerConfig, ContainerCreateBody, HostConfig, Mount, MountType}, + models::{ContainerConfig, ContainerCreateBody, HostConfig}, query_parameters::{ CommitContainerOptionsBuilder, CreateContainerOptionsBuilder, CreateImageOptionsBuilder, }, @@ -14,10 +14,10 @@ use tracing::{Instrument, info, info_span}; use crate::{ BuildProfile, DebTarget, container::{ - CARGO_HOME, RUSTUP_HOME, Sibling, ZIG_GLIBC_VERSION, cargo_cache_mounts, + CARGO_HOME, ContainerSourceLayout, RUSTUP_HOME, ZIG_GLIBC_VERSION, cargo_cache_mounts, cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_env, exec_in_container, force_remove_container, host_uid_gid, install_cargo_config, remove_container_if_exists, - resolve_siblings, start_container, + source_layout, source_mounts, start_container, }, package_version, target_dir, }; @@ -229,7 +229,7 @@ pub async fn run( // Resolve sibling paths up front so every target build sees the same set // and path errors surface before we spin up containers. - let siblings = resolve_siblings(siblings)?; + let layout = source_layout("gmutils", siblings)?; let version = package_version(CARGO_NAME)?; let target_dir = target_dir()?; @@ -240,7 +240,7 @@ pub async fn run( let docker = docker.clone(); let version = version.clone(); let target_dir = target_dir.clone(); - let siblings = siblings.clone(); + let layout = layout.clone(); let triple = target.triple(); info!( triple, @@ -250,8 +250,7 @@ pub async fn run( let span = info_span!("deb", triple); tasks.spawn( async move { - build_one_with_retry(&docker, triple, &version, &target_dir, profile, &siblings) - .await + build_one_with_retry(&docker, triple, &version, &target_dir, profile, &layout).await } .instrument(span), ); @@ -275,10 +274,10 @@ async fn build_one_with_retry( version: &str, target_dir: &std::path::Path, profile: BuildProfile, - siblings: &[Sibling], + layout: &ContainerSourceLayout, ) -> Result { for attempt in 1..=BUILD_ATTEMPTS { - match build_one(docker, triple, version, target_dir, profile, siblings).await { + match build_one(docker, triple, version, target_dir, profile, layout).await { Ok(artifact) => return Ok(artifact), Err(error) if attempt < BUILD_ATTEMPTS => { let report = Report::from_error(&error); @@ -302,7 +301,7 @@ async fn build_one( version: &str, target_dir: &std::path::Path, profile: BuildProfile, - siblings: &[Sibling], + layout: &ContainerSourceLayout, ) -> Result { let arch = deb_arch(triple)?; let gnu = gnu_arch(triple)?; @@ -316,31 +315,12 @@ async fn build_one( .await .whatever_context(format!("failed to create {}", out_dir.display()))?; - let workspace_dir = - std::env::current_dir().whatever_context("failed to get current directory")?; - - let mut mounts = vec![Mount { - target: Some("/workspace".into()), - source: Some(workspace_dir.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }]; - // User-requested sibling crates, bind-mounted at /{basename} so that - // `path = "../{basename}"` references in Cargo.toml resolve inside the - // container. - for sibling in siblings { - mounts.push(Mount { - target: Some(format!("/{}", sibling.basename)), - source: Some(sibling.host.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }); - } + let mut mounts = source_mounts(layout); mounts.extend(cargo_cache_mounts()); let bootstrap = dhttp_bootstrap_from_env()?; mounts.extend(bootstrap.mounts); - let cargo_config = cargo_config_from_siblings(siblings); + let cargo_config = cargo_config_from_siblings(&layout.overrides); let container_name = format!("{CARGO_NAME}-xtask-deb-{triple}"); info!(triple, container = %container_name, "creating build container"); @@ -376,6 +356,7 @@ async fn build_one( arch, gnu, profile, + &layout.primary.container, &bootstrap.exports, cargo_config.as_deref(), ) @@ -399,6 +380,7 @@ async fn build_one_inner( arch: &str, gnu: &str, profile: BuildProfile, + primary_source: &str, dhttp_bootstrap_exports: &str, cargo_config: Option<&str>, ) -> Result<(), Whatever> { @@ -436,9 +418,9 @@ export BUILD_PROFILE={profile_dir} export CARGO_PROFILE_ARGS="{cargo_profile_args}" export DEB_HOST_MULTIARCH={gnu} {dhttp_bootstrap_exports} -SRC=/workspace/target/{triple}/{profile_dir}/deb/src +SRC={primary_source}/target/{triple}/{profile_dir}/deb/src mkdir -p "$SRC/debian" -cp -r /workspace/{DEBIAN_PKG_DIR}/. "$SRC/debian/" +cp -r {primary_source}/{DEBIAN_PKG_DIR}/. "$SRC/debian/" printf '{PACKAGE_NAME} ({version}-1) unstable; urgency=low\n\n * release {version}\n\n -- Genmeta Tech Limited %s\n' \ "$(date -R)" > "$SRC/debian/changelog" cd "$SRC" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ce8b0e0..57f7419 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,8 +4,10 @@ mod deb; mod grouped; mod package; mod publish; +mod release_contract; mod rpm; mod scoop; +mod template; mod version_cmp; use std::{io::IsTerminal, path::PathBuf, process::Stdio}; @@ -343,40 +345,15 @@ mod tests { #[test] fn publish_s3_accepts_grouped_targets() { - let cli = Cli::try_parse_from([ - "xtask", - "publish", - "s3", - "--dry-run", - "--endpoint-url", - "https://example.invalid", - "--bucket", - "download", - "--access-key-id", - "access", - "--secret-access-key", - "secret", - "deb", - "--prefix", - "apt/gmutils", - "--suite", - "stable", - "--fingerprint", - "0123456789ABCDEF0123456789ABCDEF01234567", - "brew", - "--prefix", - "brew/gmutils", - "--public-base-url", - "https://download.example/brew/gmutils", - ]) - .expect("publish command should parse"); + let cli = Cli::try_parse_from(["xtask", "publish", "s3", "--dry-run", "deb", "brew"]) + .expect("publish command should parse"); match cli.command { Command::Publish { command } => match command { publish::PublishCommand::S3 { options, targets } => { assert!(options.dry_run); - assert_eq!(options.bucket, "download"); assert_eq!(targets[0], std::ffi::OsString::from("deb")); + assert_eq!(targets[1], std::ffi::OsString::from("brew")); } }, _ => panic!("expected publish command"), @@ -396,6 +373,17 @@ mod tests { fn release_workflow_publish_commands_are_tag_mode_safe() { assert!(!RELEASE_WORKFLOW.contains("publish_args=()")); assert!(!RELEASE_WORKFLOW.contains("\"${publish_args[@]}\"")); + assert!(RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA: ${{ vars.DHTTP_ROOT_CA }}")); + assert!(!RELEASE_WORKFLOW.contains("keychain/root.crt")); + assert!(!RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA: ${{ github.workspace }}")); + assert!(!RELEASE_WORKFLOW.contains("--endpoint-url")); + assert!(!RELEASE_WORKFLOW.contains("--bucket")); + assert!(!RELEASE_WORKFLOW.contains("--prefix")); + assert!(!RELEASE_WORKFLOW.contains("--public-base-url")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" deb")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" rpm")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" scoop")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" brew")); assert_eq!( RELEASE_WORKFLOW .matches("publish_cmd=(cargo xtask publish s3)") @@ -439,11 +427,9 @@ mod tests { #[test] fn release_workflow_homebrew_tap_updates_root_formula() { - assert!( - RELEASE_WORKFLOW - .contains("BREW_PUBLIC_BASE_URL: https://download.dhttp.net/brew/gmutils") - ); + assert!(RELEASE_WORKFLOW.contains("id: homebrew_destination")); assert!(!RELEASE_WORKFLOW.contains("download.genmeta.net")); + assert!(RELEASE_WORKFLOW.contains("tomllib.loads(Path(\"xtask/release.toml\")")); assert!(RELEASE_WORKFLOW.contains("formula_dest=\"$tap_dir/$FORMULA_NAME\"")); assert!(RELEASE_WORKFLOW.contains("git status --porcelain -- \"$FORMULA_NAME\"")); assert!(RELEASE_WORKFLOW.contains("git add \"$FORMULA_NAME\"")); diff --git a/xtask/src/package.rs b/xtask/src/package.rs index 4b9276d..55c78ef 100644 --- a/xtask/src/package.rs +++ b/xtask/src/package.rs @@ -10,7 +10,7 @@ use std::ffi::OsString; use clap::{CommandFactory, Parser, Subcommand, error::ErrorKind}; #[allow(unused_imports)] pub use manifest::{ArtifactKind, PackageArtifact, PackageManifest}; -use snafu::Whatever; +use snafu::{ResultExt, Whatever}; use crate::{BrewTarget, DebTarget, RpmTarget, ScoopTarget, grouped}; @@ -77,6 +77,10 @@ pub fn parse_package_sections(tokens: &[OsString]) -> Result, } pub async fn run(options: PackageOptions) -> Result<(), Whatever> { + let contract = crate::release_contract::load_release_contract() + .whatever_context("failed to load release contract")?; + crate::release_contract::validate_required_build_env(&contract) + .whatever_context("failed to validate build environment")?; let formats = parse_package_sections(&options.targets).unwrap_or_else(|error| error.exit()); for format in formats { match format { diff --git a/xtask/src/publish/s3.rs b/xtask/src/publish/s3.rs index e6c387c..49e2790 100644 --- a/xtask/src/publish/s3.rs +++ b/xtask/src/publish/s3.rs @@ -21,6 +21,7 @@ use tracing::info; use crate::{ grouped, package::manifest::{ArtifactKind, PackageManifest, validate_manifest}, + release_contract::{self, EnvRef, ReleaseContract}, }; pub mod brew; @@ -32,24 +33,17 @@ pub mod scoop; #[derive(Debug, Clone, Args)] pub struct S3Options { - /// S3 endpoint URL + /// Print the publish plan without uploading #[arg(long)] + pub dry_run: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedS3Options { pub endpoint_url: String, - /// S3 bucket name - #[arg(long)] pub bucket: String, - /// AWS access key id - #[arg(long, env = "XTASK_RELEASE_S3_ACCESS_KEY_ID", hide_env_values = true)] pub access_key_id: String, - /// AWS secret access key - #[arg( - long, - env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY", - hide_env_values = true - )] pub secret_access_key: String, - /// Print the publish plan without uploading - #[arg(long)] pub dry_run: bool, } @@ -94,47 +88,59 @@ struct S3TargetCli { #[derive(Debug, Subcommand)] enum S3TargetFormat { - Brew { - #[arg(long)] - prefix: String, - #[arg(long = "public-base-url")] - public_base_url: String, - }, - Scoop { - #[arg(long)] - prefix: String, - #[arg(long = "public-base-url")] - public_base_url: String, - }, - Deb { - #[arg(long)] - prefix: String, - #[arg(long)] - suite: String, - #[arg(long)] - fingerprint: String, - }, - Rpm { - #[arg(long)] - prefix: String, - }, + Brew, + Scoop, + Deb, + Rpm, } pub async fn run(options: S3Options, targets: Vec) -> Result<(), Whatever> { - let targets = parse_s3_targets(&targets).unwrap_or_else(|error| error.exit()); - let client = client(&options).await?; + let contract = release_contract::load_release_contract() + .whatever_context("failed to load release contract")?; + let resolved_options = resolve_s3_options(&options, &contract)?; + let targets = parse_s3_targets(&targets, &contract).unwrap_or_else(|error| error.exit()); + let client = client(&resolved_options).await?; for target in targets { match target { - S3Target::Brew(target) => brew::run(&options, &client, target).await?, - S3Target::Scoop(target) => scoop::run(&options, &client, target).await?, - S3Target::Deb(target) => deb::run(&options, &client, target).await?, - S3Target::Rpm(target) => rpm::run(&options, &client, target).await?, + S3Target::Brew(target) => brew::run(&resolved_options, &client, target).await?, + S3Target::Scoop(target) => scoop::run(&resolved_options, &client, target).await?, + S3Target::Deb(target) => deb::run(&resolved_options, &client, target).await?, + S3Target::Rpm(target) => rpm::run(&resolved_options, &client, target).await?, } } Ok(()) } -fn parse_s3_targets(tokens: &[OsString]) -> Result, clap::Error> { +fn resolve_s3_options( + options: &S3Options, + contract: &ReleaseContract, +) -> Result { + Ok(ResolvedS3Options { + endpoint_url: read_env_ref(&contract.destination.s3.endpoint)?, + bucket: contract.destination.s3.bucket.clone(), + access_key_id: read_env_ref(&contract.destination.s3.access_key_id)?, + secret_access_key: read_env_ref(&contract.destination.s3.secret_access_key)?, + dry_run: options.dry_run, + }) +} + +pub(crate) fn read_env_ref(ref_name: &EnvRef) -> Result { + let value = std::env::var(&ref_name.env).whatever_context(format!( + "missing required release environment variable {}", + ref_name.env + ))?; + snafu::ensure_whatever!( + !value.is_empty(), + "release environment variable {} must not be empty", + ref_name.env + ); + Ok(value) +} + +fn parse_s3_targets( + tokens: &[OsString], + contract: &ReleaseContract, +) -> Result, clap::Error> { let sections = match grouped::parse_grouped_targets(tokens, &["deb", "rpm", "brew", "scoop"]) { Ok(sections) => sections, Err(error) => return Err(target_error(ErrorKind::ValueValidation, error)), @@ -147,48 +153,87 @@ fn parse_s3_targets(tokens: &[OsString]) -> Result, clap::Error> { } sections .into_iter() - .map(|section| parse_s3_target(§ion.name, section.args)) + .map(|section| parse_s3_target(§ion.name, section.args, contract)) .collect() } -fn parse_s3_target(section_name: &str, args: Vec) -> Result { +fn parse_s3_target( + section_name: &str, + args: Vec, + contract: &ReleaseContract, +) -> Result { let mut argv = vec!["xtask publish s3".into(), section_name.to_owned().into()]; argv.extend(args); S3TargetCli::try_parse_from(argv) - .and_then(|cli| target_format_to_target(section_name, cli.target)) + .and_then(|cli| target_format_to_target(section_name, cli.target, contract)) } fn target_format_to_target( section_name: &str, target: S3TargetFormat, + contract: &ReleaseContract, ) -> Result { match target { - S3TargetFormat::Brew { - prefix, - public_base_url, - } => Ok(S3Target::Brew(BrewPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - public_base_url: parse_public_base_url(section_name, &public_base_url)?, - })), - S3TargetFormat::Scoop { - prefix, - public_base_url, - } => Ok(S3Target::Scoop(ScoopPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - public_base_url: parse_public_base_url(section_name, &public_base_url)?, - })), - S3TargetFormat::Deb { - prefix, - suite, - fingerprint, - } => Ok(S3Target::Deb(DebPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - suite, - fingerprint, - })), - S3TargetFormat::Rpm { prefix } => Ok(S3Target::Rpm(RpmPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - })), + S3TargetFormat::Brew => { + let brew = contract.destination.brew.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.brew is required", + ) + })?; + Ok(S3Target::Brew(BrewPublishTarget { + prefix: parse_prefix(section_name, &brew.prefix)?, + public_base_url: parse_public_base_url(section_name, &brew.public_base_url)?, + })) + } + S3TargetFormat::Scoop => { + let scoop = contract.destination.scoop.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.scoop is required", + ) + })?; + Ok(S3Target::Scoop(ScoopPublishTarget { + prefix: parse_prefix(section_name, &scoop.prefix)?, + public_base_url: parse_public_base_url(section_name, &scoop.public_base_url)?, + })) + } + S3TargetFormat::Deb => { + let deb = contract.destination.deb.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.deb is required", + ) + })?; + let fingerprint_ref = deb.fingerprint.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.deb.fingerprint.env is required", + ) + })?; + let fingerprint = match read_env_ref(fingerprint_ref) { + Ok(fingerprint) => fingerprint, + Err(error) => { + return Err(target_error(ErrorKind::ValueValidation, error.to_string())); + } + }; + Ok(S3Target::Deb(DebPublishTarget { + prefix: parse_prefix(section_name, &deb.prefix)?, + suite: deb.suite.clone(), + fingerprint, + })) + } + S3TargetFormat::Rpm => { + let rpm = contract.destination.rpm.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.rpm is required", + ) + })?; + Ok(S3Target::Rpm(RpmPublishTarget { + prefix: parse_prefix(section_name, &rpm.prefix)?, + })) + } } } @@ -500,7 +545,7 @@ async fn sha256_stream(mut body: ByteStream, key: &str) -> Result Result { +async fn client(options: &ResolvedS3Options) -> Result { let credentials = Credentials::new( options.access_key_id.trim().to_string(), options.secret_access_key.trim().to_string(), @@ -524,22 +569,24 @@ async fn client(options: &S3Options) -> Result { #[cfg(test)] mod tests { use aws_sdk_s3::config::RequestChecksumCalculation; - use clap::error::ErrorKind; - use super::{S3Options, client, parse_s3_targets}; + use super::{ResolvedS3Options, S3Target, client, parse_s3_targets}; + use crate::release_contract::load_release_contract; #[test] - fn brew_requires_public_base_url() { - let targets = ["brew", "--prefix", "brew/gmutils"].map(std::ffi::OsString::from); - let error = parse_s3_targets(&targets).expect_err("missing public url should fail"); - - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - assert!(error.to_string().contains("--public-base-url")); + fn parses_destination_contract_targets_without_per_target_options() { + let contract = load_release_contract().expect("contract should load"); + let targets = ["rpm", "brew", "scoop"].map(std::ffi::OsString::from); + let parsed = parse_s3_targets(&targets, &contract).expect("targets should parse"); + + assert!(matches!(parsed[0], S3Target::Rpm(_))); + assert!(matches!(parsed[1], S3Target::Brew(_))); + assert!(matches!(parsed[2], S3Target::Scoop(_))); } #[tokio::test] async fn s3_client_calculates_request_checksums_only_when_required() { - let options = S3Options { + let options = ResolvedS3Options { endpoint_url: "https://example.invalid".to_string(), bucket: "download".to_string(), access_key_id: "access".to_string(), @@ -556,23 +603,9 @@ mod tests { } #[test] - fn release_workflow_uses_public_r2_download_domain() { + fn release_workflow_uses_xtask_destination_contract() { let workflow = include_str!("../../../.github/workflows/release.yml"); - assert!(workflow.contains("BREW_PUBLIC_BASE_URL: https://download.dhttp.net/brew/gmutils")); - assert!( - workflow.contains("SCOOP_PUBLIC_BASE_URL: https://download.dhttp.net/scoop/gmutils") - ); assert!(!workflow.contains("download.genmeta.net")); } - - #[test] - fn release_workflow_publishes_deb_to_unified_apt_repo() { - let workflow = include_str!("../../../.github/workflows/release.yml"); - - assert!(workflow.contains("APT_PREFIX: ppa/genmeta")); - assert!(workflow.contains("APT_SUITE: genmeta")); - assert!(!workflow.contains("APT_PREFIX: apt/gmutils")); - assert!(!workflow.contains("APT_SUITE: stable")); - } } diff --git a/xtask/src/publish/s3/brew.rs b/xtask/src/publish/s3/brew.rs index 4c6d8f2..1270078 100644 --- a/xtask/src/publish/s3/brew.rs +++ b/xtask/src/publish/s3/brew.rs @@ -1,27 +1,20 @@ +use std::{collections::BTreeMap, path::Path}; + use aws_sdk_s3::Client; -use snafu::{ResultExt, Snafu, Whatever}; +use snafu::{OptionExt, ResultExt, Snafu, Whatever}; use tracing::{info, warn}; use super::{ - BrewPublishTarget, S3Options, + BrewPublishTarget, ResolvedS3Options, key::{PublicBaseUrl, PublicBaseUrlError}, plan::PlannedUpload, }; -use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; +use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::{self, ResolvedPackageMetadata}, +}; -const PACKAGE_NAME: &str = "gmutils"; const FORMULA_NAME: &str = "gmutils.rb"; -const DESCRIPTION: &str = "Genmeta Binary Utilities"; -const HOMEPAGE: &str = "https://www.dhttp.net"; -const LICENSE: &str = "Apache-2.0"; -const INSTALL_CONTENT: &str = r##" def install - bin.install "genmeta" - bin.install "genmeta-ssh.sh" - end - - test do - system "#{bin}/genmeta", "version" - end"##; #[derive(Debug, Snafu)] #[snafu(module)] @@ -34,11 +27,17 @@ pub enum RenderBrewError { UnsupportedTarget { target: String }, #[snafu(display("invalid public base url"))] PublicBaseUrl { source: PublicBaseUrlError }, + #[snafu(display("failed to render brew template"))] + Template { + source: crate::template::RenderTemplateError, + }, } pub fn render_formula( manifest: &PackageManifest, public_base_url: &str, + metadata: &ResolvedPackageMetadata, + template: &str, ) -> Result { snafu::ensure!( manifest.kind == ArtifactKind::Brew, @@ -46,36 +45,34 @@ pub fn render_formula( ); let base = PublicBaseUrl::parse(public_base_url).context(render_brew_error::PublicBaseUrlSnafu)?; - let class_name = formula_class_name(PACKAGE_NAME); - let mut lines = vec![ - format!("class {class_name} < Formula"), - format!(" desc \"{}\"", escape_formula_string(DESCRIPTION)), - format!(" version \"{}\"", escape_formula_string(&manifest.version)), - format!(" homepage \"{}\"", escape_formula_string(HOMEPAGE)), - format!(" license \"{}\"", escape_formula_string(LICENSE)), - String::new(), - ]; - - for artifact in &manifest.artifacts { - let archive_name = archive_name(artifact)?; - let block = brew_on_block(&artifact.target)?; - lines.extend([ - format!(" {block} do"), - format!(" url \"{}\"", base.join(archive_name)), - format!(" sha256 \"{}\"", artifact.sha256), - " end".to_string(), - String::new(), - ]); - } - - lines.push(INSTALL_CONTENT.trim_end().to_string()); - lines.push("end".to_string()); - lines.push(String::new()); - Ok(lines.join("\n")) + let variables = BTreeMap::from([ + ( + "homebrew.class".to_string(), + formula_class_name(&metadata.name), + ), + ("homebrew.urls".to_string(), formula_urls(manifest, &base)?), + ( + "package.description".to_string(), + crate::template::ruby_string(&metadata.description), + ), + ( + "package.homepage".to_string(), + crate::template::ruby_string(&metadata.homepage), + ), + ( + "package.license".to_string(), + crate::template::ruby_string(&metadata.license), + ), + ( + "package.version".to_string(), + crate::template::ruby_string(&metadata.version), + ), + ]); + crate::template::render_template(template, &variables).context(render_brew_error::TemplateSnafu) } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: BrewPublishTarget, ) -> Result<(), Whatever> { @@ -88,8 +85,14 @@ pub async fn run( &target.prefix, ) .await?; - let formula = render_formula(&manifest, target.public_base_url.as_str()) - .whatever_context("failed to render brew formula")?; + let (metadata, template) = metadata_and_template().await?; + let formula = render_formula( + &manifest, + target.public_base_url.as_str(), + &metadata, + &template, + ) + .whatever_context("failed to render brew formula")?; let formula_path = loaded .target_dir .join("common") @@ -135,6 +138,28 @@ pub async fn run( Ok(()) } +async fn metadata_and_template() -> Result<(ResolvedPackageMetadata, String), Whatever> { + let contract = release_contract::load_release_contract() + .whatever_context("failed to load release contract")?; + let metadata = release_contract::resolve_package_metadata(&contract) + .whatever_context("failed to resolve package metadata")?; + let homebrew = contract + .homebrew + .as_ref() + .whatever_context("release contract is missing homebrew template")?; + let template_path = repo_root().join(&homebrew.template.path); + let template = tokio::fs::read_to_string(&template_path) + .await + .whatever_context(format!("failed to read {}", template_path.display()))?; + Ok((metadata, template)) +} + +fn repo_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest directory should have a parent") +} + async fn plan_payload_uploads( client: &Client, bucket: &str, @@ -184,6 +209,23 @@ async fn plan_payload_uploads( Ok((uploads, manifest)) } +fn formula_urls( + manifest: &PackageManifest, + base: &PublicBaseUrl, +) -> Result { + let mut blocks = Vec::new(); + for artifact in &manifest.artifacts { + let archive_name = archive_name(artifact)?; + let block = brew_on_block(&artifact.target)?; + blocks.push(format!( + " {block} do\n url \"{}\"\n sha256 \"{}\"\n end", + base.join(archive_name), + artifact.sha256, + )); + } + Ok(blocks.join("\n\n")) +} + fn archive_name(artifact: &PackageArtifact) -> Result<&str, RenderBrewError> { artifact .archive_name @@ -204,21 +246,30 @@ fn brew_on_block(target: &str) -> Result<&'static str, RenderBrewError> { } fn formula_class_name(name: &str) -> String { - let mut chars = name.chars(); - match chars.next() { - Some(first) => first.to_uppercase().to_string() + chars.as_str(), - None => String::new(), + let mut output = String::new(); + let mut uppercase_next = true; + for c in name.chars() { + if matches!(c, '-' | '_' | '.') { + uppercase_next = true; + continue; + } + if uppercase_next { + output.extend(c.to_uppercase()); + uppercase_next = false; + } else { + output.push(c); + } } -} - -fn escape_formula_string(value: &str) -> String { - value.replace('"', "\\\"") + output } #[cfg(test)] mod tests { use super::render_formula; - use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; + use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::ResolvedPackageMetadata, + }; #[test] fn formula_uses_public_base_url() { @@ -244,9 +295,24 @@ mod tests { profile: Some("release".to_string()), }], }; + let metadata = ResolvedPackageMetadata { + name: "gmutils".to_string(), + version: "0.5.2".to_string(), + description: "Genmeta Binary Utilities".to_string(), + homepage: "https://www.dhttp.net".to_string(), + license: "Apache-2.0".to_string(), + repository: None, + authors: Vec::new(), + }; + let template = include_str!("../../../templates/gmutils.rb.in"); - let formula = render_formula(&manifest, "https://download.example/brew/gmutils") - .expect("formula should render"); + let formula = render_formula( + &manifest, + "https://download.example/brew/gmutils", + &metadata, + template, + ) + .expect("formula should render"); assert!(formula.contains("license \"Apache-2.0\"")); assert!(formula.contains("url \"https://download.example/brew/gmutils/gmutils-0.5.2-aarch64-apple-darwin.tar.gz\"")); diff --git a/xtask/src/publish/s3/deb.rs b/xtask/src/publish/s3/deb.rs index 7f1c28b..4619d22 100644 --- a/xtask/src/publish/s3/deb.rs +++ b/xtask/src/publish/s3/deb.rs @@ -20,7 +20,7 @@ use tracing::info; use walkdir::WalkDir; use super::{ - DebPublishTarget, S3Options, + DebPublishTarget, ResolvedS3Options, plan::{PlannedUpload, UploadCondition}, }; use crate::{ @@ -116,7 +116,7 @@ pub fn deb_payload_key(prefix: &str, package: &str, filename: &str) -> String { } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: DebPublishTarget, ) -> Result<(), Whatever> { diff --git a/xtask/src/publish/s3/rpm.rs b/xtask/src/publish/s3/rpm.rs index b9aa47b..e21e995 100644 --- a/xtask/src/publish/s3/rpm.rs +++ b/xtask/src/publish/s3/rpm.rs @@ -19,7 +19,7 @@ use tempfile::TempDir; use tracing::info; use walkdir::WalkDir; -use super::{RpmPublishTarget, S3Options, plan::PlannedUpload}; +use super::{ResolvedS3Options, RpmPublishTarget, plan::PlannedUpload}; use crate::{ container::{ check_docker, exec_in_container, force_remove_container, remove_container_if_exists, @@ -74,7 +74,7 @@ pub fn rpm_payload_key(prefix: &str, package: &str, version: &str, filename: &st } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: RpmPublishTarget, ) -> Result<(), Whatever> { diff --git a/xtask/src/publish/s3/scoop.rs b/xtask/src/publish/s3/scoop.rs index 0f8ae7e..6de0809 100644 --- a/xtask/src/publish/s3/scoop.rs +++ b/xtask/src/publish/s3/scoop.rs @@ -4,17 +4,17 @@ use snafu::{ResultExt, Snafu, Whatever}; use tracing::{info, warn}; use super::{ - S3Options, ScoopPublishTarget, + ResolvedS3Options, ScoopPublishTarget, key::{PublicBaseUrl, PublicBaseUrlError}, plan::PlannedUpload, }; -use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; +use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::{self, ResolvedPackageMetadata}, +}; const MANIFEST_NAME: &str = "gmutils.json"; const CARGO_NAME: &str = "genmeta"; -const DESCRIPTION: &str = "Genmeta Binary Utilities"; -const HOMEPAGE: &str = "https://www.dhttp.net"; -const LICENSE: &str = "Apache-2.0"; #[derive(Debug, Serialize)] struct ScoopManifest { @@ -52,6 +52,7 @@ pub enum RenderScoopError { pub fn render_scoop_json( manifest: &PackageManifest, public_base_url: &str, + metadata: &ResolvedPackageMetadata, ) -> Result { snafu::ensure!( manifest.kind == ArtifactKind::Scoop, @@ -82,9 +83,9 @@ pub fn render_scoop_json( let scoop_manifest = ScoopManifest { version: manifest.version.clone(), - description: DESCRIPTION.to_string(), - license: LICENSE.to_string(), - homepage: HOMEPAGE.to_string(), + description: metadata.description.clone(), + license: metadata.license.clone(), + homepage: metadata.homepage.clone(), architecture, bin: vec![format!("{CARGO_NAME}.exe"), "genmeta-ssh.bat".to_string()], checkver: CheckVer { @@ -99,7 +100,7 @@ pub fn render_scoop_json( } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: ScoopPublishTarget, ) -> Result<(), Whatever> { @@ -112,7 +113,12 @@ pub async fn run( &target.prefix, ) .await?; - let json = render_scoop_json(&manifest, target.public_base_url.as_str()) + let metadata = release_contract::resolve_package_metadata( + &release_contract::load_release_contract() + .whatever_context("failed to load release contract")?, + ) + .whatever_context("failed to resolve package metadata")?; + let json = render_scoop_json(&manifest, target.public_base_url.as_str(), &metadata) .whatever_context("failed to render scoop json")?; let manifest_path = loaded .target_dir @@ -230,7 +236,10 @@ fn scoop_arch_key(target: &str) -> Result<&'static str, RenderScoopError> { #[cfg(test)] mod tests { use super::render_scoop_json; - use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; + use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::ResolvedPackageMetadata, + }; #[test] fn scoop_json_uses_public_base_url() { @@ -257,13 +266,27 @@ mod tests { profile: Some("release".to_string()), }], }; + let metadata = ResolvedPackageMetadata { + name: "gmutils".to_string(), + version: "0.5.2".to_string(), + description: "Genmeta Binary Utilities".to_string(), + homepage: "https://www.dhttp.net".to_string(), + license: "Apache-2.0".to_string(), + repository: None, + authors: Vec::new(), + }; - let json = render_scoop_json(&manifest, "https://download.example/scoop/gmutils") - .expect("json should render"); + let json = render_scoop_json( + &manifest, + "https://download.example/scoop/gmutils", + &metadata, + ) + .expect("json should render"); let value: serde_json::Value = serde_json::from_str(&json).expect("json should parse"); assert_eq!(value["version"], "0.5.2"); assert_eq!(value["license"], "Apache-2.0"); + assert_eq!(value["homepage"], "https://www.dhttp.net"); assert_eq!( value["architecture"]["64bit"]["url"], "https://download.example/scoop/gmutils/gmutils-0.5.2-x86_64-pc-windows-msvc.zip" diff --git a/xtask/src/release_contract.rs b/xtask/src/release_contract.rs new file mode 100644 index 0000000..2bbc537 --- /dev/null +++ b/xtask/src/release_contract.rs @@ -0,0 +1,360 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use cargo_metadata::MetadataCommand; +use serde::Deserialize; +use snafu::{ResultExt, Snafu}; + +const RELEASE_CONTRACT_PATH: &str = "xtask/release.toml"; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ReleaseContract { + pub cargo: CargoSource, + pub package: Option, + pub homebrew: Option, + pub scoop: Option, + pub build: BuildContract, + pub destination: DestinationContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CargoSource { + pub manifest: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PackageOverride { + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HomebrewContract { + pub template: TemplateContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ScoopContract { + pub template: TemplateContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TemplateContract { + pub path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BuildContract { + pub env: BuildEnvContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BuildEnvContract { + pub required: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DestinationContract { + pub s3: S3Destination, + pub brew: Option, + pub deb: Option, + pub rpm: Option, + pub scoop: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct EnvRef { + pub env: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct S3Destination { + pub bucket: String, + pub endpoint: EnvRef, + pub access_key_id: EnvRef, + pub secret_access_key: EnvRef, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BrewDestination { + pub prefix: String, + pub public_base_url: String, + pub tap: TapDestination, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TapDestination { + pub repository: String, + pub base_branch: String, + pub token: EnvRef, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DebDestination { + pub prefix: String, + pub suite: String, + pub signing: DebSigning, + pub fingerprint: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DebSigning { + pub key: EnvRef, + pub passphrase: EnvRef, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct RpmDestination { + pub prefix: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ScoopDestination { + pub prefix: String, + pub public_base_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPackageMetadata { + pub name: String, + pub version: String, + pub description: String, + pub homepage: String, + pub license: String, + pub repository: Option, + pub authors: Vec, +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ReleaseContractError { + #[snafu(display("failed to read release contract"))] + Read { + source: std::io::Error, + path: PathBuf, + }, + #[snafu(display("failed to parse release contract"))] + Parse { + source: toml::de::Error, + path: PathBuf, + }, + #[snafu(display("failed to read cargo metadata"))] + CargoMetadata { + source: cargo_metadata::Error, + manifest: PathBuf, + }, + #[snafu(display("cargo metadata did not return a root package"))] + MissingRootPackage { manifest: PathBuf }, + #[snafu(display("cargo package is missing description"))] + MissingDescription { manifest: PathBuf }, + #[snafu(display("cargo package is missing homepage"))] + MissingHomepage { manifest: PathBuf }, + #[snafu(display("cargo package is missing license"))] + MissingLicense { manifest: PathBuf }, + #[snafu(display("missing required build environment variable {name}"))] + MissingBuildEnv { name: String }, + #[snafu(display("build environment variable {name} must not be empty"))] + EmptyBuildEnv { name: String }, +} + +pub fn load_release_contract() -> Result { + read_release_contract(&default_release_contract_path()) +} + +fn default_release_contract_path() -> PathBuf { + let cwd_path = PathBuf::from(RELEASE_CONTRACT_PATH); + if cwd_path.exists() { + return cwd_path; + } + Path::new(env!("CARGO_MANIFEST_DIR")).join("release.toml") +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest directory should have a parent") + .to_path_buf() +} + +pub fn read_release_contract(path: &Path) -> Result { + let input = std::fs::read_to_string(path).context(release_contract_error::ReadSnafu { + path: path.to_path_buf(), + })?; + parse_release_contract_at(path, &input) +} + +fn parse_release_contract_at( + path: &Path, + input: &str, +) -> Result { + toml::from_str(input).context(release_contract_error::ParseSnafu { + path: path.to_path_buf(), + }) +} + +pub fn resolve_package_metadata( + contract: &ReleaseContract, +) -> Result { + let manifest = if contract.cargo.manifest.is_absolute() { + contract.cargo.manifest.clone() + } else { + repo_root().join(&contract.cargo.manifest) + }; + let metadata = MetadataCommand::new() + .manifest_path(&manifest) + .no_deps() + .exec() + .context(release_contract_error::CargoMetadataSnafu { + manifest: manifest.clone(), + })?; + let package = + metadata + .root_package() + .ok_or_else(|| ReleaseContractError::MissingRootPackage { + manifest: manifest.clone(), + })?; + let name = contract + .package + .as_ref() + .and_then(|package| package.name.clone()) + .unwrap_or_else(|| package.name.to_string()); + Ok(ResolvedPackageMetadata { + name, + version: package.version.to_string(), + description: package.description.clone().ok_or_else(|| { + ReleaseContractError::MissingDescription { + manifest: manifest.clone(), + } + })?, + homepage: package.homepage.clone().ok_or_else(|| { + ReleaseContractError::MissingHomepage { + manifest: manifest.clone(), + } + })?, + license: package + .license + .clone() + .ok_or_else(|| ReleaseContractError::MissingLicense { + manifest: manifest.clone(), + })?, + repository: package.repository.clone(), + authors: package.authors.clone(), + }) +} + +pub fn validate_required_build_env(contract: &ReleaseContract) -> Result<(), ReleaseContractError> { + let values = std::env::vars().collect::>(); + validate_required_build_env_values(contract, &values) +} + +pub fn validate_required_build_env_values( + contract: &ReleaseContract, + values: &BTreeMap, +) -> Result<(), ReleaseContractError> { + for name in &contract.build.env.required { + match values.get(name) { + Some(value) if value.is_empty() => { + return Err(ReleaseContractError::EmptyBuildEnv { name: name.clone() }); + } + Some(_) => {} + None => { + return Err(ReleaseContractError::MissingBuildEnv { name: name.clone() }); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, path::Path}; + + use super::{ + ReleaseContractError, parse_release_contract_at, validate_required_build_env_values, + }; + + const CONTRACT: &str = r#" +[cargo] +manifest = "genmeta/Cargo.toml" + +[package] +name = "gmutils" + +[homebrew.template] +path = "xtask/templates/gmutils.rb.in" + +[build.env] +required = ["DHTTP_ROOT_CA", "DHTTP_STUN_SERVER"] + +[destination.s3] +bucket = "download" +endpoint.env = "XTASK_RELEASE_S3_ENDPOINT_URL" +access_key_id.env = "XTASK_RELEASE_S3_ACCESS_KEY_ID" +secret_access_key.env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY" + +[destination.brew] +prefix = "brew/gmutils" +public_base_url = "https://download.dhttp.net/brew/gmutils" +tap.repository = "genmeta/homebrew-genmeta" +tap.base_branch = "main" +tap.token.env = "HOMEBREW_TAP_GITHUB_TOKEN" + +[destination.deb] +prefix = "ppa/genmeta" +suite = "genmeta" +signing.key.env = "XTASK_RELEASE_APT_SIGNING_KEY" +signing.passphrase.env = "XTASK_RELEASE_APT_SIGNING_PASSPHRASE" + +[destination.rpm] +prefix = "rpm/gmutils" + +[destination.scoop] +prefix = "scoop/gmutils" +public_base_url = "https://download.dhttp.net/scoop/gmutils" +"#; + + #[test] + fn parses_dotted_snake_case_contract() { + let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) + .expect("contract should parse"); + + assert_eq!(contract.cargo.manifest, Path::new("genmeta/Cargo.toml")); + assert_eq!(contract.package.unwrap().name.as_deref(), Some("gmutils")); + assert_eq!( + contract.destination.s3.endpoint.env, + "XTASK_RELEASE_S3_ENDPOINT_URL" + ); + assert_eq!( + contract.destination.brew.unwrap().tap.token.env, + "HOMEBREW_TAP_GITHUB_TOKEN" + ); + assert_eq!( + contract.destination.deb.unwrap().signing.key.env, + "XTASK_RELEASE_APT_SIGNING_KEY" + ); + } + + #[test] + fn rejects_invalid_contract_toml() { + let error = parse_release_contract_at(Path::new("xtask/release.toml"), "[cargo") + .expect_err("invalid toml should fail"); + + assert!(matches!(error, ReleaseContractError::Parse { .. })); + } + + #[test] + fn rejects_missing_required_build_env() { + let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) + .expect("contract should parse"); + let values = BTreeMap::new(); + + let error = validate_required_build_env_values(&contract, &values) + .expect_err("missing DHTTP_ROOT_CA should fail"); + + assert_eq!( + error.to_string(), + "missing required build environment variable DHTTP_ROOT_CA" + ); + } +} diff --git a/xtask/src/rpm.rs b/xtask/src/rpm.rs index d5a77db..ceb1f16 100644 --- a/xtask/src/rpm.rs +++ b/xtask/src/rpm.rs @@ -5,7 +5,7 @@ //! Flow per target triple: //! 1. Ensure an image `xtask-{triple}:{IMAGE_TAG_PREFIX}` exists //! (Fedora + rpm-build + rustup nightly + Zig + cargo-zigbuild). -//! 2. Spin up a container with the workspace bind-mounted at `/workspace`. +//! 2. Spin up a container with the primary source bind-mounted at `/sources/gmutils`. //! 3. Run `cargo zigbuild --target {triple}.{glibc}` as the host uid:gid. //! 4. Generate a minimal `.spec` file in Rust (no template files), lay out a //! private `_topdir`, and run `rpmbuild -bb --target={rpm_arch}`. @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; use bollard::{ Docker, - models::{ContainerConfig, ContainerCreateBody, HostConfig, Mount, MountType}, + models::{ContainerConfig, ContainerCreateBody, HostConfig}, query_parameters::{ CommitContainerOptionsBuilder, CreateContainerOptionsBuilder, CreateImageOptionsBuilder, }, @@ -28,10 +28,10 @@ use tracing::{Instrument, info, info_span}; use crate::{ RpmTarget, container::{ - CARGO_HOME, RUSTUP_HOME, Sibling, ZIG_GLIBC_VERSION, cargo_cache_mounts, + CARGO_HOME, ContainerSourceLayout, RUSTUP_HOME, ZIG_GLIBC_VERSION, cargo_cache_mounts, cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_env, exec_in_container, force_remove_container, host_uid_gid, install_cargo_config, remove_container_if_exists, - resolve_siblings, start_container, + source_layout, source_mounts, start_container, }, package_version, target_dir, }; @@ -137,7 +137,7 @@ pub async fn run( .whatever_context("failed to connect to Docker/Podman")?; check_docker(&docker).await?; - let siblings = resolve_siblings(siblings)?; + let layout = source_layout("gmutils", siblings)?; let version = package_version(CARGO_NAME)?; let target_dir = target_dir()?; @@ -146,12 +146,12 @@ pub async fn run( let docker = docker.clone(); let version = version.clone(); let target_dir = target_dir.clone(); - let siblings = siblings.clone(); + let layout = layout.clone(); let triple = target.triple(); info!(triple, "queued rpm target build"); let span = info_span!("rpm", triple); tasks.spawn( - async move { build_one(&docker, triple, &version, &target_dir, &siblings).await } + async move { build_one(&docker, triple, &version, &target_dir, &layout).await } .instrument(span), ); } @@ -280,7 +280,7 @@ async fn build_one( triple: &str, version: &str, target_dir: &Path, - siblings: &[Sibling], + layout: &ContainerSourceLayout, ) -> Result { let arch = rpm_arch(triple)?; info!(triple, arch, "ensuring build image"); @@ -291,28 +291,12 @@ async fn build_one( .await .whatever_context(format!("failed to create {}", out_dir.display()))?; - let workspace_dir = - std::env::current_dir().whatever_context("failed to get current directory")?; - - let mut mounts = vec![Mount { - target: Some("/workspace".into()), - source: Some(workspace_dir.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }]; - for sibling in siblings { - mounts.push(Mount { - target: Some(format!("/{}", sibling.basename)), - source: Some(sibling.host.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }); - } + let mut mounts = source_mounts(layout); mounts.extend(cargo_cache_mounts()); let bootstrap = dhttp_bootstrap_from_env()?; mounts.extend(bootstrap.mounts); - let cargo_config = cargo_config_from_siblings(siblings); + let cargo_config = cargo_config_from_siblings(&layout.overrides); let container_name = format!("{CARGO_NAME}-xtask-rpm-{triple}"); remove_container_if_exists(docker, &container_name).await; @@ -343,6 +327,7 @@ async fn build_one( triple, version, arch, + &layout.primary.container, &bootstrap.exports, cargo_config.as_deref(), ) @@ -395,12 +380,14 @@ async fn find_rpm_artifact(out_dir: &Path) -> Result, ) -> Result<(), Whatever> { @@ -426,10 +413,10 @@ export CARGO_HOME={CARGO_HOME} {dhttp_bootstrap_exports} export RUSTFLAGS="${{RUSTFLAGS:-}}" {aarch64_zigbuild_workaround} -cd /workspace +cd {primary_source} cargo zigbuild --release --target {triple}.{ZIG_GLIBC_VERSION} --bin genmeta -TOPDIR=/workspace/target/{triple}/release/rpm +TOPDIR={primary_source}/target/{triple}/release/rpm rm -rf "$TOPDIR"/{{SPECS,BUILD,BUILDROOT,SOURCES,SRPMS,RPMS}} mkdir -p "$TOPDIR"/{{SPECS,BUILD,BUILDROOT,SOURCES,SRPMS,RPMS}} @@ -437,8 +424,8 @@ SPEC="$TOPDIR/SPECS/{PACKAGE_NAME}.spec" printf '%s' {spec_escaped} > "$SPEC" # stage prebuilt binary + script as SOURCES so rpmbuild's %install can pick them up -cp /workspace/target/{triple}/release/genmeta "$TOPDIR/SOURCES/genmeta" -cp /workspace/genmeta-ssh.sh "$TOPDIR/SOURCES/genmeta-ssh.sh" +cp {primary_source}/target/{triple}/release/genmeta "$TOPDIR/SOURCES/genmeta" +cp {primary_source}/genmeta-ssh.sh "$TOPDIR/SOURCES/genmeta-ssh.sh" rpmbuild -bb \ --target={arch} \ diff --git a/xtask/src/template.rs b/xtask/src/template.rs new file mode 100644 index 0000000..4295f53 --- /dev/null +++ b/xtask/src/template.rs @@ -0,0 +1,81 @@ +use std::collections::BTreeMap; + +use snafu::{Snafu, ensure}; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum RenderTemplateError { + #[snafu(display("template variable {name} is not defined"))] + MissingVariable { name: String }, + #[snafu(display("template contains unresolved placeholders"))] + UnresolvedPlaceholder, +} + +pub fn render_template( + template: &str, + variables: &BTreeMap, +) -> Result { + let mut output = String::with_capacity(template.len()); + let mut rest = template; + while let Some(start) = rest.find("{{") { + output.push_str(&rest[..start]); + let after_start = &rest[start + 2..]; + let Some(end) = after_start.find("}}") else { + return Err(RenderTemplateError::UnresolvedPlaceholder); + }; + let name = after_start[..end].trim().to_string(); + let value = variables + .get(&name) + .ok_or(RenderTemplateError::MissingVariable { name })?; + output.push_str(value); + rest = &after_start[end + 2..]; + } + output.push_str(rest); + ensure!( + !output.contains("{{") && !output.contains("}}"), + render_template_error::UnresolvedPlaceholderSnafu + ); + Ok(output) +} + +pub fn ruby_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{render_template, ruby_string}; + + #[test] + fn renders_named_variables() { + let variables = BTreeMap::from([ + ("package.name".to_string(), "gmutils".to_string()), + ("package.version".to_string(), "0.6.1".to_string()), + ]); + + let rendered = render_template("{{ package.name }} {{package.version}}", &variables) + .expect("template should render"); + + assert_eq!(rendered, "gmutils 0.6.1"); + } + + #[test] + fn rejects_missing_variables() { + let variables = BTreeMap::new(); + + let error = render_template("{{package.name}}", &variables) + .expect_err("missing variable should fail"); + + assert_eq!( + error.to_string(), + "template variable package.name is not defined" + ); + } + + #[test] + fn escapes_ruby_strings() { + assert_eq!(ruby_string("a\\\"b"), "a\\\\\\\"b"); + } +} diff --git a/xtask/templates/gmutils.rb.in b/xtask/templates/gmutils.rb.in new file mode 100644 index 0000000..b209001 --- /dev/null +++ b/xtask/templates/gmutils.rb.in @@ -0,0 +1,17 @@ +class {{homebrew.class}} < Formula + desc "{{package.description}}" + version "{{package.version}}" + homepage "{{package.homepage}}" + license "{{package.license}}" + +{{homebrew.urls}} + + def install + bin.install "genmeta" + bin.install "genmeta-ssh.sh" + end + + test do + system "#{bin}/genmeta", "version" + end +end From 92cc67f8be43d8aaf84787b71be7986766eaebab Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 14:42:53 +0800 Subject: [PATCH 03/21] fix(identity): treat missing home as empty local state --- genmeta-identity/src/cli.rs | 45 ++++++++++++++++- genmeta-identity/src/cli/flow/local.rs | 68 ++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index ac52f43..f6e0acc 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -47,6 +47,11 @@ pub enum Error { LoadDefaultConfig { source: LoadDhttpSettingsError }, #[snafu(transparent)] SaveDefaultConfig { source: SaveDhttpSettingsError }, + #[snafu(display("failed to create dhttp home directory at {}", path.display()))] + CreateDhttpHomeDir { + path: std::path::PathBuf, + source: io::Error, + }, #[snafu(transparent)] ListIdentities { source: ListIdentityProfilesError }, #[snafu(transparent)] @@ -175,6 +180,14 @@ async fn load_current_settings(dhttp_home: &DhttpHome) -> Result Result<(), Error> { + if let Some(parent) = default_config.path().parent() { + tokio::fs::create_dir_all(parent) + .await + .context(CreateDhttpHomeDirSnafu { + path: parent.to_path_buf(), + })?; + } + let path = default_config.path().display(); tracing::Span::current().pb_set_message(&format!("Saving default configuration to {path}...")); default_config.save().await?; @@ -508,13 +521,29 @@ pub async fn run(options: Options) -> Result<(), Error> { #[cfg(test)] mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use clap::{CommandFactory, Parser}; - use dhttp::{identity::Identity, name::Name}; + use dhttp::{home::DhttpHome, identity::Identity, name::DhttpName, name::Name}; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use super::{Create, Options, cert_server_base_url, certificate_chain_key_from_identity}; use crate::CERT_SERVER_BASE_URL; + fn unique_test_home_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-cli-{test_name}-{}-{nonce}", + std::process::id() + )) + } + #[test] fn cert_server_base_url_uses_compile_time_bootstrap_url() { let url = cert_server_base_url(); @@ -687,4 +716,18 @@ mod tests { assert!(rendered.contains("--send-code"), "{rendered}"); assert!(rendered.contains("--verify-code"), "{rendered}"); } + + #[tokio::test] + async fn save_settings_creates_missing_home_directory() { + let home_path = unique_test_home_path("save-settings"); + let dhttp_home = DhttpHome::new(home_path.clone()); + let mut settings = dhttp_home.new_settings(); + settings + .settings_mut() + .set_default_identity_name(DhttpName::try_from("alice.smith").unwrap()); + + super::save_settings(&settings).await.unwrap(); + + assert!(home_path.join("settings.toml").exists()); + } } diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index 0a3dc51..1d97a5f 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -211,10 +211,19 @@ pub(crate) async fn load_inventory( dhttp_home: &DhttpHome, default_name: Option>, ) -> Result { - let names = dhttp_home + let names = match dhttp_home .identity_profile_names() .try_collect::>() - .await?; + .await + { + Ok(names) => names, + Err(dhttp::home::identity::ssl::ListIdentityProfilesError::ReadDir { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + return Ok(LocalInventory { groups: Vec::new() }); + } + Err(error) => return Err(Error::from(error)), + }; let mut summaries = Vec::with_capacity(names.len()); for name in names { summaries.push(load_summary(dhttp_home, name.borrow(), default_name.clone()).await?); @@ -222,6 +231,20 @@ pub(crate) async fn load_inventory( Ok(build_inventory(summaries)) } +pub(crate) async fn try_load_summary( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, + default_name: Option>, +) -> Result, Error> { + match load_summary(dhttp_home, name, default_name).await { + Ok(summary) => Ok(Some(summary)), + Err(Error::ResolveIdentityProfile { + source: dhttp::home::identity::ssl::ResolveIdentityProfileError::NotFound { .. }, + }) => Ok(None), + Err(error) => Err(error), + } +} + pub(crate) async fn load_summary( dhttp_home: &DhttpHome, name: DhttpName<'_>, @@ -457,7 +480,12 @@ fn private_key_state_from_error( #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use dhttp::{home::DhttpHome, name::DhttpName}; use super::{ InteractiveInventoryChoice, LocalIdentityAssessment, LocalIdentityMaterialState, @@ -469,6 +497,17 @@ mod tests { const NOW: i64 = 1_794_298_000; + fn unique_test_home_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-local-{test_name}-{}-{nonce}", + std::process::id() + )) + } + fn ready_summary(name: &str, is_default: bool, chain: &str) -> LocalIdentitySummary { LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), @@ -679,4 +718,27 @@ mod tests { ] ); } + + #[tokio::test] + async fn load_inventory_returns_empty_when_home_directory_is_missing() { + let home_path = unique_test_home_path("missing-home-inventory"); + let dhttp_home = DhttpHome::new(home_path); + + let inventory = super::load_inventory(&dhttp_home, None).await.unwrap(); + + assert!(inventory.groups.is_empty()); + } + + #[tokio::test] + async fn try_load_summary_returns_none_when_profile_is_missing() { + let home_path = unique_test_home_path("missing-home-summary"); + let dhttp_home = DhttpHome::new(home_path); + let name = DhttpName::try_from("alice.smith").unwrap(); + + let summary = super::try_load_summary(&dhttp_home, name.borrow(), None) + .await + .unwrap(); + + assert!(summary.is_none()); + } } From c098a63204e396f98dd6938246f74a21db8fa612 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 14:44:34 +0800 Subject: [PATCH 04/21] refactor(identity): unify default prompt labels --- genmeta-identity/src/cli/flow/epilogue.rs | 88 +++++++++++++++---- genmeta-identity/src/cli/flow/output.rs | 102 +++++++++++++++++----- 2 files changed, 151 insertions(+), 39 deletions(-) diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index f0417a0..16f9452 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -5,6 +5,12 @@ use dhttp::{home::DhttpHome, name::DhttpName}; use super::{local, output, transcript}; use crate::cli::{self, Error, prompt::InquireResultExt}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CurrentDefaultSummary { + pub(crate) name: String, + pub(crate) status: local::LocalIdentityStatus, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DefaultSuggestion { pub(crate) prompt: String, @@ -13,17 +19,21 @@ pub(crate) struct DefaultSuggestion { pub(crate) fn suggest_default_change( saved_name: &str, - current_default: Option<&str>, + current_default: Option<&CurrentDefaultSummary>, + ansi: bool, ) -> Option { match current_default { - Some(current) if current == saved_name => None, - Some(_) => Some(DefaultSuggestion { - prompt: format!("Set {saved_name} as the default identity on this device?"), - default: true, + Some(current) if current.name == saved_name => None, + Some(current) => Some(DefaultSuggestion { + prompt: format!( + "Set {saved_name} as the default identity on this device? {}", + output::format_current_default_suffix(¤t.name, ¤t.status, ansi) + ), + default: false, }), None => Some(DefaultSuggestion { prompt: format!("Set {saved_name} as the default identity on this device?"), - default: false, + default: true, }), } } @@ -53,6 +63,26 @@ async fn current_default_name(dhttp_home: &DhttpHome) -> Result Result, Error> { + let Some(name) = current_default_name(dhttp_home).await? else { + return Ok(None); + }; + + let status = match local::try_load_summary(dhttp_home, name.borrow(), None).await? { + Some(summary) => summary.status, + None => local::LocalIdentityStatus::Invalid { + detail: "identity is not saved on this device".to_string(), + }, + }; + + Ok(Some(CurrentDefaultSummary { + name: name.as_partial().to_string(), + status, + })) +} + async fn save_default_name( dhttp_home: &DhttpHome, name: DhttpName<'_>, @@ -76,6 +106,7 @@ pub(crate) async fn run_lifecycle_epilogue( ) -> Result<(), Error> { let ansi = std::io::stdout().is_terminal(); let mut default_after = current_default_name(dhttp_home).await?; + let current_default = current_default_summary(dhttp_home).await?; let summary = local::load_summary( dhttp_home, name.clone(), @@ -89,7 +120,8 @@ pub(crate) async fn run_lifecycle_epilogue( if interactive && let Some(suggestion) = suggest_default_change( name.as_partial(), - default_after.as_ref().map(|default| default.as_partial()), + current_default.as_ref(), + ansi, ) { let accepted = crate::cli::prompt::sync(move || { @@ -142,8 +174,11 @@ mod tests { use dhttp::{home::DhttpHome, name::DhttpName}; use tokio::fs; - use super::{DefaultSuggestion, default_block, suggest_default_change}; - use crate::cli::flow::output::DefaultIdentityBlock; + use super::{CurrentDefaultSummary, DefaultSuggestion, default_block, suggest_default_change}; + use crate::cli::flow::{ + local::LocalIdentityStatus, + output::DefaultIdentityBlock, + }; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -157,22 +192,37 @@ mod tests { } #[test] - fn suggest_new_default_uses_yes_by_default_when_another_default_exists() { - let suggestion = suggest_default_change("alice.smith", Some("meng.lin")).unwrap(); + fn suggest_fill_empty_default_uses_yes_by_default() { + let suggestion = suggest_default_change("alice.smith", None, false).unwrap(); + assert!(suggestion.default); assert_eq!( - suggestion, - DefaultSuggestion { - prompt: "Set alice.smith as the default identity on this device?".to_string(), - default: true, - } + suggestion.prompt, + "Set alice.smith as the default identity on this device?" ); } #[test] - fn suggest_fill_empty_default_uses_no_by_default() { - let suggestion = suggest_default_change("alice.smith", None).unwrap(); - assert!(!suggestion.default); + fn suggest_replacing_default_uses_no_by_default_and_shows_current_status() { + let suggestion = suggest_default_change( + "alice.smith", + Some(&CurrentDefaultSummary { + name: "meng.lin".to_string(), + status: LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + }, + }), + false, + ) + .unwrap(); + + assert_eq!( + suggestion, + DefaultSuggestion { + prompt: "Set alice.smith as the default identity on this device? (current: meng.lin [invalid])".to_string(), + default: false, + } + ); } #[test] diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index afee240..6bf2456 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -65,15 +65,11 @@ pub(crate) enum DefaultIdentityBlock { } pub(crate) fn summary_line_style(summary: &LocalIdentitySummary) -> LineStyle { - if matches!( - summary.status, - LocalIdentityStatus::Invalid { .. } | LocalIdentityStatus::Incomplete { .. } - ) { - LineStyle::Dim - } else if summary.is_default { - LineStyle::Bold - } else { - LineStyle::Plain + match status_line_style(&summary.status) { + LineStyle::Dim => LineStyle::Dim, + LineStyle::Plain if summary.is_default => LineStyle::Bold, + LineStyle::Plain => LineStyle::Plain, + LineStyle::Bold => LineStyle::Bold, } } @@ -89,6 +85,47 @@ fn render_line(text: String, style: LineStyle, ansi: bool) -> String { } } +pub(crate) fn compact_identity_label(summary: &LocalIdentitySummary) -> String { + compact_identity_label_parts( + summary.target.short_name(), + &summary.status, + summary.is_default, + ) +} + +pub(crate) fn compact_identity_label_parts( + name: &str, + status: &LocalIdentityStatus, + is_default: bool, +) -> String { + let mut label = format!("{name} [{}]", status.label()); + if is_default { + label.push_str(" (default identity)"); + } + label +} + +pub(crate) fn status_line_style(status: &LocalIdentityStatus) -> LineStyle { + match status { + LocalIdentityStatus::Invalid { .. } | LocalIdentityStatus::Incomplete { .. } => { + LineStyle::Dim + } + LocalIdentityStatus::Ready { .. } | LocalIdentityStatus::Expired { .. } => LineStyle::Plain, + } +} + +pub(crate) fn format_current_default_suffix( + name: &str, + status: &LocalIdentityStatus, + ansi: bool, +) -> String { + render_line( + format!("(current: {})", compact_identity_label_parts(name, status, false)), + LineStyle::Dim, + ansi, + ) +} + pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: bool) -> String { match choice { InteractiveInventoryChoice::Saved(summary) => { @@ -97,15 +134,11 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo } else { "" }; - let mut label = format!( - "{prefix}{} [{}]", - summary.target.short_name(), - summary.status.label() - ); - if summary.is_default { - label.push_str(" (default identity)"); - } - render_line(label, summary_line_style(summary), ansi) + render_line( + format!("{prefix}{}", compact_identity_label(summary)), + summary_line_style(summary), + ansi, + ) } InteractiveInventoryChoice::Organization { target } => render_line( format!("{} (not saved locally)", target.short_name()), @@ -256,8 +289,9 @@ mod tests { use std::path::PathBuf; use super::{ - DefaultIdentityBlock, LineStyle, format_default_identity_block, format_default_summary, - format_info, render_choice_label, render_inventory, summary_line_style, + DefaultIdentityBlock, LineStyle, format_current_default_suffix, + format_default_identity_block, format_default_summary, format_info, + render_choice_label, render_inventory, summary_line_style, compact_identity_label, }; use crate::cli::flow::{ local::{ @@ -318,6 +352,20 @@ Saved at: /tmp/phone.alice.smith"; assert_eq!(summary_line_style(&profile), LineStyle::Bold); } + #[test] + fn compact_label_uses_square_bracket_status_without_chain_id() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + ); + + assert_eq!(compact_identity_label(&profile), "alice.smith [ready]"); + } + #[test] fn invalid_default_line_prefers_dim_over_bold() { let profile = summary( @@ -332,6 +380,20 @@ Saved at: /tmp/phone.alice.smith"; assert_eq!(summary_line_style(&profile), LineStyle::Dim); } + #[test] + fn current_default_suffix_uses_compact_label_text() { + assert_eq!( + format_current_default_suffix( + "meng.lin", + &LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + }, + false, + ), + "(current: meng.lin [invalid])" + ); + } + #[test] fn formats_none_default_block() { assert_eq!( From 948ed36ca9eb729326d3a7df97b182dab3072b2c Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 14:49:16 +0800 Subject: [PATCH 05/21] refactor(identity): require explicit apply targets --- genmeta-identity/src/cli.rs | 13 +- genmeta-identity/src/cli/flow/apply.rs | 226 +++++++++--------- genmeta-identity/src/cli/flow/create.rs | 1 - .../src/cli/flow/default_identity.rs | 33 ++- genmeta-identity/src/cli/flow/local.rs | 71 +----- genmeta-identity/src/cli/flow/output.rs | 3 - genmeta-identity/src/cli/flow/renew.rs | 6 +- 7 files changed, 141 insertions(+), 212 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index f6e0acc..d219250 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -319,8 +319,6 @@ pub struct Create { pub struct Apply { #[arg(value_name = "IDENTITY")] pub name: Option, - #[arg(long = "default", conflicts_with = "name")] - pub use_default: bool, #[arg(long)] pub kind: Option, #[arg(long)] @@ -671,11 +669,12 @@ mod tests { } #[test] - fn apply_and_renew_accept_default_flag() { - assert!( - Options::try_parse_from(["genmeta", "apply", "--default", "--kind", "primary",]) - .is_ok() - ); + fn apply_rejects_default_flag_while_renew_keeps_it() { + let apply_error = + Options::try_parse_from(["genmeta", "apply", "--default", "--kind", "primary"]) + .unwrap_err(); + assert!(apply_error.to_string().contains("--default")); + assert!(Options::try_parse_from(["genmeta", "renew", "--default"]).is_ok()); } diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index d58117e..0594129 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -7,7 +7,7 @@ use tracing::{Instrument, info_span}; use super::{ approval, kind::IdentityKind, - local::{self, InteractiveInventoryChoice, LocalIdentityStatus, LocalIdentitySummary}, + local::{self, LocalIdentityStatus, LocalIdentitySummary}, target::{IdentityLevel, IdentityTarget}, }; use crate::{ @@ -107,6 +107,12 @@ pub(crate) enum ApplyRunOutcome { ReturnedToCaller, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ApplyPostSavePolicy { + ManageDefaultSuggestion, + SkipDefaultSuggestion, +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ApplyVerifyCodeAction { ResendVerificationCode, @@ -376,60 +382,23 @@ fn apply_identity_name_opening() -> &'static str { "Apply an existing identity to this device.\n\nThis will create a new certificate chain for an existing identity\nand save it on this device.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo apply a sub-identity, add one more name before it:\n phone.alice.smith" } -async fn resolve_target( +fn explicit_target_from_command( command: &Apply, - dhttp_home: &DhttpHome, -) -> Result, Error> { - if command.use_default { - return cli::resolve_default_target_name(dhttp_home).await; - } +) -> Result>, Error> { + command.name.as_deref().map(cli::parse_identity_name).transpose() +} - match command.name.as_deref() { - Some(name) => cli::parse_identity_name(name), - None => { - let default_name = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let inventory = - local::load_inventory(dhttp_home, default_name.as_ref().map(|name| name.borrow())) - .await?; - let choices = local::build_apply_inventory_choices(&inventory); - if choices.is_empty() { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - return cli::parse_identity_name(&identity); - } - let labels: Vec = choices - .iter() - .map(|choice| { - super::output::render_choice_label(choice, std::io::stdout().is_terminal()) - }) - .collect(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to apply to this device:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => Ok(summary.target.into_dhttp_name()), - InteractiveInventoryChoice::Organization { target } => Ok(target.into_dhttp_name()), - InteractiveInventoryChoice::EnterAnotherIdentity => { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - cli::parse_identity_name(&identity) - } - } - } +async fn prompt_apply_target() -> Result, Error> { + let identity = crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) + .await + .require_interactive("IDENTITY")?; + cli::parse_identity_name(&identity) +} + +async fn resolve_target(command: &Apply) -> Result, Error> { + match explicit_target_from_command(command)? { + Some(name) => Ok(name), + None => prompt_apply_target().await, } } @@ -540,7 +509,6 @@ async fn run_helper_apply_action( approval::ApprovalHelperAction::Apply | approval::ApprovalHelperAction::Reapply => { let command = Apply { name: Some(auth_domain.to_string()), - use_default: false, kind: None, replace_local: matches!(action, approval::ApprovalHelperAction::Reapply), device_name: None, @@ -625,75 +593,45 @@ async fn prompt_apply_approval_menu_action( .whatever_context::<_, Error>("selected apply approval action is unavailable") } -pub(crate) async fn run_interactive( +async fn run_post_save_epilogue( + post_save: ApplyPostSavePolicy, + dhttp_home: &DhttpHome, + domain: dhttp::name::DhttpName<'_>, + default_identity_when_command_started: Option>, + interactive: bool, +) -> Result<(), Error> { + match post_save { + ApplyPostSavePolicy::ManageDefaultSuggestion => { + crate::cli::flow::epilogue::run_lifecycle_epilogue( + dhttp_home, + domain, + default_identity_when_command_started, + interactive, + ) + .await + } + ApplyPostSavePolicy::SkipDefaultSuggestion => { + crate::cli::flow::epilogue::run_local_epilogue(dhttp_home, domain).await + } + } +} + +async fn run_interactive_with_policy( command: &Apply, dhttp_home: &DhttpHome, cert_server: &CertServer, return_to: Option<&str>, + post_save: ApplyPostSavePolicy, ) -> Result { let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let initial_target = if command.use_default { - Some(cli::resolve_default_target_name(dhttp_home).await?) - } else { - command - .name - .as_deref() - .map(cli::parse_identity_name) - .transpose()? - }; + let initial_target = explicit_target_from_command(command)?; let mut state = InteractiveApplyState::from_command(command, initial_target)?; loop { if state.target.is_none() { - let default_name = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let inventory = - local::load_inventory(dhttp_home, default_name.as_ref().map(|name| name.borrow())) - .await?; - let choices = local::build_apply_inventory_choices(&inventory); - if choices.is_empty() { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - state.target = Some(cli::parse_identity_name(&identity)?); - } else { - let labels: Vec = choices - .iter() - .map(|choice| { - super::output::render_choice_label(choice, std::io::stdout().is_terminal()) - }) - .collect(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to apply to this device:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => { - state.target = Some(summary.target.into_dhttp_name()); - } - InteractiveInventoryChoice::Organization { target } => { - state.target = Some(target.into_dhttp_name()); - } - InteractiveInventoryChoice::EnterAnotherIdentity => { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - state.target = Some(cli::parse_identity_name(&identity)?); - } - } - } + state.target = Some(prompt_apply_target().await?); continue; } @@ -963,7 +901,8 @@ pub(crate) async fn run_interactive( ) .instrument(info_span!("save_identity")) .await?; - crate::cli::flow::epilogue::run_lifecycle_epilogue( + run_post_save_epilogue( + post_save, dhttp_home, domain.borrow(), default_identity_when_command_started.clone(), @@ -974,14 +913,33 @@ pub(crate) async fn run_interactive( } } -pub(crate) async fn run( +pub(crate) async fn run_interactive( + command: &Apply, + dhttp_home: &DhttpHome, + cert_server: &CertServer, + return_to: Option<&str>, +) -> Result { + run_interactive_with_policy( + command, + dhttp_home, + cert_server, + return_to, + ApplyPostSavePolicy::ManageDefaultSuggestion, + ) + .await +} + +pub(crate) async fn run_with_policy( command: &Apply, dhttp_home: &DhttpHome, cert_server: &CertServer, + post_save: ApplyPostSavePolicy, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); if is_interactive && !command.send_code { - return match run_interactive(command, dhttp_home, cert_server, None).await? { + return match run_interactive_with_policy(command, dhttp_home, cert_server, None, post_save) + .await? + { ApplyRunOutcome::Applied => Ok(()), ApplyRunOutcome::ReturnedToCaller => whatever!("apply was cancelled"), }; @@ -989,7 +947,7 @@ pub(crate) async fn run( let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let domain = resolve_target(command, dhttp_home).await?; + let domain = resolve_target(command).await?; let target = IdentityTarget::parse(domain.as_partial())?; let kind = resolve_kind(command).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref()); @@ -1067,7 +1025,8 @@ pub(crate) async fn run( ) .instrument(info_span!("save_identity")) .await?; - crate::cli::flow::epilogue::run_lifecycle_epilogue( + run_post_save_epilogue( + post_save, dhttp_home, domain.borrow(), default_identity_when_command_started, @@ -1076,13 +1035,27 @@ pub(crate) async fn run( .await } +pub(crate) async fn run( + command: &Apply, + dhttp_home: &DhttpHome, + cert_server: &CertServer, +) -> Result<(), Error> { + run_with_policy( + command, + dhttp_home, + cert_server, + ApplyPostSavePolicy::ManageDefaultSuggestion, + ) + .await +} + #[cfg(test)] mod tests { use super::{ ApplyApprovalMenuAction, ApplyApprovalPlan, ApplyEmailAction, ApplyVerifyCodeAction, InteractiveApplyState, apply_approval_menu_actions, apply_email_actions, apply_identity_name_opening, apply_verification_options, apply_verify_code_actions, - approval_plan_from_selection, build_apply_approval_options, + approval_plan_from_selection, build_apply_approval_options, explicit_target_from_command, resolve_non_interactive_approval_plan, }; use crate::{ @@ -1098,7 +1071,6 @@ mod tests { let mut state = InteractiveApplyState::from_command( &Apply { name: Some("alice.smith".to_string()), - use_default: false, kind: Some("primary".to_string()), replace_local: false, device_name: None, @@ -1127,7 +1099,6 @@ mod tests { let mut state = InteractiveApplyState::from_command( &Apply { name: Some("alice.smith".to_string()), - use_default: false, kind: Some("primary".to_string()), replace_local: false, device_name: None, @@ -1152,6 +1123,23 @@ mod tests { assert!(state.verify_code.is_none()); } + #[test] + fn explicit_target_from_command_returns_none_without_name() { + let target = explicit_target_from_command(&Apply { + name: None, + kind: None, + replace_local: false, + device_name: None, + email: None, + send_code: false, + verify_code: None, + auth: None, + }) + .unwrap(); + + assert!(target.is_none()); + } + #[test] fn root_apply_without_local_auth_defaults_to_email_non_interactively() { assert_eq!( diff --git a/genmeta-identity/src/cli/flow/create.rs b/genmeta-identity/src/cli/flow/create.rs index 453c024..1b9f315 100644 --- a/genmeta-identity/src/cli/flow/create.rs +++ b/genmeta-identity/src/cli/flow/create.rs @@ -676,7 +676,6 @@ To continue creating {}, it will {verb} {short_parent_identity} on this device, )); let command = crate::cli::Apply { name: Some(parent_identity.to_string()), - use_default: false, kind: None, replace_local, device_name: None, diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index e96646f..8695c61 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -89,9 +89,19 @@ async fn run_helper_apply( target.short_name(), target.short_name() )); - let command = crate::cli::Apply { + let command = helper_apply_command(target); + super::apply::run_with_policy( + &command, + dhttp_home, + cert_server, + super::apply::ApplyPostSavePolicy::SkipDefaultSuggestion, + ) + .await +} + +fn helper_apply_command(target: &IdentityTarget) -> crate::cli::Apply { + crate::cli::Apply { name: Some(target.short_name().to_string()), - use_default: false, kind: None, replace_local: false, device_name: None, @@ -99,8 +109,7 @@ async fn run_helper_apply( send_code: false, verify_code: None, auth: None, - }; - super::apply::run(&command, dhttp_home, cert_server).await + } } async fn select_interactive_default_summary( @@ -157,9 +166,6 @@ async fn select_interactive_default_summary( DefaultOrganizationAction::ChooseAnotherIdentity => continue, } } - InteractiveInventoryChoice::EnterAnotherIdentity => { - whatever!("default identity selection does not support free-form identity entry") - } } } } @@ -237,8 +243,11 @@ pub(crate) async fn run( #[cfg(test)] mod tests { + use crate::cli::flow::target::IdentityTarget; + use super::{ - DefaultOrganizationAction, default_organization_actions, organization_action_from_selection, + DefaultOrganizationAction, default_organization_actions, helper_apply_command, + organization_action_from_selection, }; #[test] @@ -272,4 +281,12 @@ mod tests { DefaultOrganizationAction::ChooseAnotherIdentity, ); } + + #[test] + fn helper_apply_command_uses_explicit_name_without_default_lookup() { + let command = helper_apply_command(&IdentityTarget::parse("alice.smith").unwrap()); + + assert_eq!(command.name.as_deref(), Some("alice.smith")); + assert!(command.kind.is_none()); + } } diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index 1d97a5f..e7a3de9 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -91,7 +91,6 @@ pub(crate) struct LocalInventory { pub(crate) enum InteractiveInventoryChoice { Saved(LocalIdentitySummary), Organization { target: IdentityTarget }, - EnterAnotherIdentity, } pub(crate) fn classify_status( @@ -267,33 +266,6 @@ pub(crate) async fn load_summary( }) } -pub(crate) fn build_apply_inventory_choices( - inventory: &LocalInventory, -) -> Vec { - let mut choices = Vec::new(); - for group in &inventory.groups { - match &group.root { - LocalInventoryRoot::Saved(summary) => { - choices.push(InteractiveInventoryChoice::Saved(summary.clone())); - } - LocalInventoryRoot::Organization { target } => { - choices.push(InteractiveInventoryChoice::Organization { - target: target.clone(), - }); - } - } - choices.extend( - group - .children - .iter() - .cloned() - .map(InteractiveInventoryChoice::Saved), - ); - } - choices.push(InteractiveInventoryChoice::EnterAnotherIdentity); - choices -} - pub(crate) fn build_renew_inventory_choices( inventory: &LocalInventory, ) -> Vec { @@ -490,8 +462,8 @@ mod tests { use super::{ InteractiveInventoryChoice, LocalIdentityAssessment, LocalIdentityMaterialState, LocalIdentityStatus, LocalIdentitySummary, LocalInventoryRoot, - build_apply_inventory_choices, build_default_inventory_choices, build_inventory, - build_renew_inventory_choices, classify_status, + build_default_inventory_choices, build_inventory, build_renew_inventory_choices, + classify_status, }; use crate::cli::flow::target::IdentityTarget; @@ -634,45 +606,6 @@ mod tests { ); } - #[test] - fn builds_apply_inventory_choices_with_organization_root_and_enter_another_identity() { - let mut tv = ready_summary("tv.alice.smith", false, "secondary:3"); - tv.status = LocalIdentityStatus::Incomplete { - detail: "private key missing".to_string(), - }; - tv.certificate_chain = None; - - let inventory = build_inventory(vec![ - ready_summary("tablet.reimu.scarlet", false, "secondary:1"), - tv.clone(), - ready_summary("phone.alice.smith", false, "secondary:2"), - ]); - - assert_eq!( - build_apply_inventory_choices(&inventory), - vec![ - InteractiveInventoryChoice::Organization { - target: IdentityTarget::parse("alice.smith").unwrap(), - }, - InteractiveInventoryChoice::Saved(ready_summary( - "phone.alice.smith", - false, - "secondary:2", - )), - InteractiveInventoryChoice::Saved(tv), - InteractiveInventoryChoice::Organization { - target: IdentityTarget::parse("reimu.scarlet").unwrap(), - }, - InteractiveInventoryChoice::Saved(ready_summary( - "tablet.reimu.scarlet", - false, - "secondary:1", - )), - InteractiveInventoryChoice::EnterAnotherIdentity, - ] - ); - } - #[test] fn build_renew_inventory_choices_keeps_missing_parent_roots() { let child = ready_summary("shanghai.alice.ma", false, "secondary:1"); diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 6bf2456..4f32778 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -145,7 +145,6 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo LineStyle::Dim, ansi, ), - InteractiveInventoryChoice::EnterAnotherIdentity => "Enter another identity".to_string(), } } @@ -511,7 +510,6 @@ reimu.scarlet (not saved locally)\n\ )), false, ), - render_choice_label(&InteractiveInventoryChoice::EnterAnotherIdentity, false), ]; assert_eq!( @@ -520,7 +518,6 @@ reimu.scarlet (not saved locally)\n\ "alice.smith [ready] (default identity)".to_string(), "reimu.scarlet (not saved locally)".to_string(), " tablet.reimu.scarlet [ready]".to_string(), - "Enter another identity".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 3e6ce59..52c9034 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -283,8 +283,7 @@ async fn resolve_target( .whatever_context::<_, Error>("selected identity choice is unavailable")?; match choice { InteractiveInventoryChoice::Saved(summary) => Ok(summary.target.into_dhttp_name()), - InteractiveInventoryChoice::Organization { .. } - | InteractiveInventoryChoice::EnterAnotherIdentity => { + InteractiveInventoryChoice::Organization { .. } => { whatever!("renew requires a saved local identity profile") } } @@ -406,9 +405,6 @@ async fn run_interactive( )); state.revisit_target_selection(); } - InteractiveInventoryChoice::EnterAnotherIdentity => { - whatever!("renew requires a saved local identity profile") - } } continue; } From a663f66c7287262a48ef1f381154c3d5ffa061e4 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 14:57:39 +0800 Subject: [PATCH 06/21] fix(identity): align default and renew local-state errors --- genmeta-identity/src/cli.rs | 62 ++++++++++- .../src/cli/flow/default_identity.rs | 105 ++++++++++++++---- genmeta-identity/src/cli/flow/renew.rs | 64 +++++++++++ 3 files changed, 205 insertions(+), 26 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index d219250..a9fab0f 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -433,12 +433,19 @@ impl Info { ), }, }; - let summary = flow::local::load_summary( + let Some(summary) = flow::local::try_load_summary( dhttp_home, name.borrow(), default_name.as_ref().map(|default| default.borrow()), ) - .await?; + .await? + else { + whatever!( + "{} is not saved on this device.\n\nTo inspect it locally, apply {} to this device first.", + name.as_partial(), + name.as_partial(), + ); + }; flow::transcript::print_block(&flow::output::format_info( &summary, std::io::stdout().is_terminal(), @@ -528,7 +535,9 @@ mod tests { use dhttp::{home::DhttpHome, identity::Identity, name::DhttpName, name::Name}; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; - use super::{Create, Options, cert_server_base_url, certificate_chain_key_from_identity}; + use super::{ + Create, Default, Info, Options, cert_server_base_url, certificate_chain_key_from_identity, + }; use crate::CERT_SERVER_BASE_URL; fn unique_test_home_path(test_name: &str) -> PathBuf { @@ -558,6 +567,11 @@ mod tests { ) } + fn dummy_cert_server() -> crate::cert_server::CertServer { + _ = rustls::crypto::ring::default_provider().install_default(); + crate::cert_server::CertServer::new("https://license.genmeta.net").unwrap() + } + #[test] fn certificate_chain_key_from_identity_reads_dhttp_ski() { let identity = local_identity_with_dhttp_ski(); @@ -729,4 +743,46 @@ mod tests { assert!(home_path.join("settings.toml").exists()); } + + #[tokio::test] + async fn info_reports_unsaved_identity_with_business_message() { + let home_path = unique_test_home_path("info-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Info { + name: Some("alice.smith".to_string()), + }; + + let error = command.run(&dhttp_home, &dummy_cert_server()).await.unwrap_err(); + let rendered = error.to_string(); + + assert!( + rendered.contains("alice.smith is not saved on this device"), + "{rendered}" + ); + assert!( + rendered.contains("apply alice.smith to this device first"), + "{rendered}" + ); + } + + #[tokio::test] + async fn default_reports_unsaved_identity_non_interactively() { + let home_path = unique_test_home_path("default-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Default { + name: Some("alice.smith".to_string()), + allow_nonready: false, + }; + + let error = command + .run(&dhttp_home, &dummy_cert_server()) + .await + .unwrap_err(); + let rendered = error.to_string(); + + assert!( + rendered.contains("alice.smith is not saved on this device"), + "{rendered}" + ); + } } diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 8695c61..d051d78 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -49,34 +49,60 @@ fn organization_action_from_selection( } async fn set_default_summary( - command: &Default, dhttp_home: &DhttpHome, current_config: Option, summary: LocalIdentitySummary, +) -> Result<(), Error> { + let mut current_config = current_config.unwrap_or_else(|| { + dhttp::home::identity::settings::DhttpSettingsFile::new(dhttp_home.settings_path()) + }); + current_config + .settings_mut() + .set_default_identity_name(summary.target.into_dhttp_name()); + cli::save_settings(¤t_config).await +} + +async fn confirm_default_target( + command: &Default, + summary: &LocalIdentitySummary, + current_default: Option<&super::epilogue::CurrentDefaultSummary>, + ansi: bool, ) -> Result<(), Error> { if !summary.status.is_ready() && !command.allow_nonready { - let confirmed = cli::prompt::sync({ - let message = format!( - "{} is {}. Set it as the default identity anyway?", - summary.target.short_name(), - summary.status.label() - ); - move || inquire::Confirm::new(&message).with_default(false).prompt() + let message = format!( + "{} is {}. Set it as the default identity anyway?", + summary.target.short_name(), + summary.status.label() + ); + let confirmed = cli::prompt::sync(move || { + inquire::Confirm::new(&message).with_default(false).prompt() }) .await .require_interactive("--allow-nonready")?; if !confirmed { whatever!("default identity was not changed"); } + return Ok(()); } - let mut current_config = current_config.unwrap_or_else(|| { - dhttp::home::identity::settings::DhttpSettingsFile::new(dhttp_home.settings_path()) - }); - current_config - .settings_mut() - .set_default_identity_name(summary.target.into_dhttp_name()); - cli::save_settings(¤t_config).await + if let Some(suggestion) = super::epilogue::suggest_default_change( + summary.target.short_name(), + current_default, + ansi, + ) { + let accepted = cli::prompt::sync(move || { + inquire::Confirm::new(&suggestion.prompt) + .with_default(suggestion.default) + .prompt() + }) + .await + .require_interactive("IDENTITY")?; + if !accepted { + whatever!("default identity was not changed"); + } + } + + Ok(()) } async fn run_helper_apply( @@ -112,6 +138,33 @@ fn helper_apply_command(target: &IdentityTarget) -> crate::cli::Apply { } } +async fn summary_for_named_default_target( + dhttp_home: &DhttpHome, + cert_server: &CertServer, + target: &IdentityTarget, + configured_default_name: Option>, +) -> Result { + if let Some(summary) = + local::try_load_summary(dhttp_home, target.dhttp_name(), configured_default_name.clone()) + .await? + { + return Ok(summary); + } + + if !std::io::stdin().is_terminal() { + whatever!( + "{} is not saved on this device.\n\nTo use it as the default identity, apply {} to this device first or rerun this command interactively.", + target.short_name(), + target.short_name(), + ); + } + + run_helper_apply(dhttp_home, cert_server, target).await?; + local::try_load_summary(dhttp_home, target.dhttp_name(), configured_default_name) + .await? + .whatever_context::<_, Error>("helper apply did not save the requested identity") +} + async fn select_interactive_default_summary( dhttp_home: &DhttpHome, cert_server: &CertServer, @@ -155,10 +208,10 @@ async fn select_interactive_default_summary( .require_interactive("IDENTITY")?; match organization_action_from_selection(&options, &selected)? { DefaultOrganizationAction::ApplyToLocalDevice => { - run_helper_apply(dhttp_home, cert_server, &target).await?; - return local::load_summary( + return summary_for_named_default_target( dhttp_home, - target.dhttp_name(), + cert_server, + &target, configured_default_name, ) .await; @@ -179,6 +232,8 @@ pub(crate) async fn run( let configured_default_name = current_config .as_ref() .and_then(|config| config.settings().default_identity_name().cloned()); + let current_default = super::epilogue::current_default_summary(dhttp_home).await?; + let ansi = std::io::stdout().is_terminal(); match command.name.as_ref() { None => { @@ -224,19 +279,23 @@ pub(crate) async fn run( .map(|default| default.borrow()), ) .await?; - set_default_summary(command, dhttp_home, current_config, selected_summary).await + confirm_default_target(command, &selected_summary, current_default.as_ref(), ansi) + .await?; + set_default_summary(dhttp_home, current_config, selected_summary).await } Some(name) => { - let name = cli::parse_identity_name(name)?; - let summary = local::load_summary( + let target = IdentityTarget::parse(name)?; + let summary = summary_for_named_default_target( dhttp_home, - name.borrow(), + cert_server, + &target, configured_default_name .as_ref() .map(|default| default.borrow()), ) .await?; - set_default_summary(command, dhttp_home, current_config, summary).await + confirm_default_target(command, &summary, current_default.as_ref(), ansi).await?; + set_default_summary(dhttp_home, current_config, summary).await } } } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 52c9034..6a40694 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -167,6 +167,20 @@ fn renew_not_saved_root_message(short_name: &str) -> String { ) } +async fn ensure_saved_renew_target( + dhttp_home: &DhttpHome, + name: dhttp::name::DhttpName<'_>, +) -> Result<(), Error> { + if local::try_load_summary(dhttp_home, name.borrow(), None) + .await? + .is_some() + { + return Ok(()); + } + + whatever!("{}", renew_not_saved_root_message(name.as_partial())); +} + fn build_renew_approval_options(target: &str) -> Vec { approval::build_approval_options(approval::ApprovalMenuSpec { email_label: "Verify with email".to_string(), @@ -413,6 +427,7 @@ async fn run_interactive( .target .clone() .whatever_context::<_, Error>("interactive renew target is unavailable")?; + ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; if state.approval_plan.is_none() { if let Some(auth) = command.auth { @@ -656,6 +671,7 @@ pub(crate) async fn run( return run_interactive(command, dhttp_home, cert_server).await; } let domain = resolve_target(command, dhttp_home).await?; + ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; let approval_plan = resolve_approval_plan(domain.as_partial(), command.auth, is_interactive).await?; let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; @@ -730,6 +746,13 @@ pub(crate) async fn run( #[cfg(test)] mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use dhttp::home::DhttpHome; + use super::{ InteractiveRenewState, RenewApprovalMenuAction, RenewApprovalPlan, RenewEmailAction, RenewVerifyCodeAction, approval_plan_from_selection, build_renew_approval_options, @@ -739,6 +762,22 @@ mod tests { }; use crate::{auth::AuthMethod, cli::Renew}; + fn unique_test_home_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-renew-{test_name}-{}-{nonce}", + std::process::id() + )) + } + + fn dummy_cert_server() -> crate::cert_server::CertServer { + _ = rustls::crypto::ring::default_provider().install_default(); + crate::cert_server::CertServer::new("https://license.genmeta.net").unwrap() + } + #[test] fn stay_recovery_keeps_renew_verify_state() { let mut state = InteractiveRenewState::from_command( @@ -840,6 +879,31 @@ Apply alice.ma to this device first, then return to renew." ); } + #[tokio::test] + async fn renew_reports_saved_local_requirement_when_named_identity_is_missing() { + let home_path = unique_test_home_path("renew-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Renew { + name: Some("alice.smith".to_string()), + use_default: false, + device_name: None, + email: None, + send_code: false, + verify_code: None, + auth: None, + }; + + let error = super::run(&command, &dhttp_home, &dummy_cert_server()) + .await + .unwrap_err(); + let rendered = error.to_string(); + + assert!( + rendered.contains("Apply alice.smith to this device first"), + "{rendered}" + ); + } + #[test] fn renew_verification_options_place_local_before_email() { let options = build_renew_approval_options("alice.ma"); From 3edb618565d6eb62577790e2c8c5154303480e3b Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 14:58:17 +0800 Subject: [PATCH 07/21] feat(xtask): bind build env per package target --- xtask/release.toml | 35 +++-- xtask/src/brew.rs | 41 +++-- xtask/src/container.rs | 16 +- xtask/src/deb.rs | 33 +++- xtask/src/package.rs | 9 +- xtask/src/package/brew.rs | 8 +- xtask/src/package/deb.rs | 3 +- xtask/src/package/rpm.rs | 3 +- xtask/src/package/scoop.rs | 8 +- xtask/src/release_contract.rs | 277 ++++++++++++++++++++++++++++++---- xtask/src/rpm.rs | 22 ++- xtask/src/scoop.rs | 18 ++- 12 files changed, 397 insertions(+), 76 deletions(-) diff --git a/xtask/release.toml b/xtask/release.toml index 2d875a7..e1c9fc0 100644 --- a/xtask/release.toml +++ b/xtask/release.toml @@ -7,15 +7,32 @@ name = "gmutils" [homebrew.template] path = "xtask/templates/gmutils.rb.in" -[build.env] -required = [ - "DHTTP_ROOT_CA", - "DHTTP_STUN_SERVER", - "DHTTP_H3_DNS_SERVER", - "DHTTP_HTTP_DNS_SERVER", - "DHTTP_MDNS_SERVICE", - "DHTTP_CERT_SERVER_URL", -] +[build.env.DHTTP_ROOT_CA] +env = "DHTTP_ROOT_CA" + +[build.env.DHTTP_STUN_SERVER] +env = "DHTTP_STUN_SERVER" + +[build.env.DHTTP_H3_DNS_SERVER] +env = "DHTTP_H3_DNS_SERVER" + +[build.env.DHTTP_HTTP_DNS_SERVER] +env = "DHTTP_HTTP_DNS_SERVER" + +[build.env.DHTTP_MDNS_SERVICE] +env = "DHTTP_MDNS_SERVICE" + +[build.env.DHTTP_CERT_SERVER_URL] +env = "DHTTP_CERT_SERVER_URL" + +[build.env.DHTTP_GLOBAL_HOME] +env = "DHTTP_GLOBAL_HOME" + +[homebrew.target.aarch64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/opt/homebrew/etc/dhttp" + +[homebrew.target.x86_64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/usr/local/etc/dhttp" [destination.s3] bucket = "download" diff --git a/xtask/src/brew.rs b/xtask/src/brew.rs index 2bf4c5d..f532394 100644 --- a/xtask/src/brew.rs +++ b/xtask/src/brew.rs @@ -1,12 +1,19 @@ #![allow(dead_code)] -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use flate2::{Compression, write::GzEncoder}; use snafu::{ResultExt, Whatever}; use tracing::{Instrument, info, info_span}; -use crate::{BrewTarget, package_meta, run_cmd, run_cmd_quiet, sha256_file, target_dir}; +use crate::{ + BrewTarget, package_meta, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + run_cmd, run_cmd_quiet, sha256_file, target_dir, +}; const CARGO_NAME: &str = "genmeta"; @@ -39,7 +46,10 @@ fn create_tar_gz(staging: &Path, output: &Path) -> Result<(), Whatever> { Ok(()) } -pub async fn run(targets: &[BrewTarget]) -> Result, Whatever> { +pub async fn run( + contract: &ReleaseContract, + targets: &[BrewTarget], +) -> Result, Whatever> { info!(target_count = targets.len(), "starting brew dist build"); let meta = package_meta(CARGO_NAME)?; let target_dir = target_dir()?; @@ -51,10 +61,12 @@ pub async fn run(targets: &[BrewTarget]) -> Result, Whatever> { let target_dir = target_dir.clone(); let workspace = workspace.clone(); let triple = target.triple(); + let build_env = resolve_build_env_from_process(contract, PackageKind::Brew, Some(triple)) + .whatever_context("failed to resolve build environment for brew target")?; info!(triple, "queued brew target build"); let span = info_span!("brew", triple); tasks.spawn( - async move { build_one(triple, &version, &target_dir, &workspace).await } + async move { build_one(triple, &version, &target_dir, &workspace, build_env).await } .instrument(span), ); } @@ -75,20 +87,25 @@ async fn build_one( version: &str, target_dir: &Path, workspace: &Path, + build_env: BTreeMap, ) -> Result { info!(triple, "checking cargo availability"); check_cargo().await?; // Build info!(triple, "starting cargo build for brew target"); - run_cmd(tokio::process::Command::new("cargo").args([ - "build", - "--release", - "--target", - triple, - "--bin", - CARGO_NAME, - ])) + run_cmd( + tokio::process::Command::new("cargo") + .envs(&build_env) + .args([ + "build", + "--release", + "--target", + triple, + "--bin", + CARGO_NAME, + ]), + ) .await .whatever_context(format!("cargo build failed for {triple}"))?; info!(triple, "cargo build finished for brew target"); diff --git a/xtask/src/container.rs b/xtask/src/container.rs index 2f40899..9121228 100644 --- a/xtask/src/container.rs +++ b/xtask/src/container.rs @@ -30,22 +30,25 @@ const DHTTP_H3_DNS_SERVER: &str = "DHTTP_H3_DNS_SERVER"; const DHTTP_HTTP_DNS_SERVER: &str = "DHTTP_HTTP_DNS_SERVER"; const DHTTP_MDNS_SERVICE: &str = "DHTTP_MDNS_SERVICE"; const DHTTP_CERT_SERVER_URL: &str = "DHTTP_CERT_SERVER_URL"; +const DHTTP_GLOBAL_HOME: &str = "DHTTP_GLOBAL_HOME"; -const DHTTP_BOOTSTRAP_VARS: [&str; 6] = [ +const DHTTP_BOOTSTRAP_VARS: [&str; 7] = [ DHTTP_ROOT_CA, DHTTP_STUN_SERVER, DHTTP_H3_DNS_SERVER, DHTTP_HTTP_DNS_SERVER, DHTTP_MDNS_SERVICE, DHTTP_CERT_SERVER_URL, + DHTTP_GLOBAL_HOME, ]; -const DHTTP_BOOTSTRAP_SCALAR_VARS: [&str; 5] = [ +const DHTTP_BOOTSTRAP_SCALAR_VARS: [&str; 6] = [ DHTTP_STUN_SERVER, DHTTP_H3_DNS_SERVER, DHTTP_HTTP_DNS_SERVER, DHTTP_MDNS_SERVICE, DHTTP_CERT_SERVER_URL, + DHTTP_GLOBAL_HOME, ]; #[derive(Debug)] @@ -487,6 +490,10 @@ mod tests { "DHTTP_CERT_SERVER_URL".to_string(), "https://license.test".to_string(), ); + values.insert( + "DHTTP_GLOBAL_HOME".to_string(), + "/opt/homebrew/etc/dhttp".to_string(), + ); let bootstrap = dhttp_bootstrap_from_values(values).expect("build bootstrap"); @@ -540,6 +547,11 @@ mod tests { .exports .contains("export DHTTP_CERT_SERVER_URL='https://license.test'\n") ); + assert!( + bootstrap + .exports + .contains("export DHTTP_GLOBAL_HOME='/opt/homebrew/etc/dhttp'\n") + ); } #[test] diff --git a/xtask/src/deb.rs b/xtask/src/deb.rs index 5874dd8..fa323aa 100644 --- a/xtask/src/deb.rs +++ b/xtask/src/deb.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +use std::collections::BTreeMap; + use bollard::{ Docker, models::{ContainerConfig, ContainerCreateBody, HostConfig}, @@ -15,11 +17,13 @@ use crate::{ BuildProfile, DebTarget, container::{ CARGO_HOME, ContainerSourceLayout, RUSTUP_HOME, ZIG_GLIBC_VERSION, cargo_cache_mounts, - cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_env, exec_in_container, + cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_values, exec_in_container, force_remove_container, host_uid_gid, install_cargo_config, remove_container_if_exists, source_layout, source_mounts, start_container, }, - package_version, target_dir, + package_version, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + target_dir, }; const CARGO_NAME: &str = "genmeta"; @@ -214,6 +218,7 @@ chmod -R a+rX {CARGO_HOME} {RUSTUP_HOME} } pub async fn run( + contract: &ReleaseContract, targets: &[DebTarget], profile: BuildProfile, siblings: &[std::path::PathBuf], @@ -233,6 +238,8 @@ pub async fn run( let version = package_version(CARGO_NAME)?; let target_dir = target_dir()?; + let build_env = resolve_build_env_from_process(contract, PackageKind::Deb, None) + .whatever_context("failed to resolve build environment for deb packaging")?; let mut tasks = tokio::task::JoinSet::new(); @@ -241,6 +248,7 @@ pub async fn run( let version = version.clone(); let target_dir = target_dir.clone(); let layout = layout.clone(); + let build_env = build_env.clone(); let triple = target.triple(); info!( triple, @@ -250,7 +258,16 @@ pub async fn run( let span = info_span!("deb", triple); tasks.spawn( async move { - build_one_with_retry(&docker, triple, &version, &target_dir, profile, &layout).await + build_one_with_retry( + &docker, + triple, + &version, + &target_dir, + profile, + &layout, + build_env, + ) + .await } .instrument(span), ); @@ -275,9 +292,14 @@ async fn build_one_with_retry( target_dir: &std::path::Path, profile: BuildProfile, layout: &ContainerSourceLayout, + build_env: BTreeMap, ) -> Result { for attempt in 1..=BUILD_ATTEMPTS { - match build_one(docker, triple, version, target_dir, profile, layout).await { + match build_one( + docker, triple, version, target_dir, profile, layout, &build_env, + ) + .await + { Ok(artifact) => return Ok(artifact), Err(error) if attempt < BUILD_ATTEMPTS => { let report = Report::from_error(&error); @@ -302,6 +324,7 @@ async fn build_one( target_dir: &std::path::Path, profile: BuildProfile, layout: &ContainerSourceLayout, + build_env: &BTreeMap, ) -> Result { let arch = deb_arch(triple)?; let gnu = gnu_arch(triple)?; @@ -318,7 +341,7 @@ async fn build_one( let mut mounts = source_mounts(layout); mounts.extend(cargo_cache_mounts()); - let bootstrap = dhttp_bootstrap_from_env()?; + let bootstrap = dhttp_bootstrap_from_values(build_env.clone())?; mounts.extend(bootstrap.mounts); let cargo_config = cargo_config_from_siblings(&layout.overrides); diff --git a/xtask/src/package.rs b/xtask/src/package.rs index 55c78ef..cfd9e41 100644 --- a/xtask/src/package.rs +++ b/xtask/src/package.rs @@ -79,8 +79,6 @@ pub fn parse_package_sections(tokens: &[OsString]) -> Result, pub async fn run(options: PackageOptions) -> Result<(), Whatever> { let contract = crate::release_contract::load_release_contract() .whatever_context("failed to load release contract")?; - crate::release_contract::validate_required_build_env(&contract) - .whatever_context("failed to validate build environment")?; let formats = parse_package_sections(&options.targets).unwrap_or_else(|error| error.exit()); for format in formats { match format { @@ -90,6 +88,7 @@ pub async fn run(options: PackageOptions) -> Result<(), Whatever> { siblings, } => { deb::run( + &contract, &targets, crate::BuildProfile::from_debug(debug), &siblings, @@ -98,13 +97,13 @@ pub async fn run(options: PackageOptions) -> Result<(), Whatever> { .await? } PackageFormat::Rpm { targets, siblings } => { - rpm::run(&targets, &siblings, options.overwrite_manifest).await? + rpm::run(&contract, &targets, &siblings, options.overwrite_manifest).await? } PackageFormat::Brew { targets } => { - brew::run(&targets, options.overwrite_manifest).await? + brew::run(&contract, &targets, options.overwrite_manifest).await? } PackageFormat::Scoop { targets } => { - scoop::run(&targets, options.overwrite_manifest).await? + scoop::run(&contract, &targets, options.overwrite_manifest).await? } } } diff --git a/xtask/src/package/brew.rs b/xtask/src/package/brew.rs index 9c960b2..5adb5a3 100644 --- a/xtask/src/package/brew.rs +++ b/xtask/src/package/brew.rs @@ -76,8 +76,12 @@ fn target_relative_path(path: &Path) -> Result { .ok_or(BrewPackageError::ArtifactPathUtf8) } -pub async fn run(targets: &[BrewTarget], overwrite_manifest: bool) -> Result<(), Whatever> { - let archives = crate::brew::run(targets).await?; +pub async fn run( + contract: &crate::release_contract::ReleaseContract, + targets: &[BrewTarget], + overwrite_manifest: bool, +) -> Result<(), Whatever> { + let archives = crate::brew::run(contract, targets).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir.join("common").join("brew").join("manifest.toml"); diff --git a/xtask/src/package/deb.rs b/xtask/src/package/deb.rs index 50eba93..1d3a41f 100644 --- a/xtask/src/package/deb.rs +++ b/xtask/src/package/deb.rs @@ -104,12 +104,13 @@ async fn read_deb_metadata(path: &Path) -> Result Result<(), Whatever> { - let deb_artifacts = crate::deb::run(targets, profile, siblings).await?; + let deb_artifacts = crate::deb::run(contract, targets, profile, siblings).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir.join("common").join("deb").join("manifest.toml"); diff --git a/xtask/src/package/rpm.rs b/xtask/src/package/rpm.rs index c895c19..8cb30a9 100644 --- a/xtask/src/package/rpm.rs +++ b/xtask/src/package/rpm.rs @@ -100,11 +100,12 @@ async fn read_rpm_metadata(path: &Path) -> Result Result<(), Whatever> { - let rpm_artifacts = crate::rpm::run(targets, siblings).await?; + let rpm_artifacts = crate::rpm::run(contract, targets, siblings).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir.join("common").join("rpm").join("manifest.toml"); diff --git a/xtask/src/package/scoop.rs b/xtask/src/package/scoop.rs index 6a164d1..a74af46 100644 --- a/xtask/src/package/scoop.rs +++ b/xtask/src/package/scoop.rs @@ -76,8 +76,12 @@ fn target_relative_path(path: &Path) -> Result { .ok_or(ScoopPackageError::ArtifactPathUtf8) } -pub async fn run(targets: &[ScoopTarget], overwrite_manifest: bool) -> Result<(), Whatever> { - let archives = crate::scoop::run(targets).await?; +pub async fn run( + contract: &crate::release_contract::ReleaseContract, + targets: &[ScoopTarget], + overwrite_manifest: bool, +) -> Result<(), Whatever> { + let archives = crate::scoop::run(contract, targets).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir diff --git a/xtask/src/release_contract.rs b/xtask/src/release_contract.rs index 2bbc537..7190ebb 100644 --- a/xtask/src/release_contract.rs +++ b/xtask/src/release_contract.rs @@ -32,6 +32,14 @@ pub struct PackageOverride { #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct HomebrewContract { pub template: TemplateContract, + #[serde(default)] + pub target: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +pub struct HomebrewTargetContract { + #[serde(default)] + pub env: BTreeMap, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -44,14 +52,16 @@ pub struct TemplateContract { pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] pub struct BuildContract { - pub env: BuildEnvContract, + #[serde(default)] + pub env: BTreeMap, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BuildEnvContract { - pub required: Vec, +pub struct BuildEnvBinding { + pub env: Option, + pub value: Option, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -126,6 +136,14 @@ pub struct ResolvedPackageMetadata { pub authors: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageKind { + Deb, + Rpm, + Brew, + Scoop, +} + #[derive(Debug, Snafu)] #[snafu(module)] pub enum ReleaseContractError { @@ -152,6 +170,8 @@ pub enum ReleaseContractError { MissingHomepage { manifest: PathBuf }, #[snafu(display("cargo package is missing license"))] MissingLicense { manifest: PathBuf }, + #[snafu(display("build env binding {name} must set exactly one of env or value"))] + InvalidBuildEnvBinding { name: String }, #[snafu(display("missing required build environment variable {name}"))] MissingBuildEnv { name: String }, #[snafu(display("build environment variable {name} must not be empty"))] @@ -188,9 +208,21 @@ fn parse_release_contract_at( path: &Path, input: &str, ) -> Result { - toml::from_str(input).context(release_contract_error::ParseSnafu { + let contract = toml::from_str(input).context(release_contract_error::ParseSnafu { path: path.to_path_buf(), - }) + })?; + validate_release_contract(&contract)?; + Ok(contract) +} + +fn validate_release_contract(contract: &ReleaseContract) -> Result<(), ReleaseContractError> { + validate_build_env_bindings(&contract.build.env)?; + if let Some(homebrew) = &contract.homebrew { + for target in homebrew.target.values() { + validate_build_env_bindings(&target.env)?; + } + } + Ok(()) } pub fn resolve_package_metadata( @@ -243,35 +275,115 @@ pub fn resolve_package_metadata( }) } -pub fn validate_required_build_env(contract: &ReleaseContract) -> Result<(), ReleaseContractError> { +pub fn resolve_build_env_from_process( + contract: &ReleaseContract, + package_kind: PackageKind, + target: Option<&str>, +) -> Result, ReleaseContractError> { let values = std::env::vars().collect::>(); - validate_required_build_env_values(contract, &values) + resolve_build_env_values(contract, package_kind, target, &values) } -pub fn validate_required_build_env_values( +pub fn resolve_build_env_values( contract: &ReleaseContract, + package_kind: PackageKind, + target: Option<&str>, values: &BTreeMap, -) -> Result<(), ReleaseContractError> { - for name in &contract.build.env.required { - match values.get(name) { - Some(value) if value.is_empty() => { - return Err(ReleaseContractError::EmptyBuildEnv { name: name.clone() }); - } - Some(_) => {} - None => { - return Err(ReleaseContractError::MissingBuildEnv { name: name.clone() }); - } +) -> Result, ReleaseContractError> { + let mut bindings = contract.build.env.clone(); + if package_kind == PackageKind::Brew + && let (Some(homebrew), Some(target)) = (&contract.homebrew, target) + && let Some(target_contract) = homebrew.target.get(target) + { + bindings.extend(target_contract.env.clone()); + } + + let mut resolved = BTreeMap::new(); + for (name, binding) in bindings { + if let Some(value) = resolve_build_env_binding_value(&name, &binding, values)? { + resolved.insert(name, value); } } + Ok(resolved) +} + +fn validate_build_env_bindings( + bindings: &BTreeMap, +) -> Result<(), ReleaseContractError> { + for (name, binding) in bindings { + validate_build_env_binding(name, binding)?; + } Ok(()) } +fn validate_build_env_binding( + name: &str, + binding: &BuildEnvBinding, +) -> Result<(), ReleaseContractError> { + match (&binding.env, &binding.value) { + (Some(_), None) | (None, Some(_)) => Ok(()), + _ => Err(ReleaseContractError::InvalidBuildEnvBinding { + name: name.to_owned(), + }), + } +} + +fn resolve_build_env_binding_value( + name: &str, + binding: &BuildEnvBinding, + values: &BTreeMap, +) -> Result, ReleaseContractError> { + validate_build_env_binding(name, binding)?; + + if let Some(env_name) = &binding.env { + return resolve_env_ref(name, env_name, values); + } + + let value = binding + .value + .as_ref() + .expect("validated build env binding must have a value"); + if value.is_empty() { + return Err(ReleaseContractError::EmptyBuildEnv { + name: name.to_owned(), + }); + } + Ok(Some(value.clone())) +} + +fn resolve_env_ref( + logical_name: &str, + env_name: &str, + values: &BTreeMap, +) -> Result, ReleaseContractError> { + let Some(value) = values.get(env_name) else { + if build_env_is_optional(logical_name) { + return Ok(None); + } + return Err(ReleaseContractError::MissingBuildEnv { + name: env_name.to_owned(), + }); + }; + + if value.is_empty() { + return Err(ReleaseContractError::EmptyBuildEnv { + name: env_name.to_owned(), + }); + } + + Ok(Some(value.clone())) +} + +fn build_env_is_optional(name: &str) -> bool { + matches!(name, "DHTTP_GLOBAL_HOME") +} + #[cfg(test)] mod tests { use std::{collections::BTreeMap, path::Path}; use super::{ - ReleaseContractError, parse_release_contract_at, validate_required_build_env_values, + PackageKind, ReleaseContractError, parse_release_contract_at, resolve_build_env_values, }; const CONTRACT: &str = r#" @@ -284,8 +396,32 @@ name = "gmutils" [homebrew.template] path = "xtask/templates/gmutils.rb.in" -[build.env] -required = ["DHTTP_ROOT_CA", "DHTTP_STUN_SERVER"] +[build.env.DHTTP_ROOT_CA] +env = "DHTTP_ROOT_CA" + +[build.env.DHTTP_STUN_SERVER] +env = "DHTTP_STUN_SERVER" + +[build.env.DHTTP_H3_DNS_SERVER] +env = "DHTTP_H3_DNS_SERVER" + +[build.env.DHTTP_HTTP_DNS_SERVER] +env = "DHTTP_HTTP_DNS_SERVER" + +[build.env.DHTTP_MDNS_SERVICE] +env = "DHTTP_MDNS_SERVICE" + +[build.env.DHTTP_CERT_SERVER_URL] +env = "DHTTP_CERT_SERVER_URL" + +[build.env.DHTTP_GLOBAL_HOME] +env = "DHTTP_GLOBAL_HOME" + +[homebrew.target.aarch64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/opt/homebrew/etc/dhttp" + +[homebrew.target.x86_64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/usr/local/etc/dhttp" [destination.s3] bucket = "download" @@ -344,17 +480,100 @@ public_base_url = "https://download.dhttp.net/scoop/gmutils" } #[test] - fn rejects_missing_required_build_env() { + fn resolves_homebrew_target_override() { let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) .expect("contract should parse"); - let values = BTreeMap::new(); - - let error = validate_required_build_env_values(&contract, &values) - .expect_err("missing DHTTP_ROOT_CA should fail"); + let values = BTreeMap::from([ + ("DHTTP_ROOT_CA".to_string(), "/tmp/root.crt".to_string()), + ( + "DHTTP_STUN_SERVER".to_string(), + "nat.genmeta.net:20004".to_string(), + ), + ( + "DHTTP_H3_DNS_SERVER".to_string(), + "https://dns.genmeta.net:4433".to_string(), + ), + ( + "DHTTP_HTTP_DNS_SERVER".to_string(), + "https://dns.genmeta.net".to_string(), + ), + ("DHTTP_MDNS_SERVICE".to_string(), "_dhttp.local".to_string()), + ( + "DHTTP_CERT_SERVER_URL".to_string(), + "https://license.genmeta.net".to_string(), + ), + ( + "DHTTP_GLOBAL_HOME".to_string(), + "/runtime/should-be-overridden".to_string(), + ), + ]); + + let resolved = resolve_build_env_values( + &contract, + PackageKind::Brew, + Some("aarch64-apple-darwin"), + &values, + ) + .expect("build env should resolve"); assert_eq!( - error.to_string(), - "missing required build environment variable DHTTP_ROOT_CA" + resolved.get("DHTTP_GLOBAL_HOME").map(String::as_str), + Some("/opt/homebrew/etc/dhttp") ); } + + #[test] + fn skips_missing_optional_global_home_env() { + let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) + .expect("contract should parse"); + let values = BTreeMap::from([ + ("DHTTP_ROOT_CA".to_string(), "/tmp/root.crt".to_string()), + ( + "DHTTP_STUN_SERVER".to_string(), + "nat.genmeta.net:20004".to_string(), + ), + ( + "DHTTP_H3_DNS_SERVER".to_string(), + "https://dns.genmeta.net:4433".to_string(), + ), + ( + "DHTTP_HTTP_DNS_SERVER".to_string(), + "https://dns.genmeta.net".to_string(), + ), + ("DHTTP_MDNS_SERVICE".to_string(), "_dhttp.local".to_string()), + ( + "DHTTP_CERT_SERVER_URL".to_string(), + "https://license.genmeta.net".to_string(), + ), + ]); + + let resolved = resolve_build_env_values(&contract, PackageKind::Deb, None, &values) + .expect("optional global home may be absent"); + + assert!(!resolved.contains_key("DHTTP_GLOBAL_HOME")); + } + + #[test] + fn rejects_binding_with_env_and_value() { + let error = parse_release_contract_at( + Path::new("xtask/release.toml"), + r#" +[cargo] +manifest = "genmeta/Cargo.toml" + +[build.env.DHTTP_STUN_SERVER] +env = "DHTTP_STUN_SERVER" +value = "nat.genmeta.net:20004" + +[destination.s3] +bucket = "download" +endpoint.env = "XTASK_RELEASE_S3_ENDPOINT_URL" +access_key_id.env = "XTASK_RELEASE_S3_ACCESS_KEY_ID" +secret_access_key.env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY" +"#, + ) + .expect_err("conflicting binding must fail"); + + assert!(error.to_string().contains("DHTTP_STUN_SERVER")); + } } diff --git a/xtask/src/rpm.rs b/xtask/src/rpm.rs index ceb1f16..448f417 100644 --- a/xtask/src/rpm.rs +++ b/xtask/src/rpm.rs @@ -12,7 +12,10 @@ //! 5. Move the produced `.rpm` next to the `.deb` outputs under //! `target/{triple}/release/rpm/`. -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use bollard::{ Docker, @@ -29,11 +32,13 @@ use crate::{ RpmTarget, container::{ CARGO_HOME, ContainerSourceLayout, RUSTUP_HOME, ZIG_GLIBC_VERSION, cargo_cache_mounts, - cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_env, exec_in_container, + cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_values, exec_in_container, force_remove_container, host_uid_gid, install_cargo_config, remove_container_if_exists, source_layout, source_mounts, start_container, }, - package_version, target_dir, + package_version, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + target_dir, }; const CARGO_NAME: &str = "genmeta"; @@ -129,6 +134,7 @@ fn aarch64_zigbuild_workaround_script(triple: &str) -> String { } pub async fn run( + contract: &ReleaseContract, targets: &[RpmTarget], siblings: &[std::path::PathBuf], ) -> Result, Whatever> { @@ -140,6 +146,8 @@ pub async fn run( let layout = source_layout("gmutils", siblings)?; let version = package_version(CARGO_NAME)?; let target_dir = target_dir()?; + let build_env = resolve_build_env_from_process(contract, PackageKind::Rpm, None) + .whatever_context("failed to resolve build environment for rpm packaging")?; let mut tasks = tokio::task::JoinSet::new(); for &target in targets { @@ -147,11 +155,14 @@ pub async fn run( let version = version.clone(); let target_dir = target_dir.clone(); let layout = layout.clone(); + let build_env = build_env.clone(); let triple = target.triple(); info!(triple, "queued rpm target build"); let span = info_span!("rpm", triple); tasks.spawn( - async move { build_one(&docker, triple, &version, &target_dir, &layout).await } + async move { + build_one(&docker, triple, &version, &target_dir, &layout, &build_env).await + } .instrument(span), ); } @@ -281,6 +292,7 @@ async fn build_one( version: &str, target_dir: &Path, layout: &ContainerSourceLayout, + build_env: &BTreeMap, ) -> Result { let arch = rpm_arch(triple)?; info!(triple, arch, "ensuring build image"); @@ -294,7 +306,7 @@ async fn build_one( let mut mounts = source_mounts(layout); mounts.extend(cargo_cache_mounts()); - let bootstrap = dhttp_bootstrap_from_env()?; + let bootstrap = dhttp_bootstrap_from_values(build_env.clone())?; mounts.extend(bootstrap.mounts); let cargo_config = cargo_config_from_siblings(&layout.overrides); diff --git a/xtask/src/scoop.rs b/xtask/src/scoop.rs index 9ef1730..54ff481 100644 --- a/xtask/src/scoop.rs +++ b/xtask/src/scoop.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use std::{ + collections::BTreeMap, io::Write, path::{Path, PathBuf}, }; @@ -8,7 +9,11 @@ use std::{ use snafu::{OptionExt, ResultExt, Whatever}; use tracing::{Instrument, info, info_span}; -use crate::{ScoopTarget, package_meta, run_cmd, run_cmd_quiet, target_dir}; +use crate::{ + ScoopTarget, package_meta, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + run_cmd, run_cmd_quiet, target_dir, +}; const CARGO_NAME: &str = "genmeta"; @@ -57,7 +62,10 @@ fn create_zip(staging: &Path, output: &Path) -> Result<(), Whatever> { Ok(()) } -pub async fn run(targets: &[ScoopTarget]) -> Result, Whatever> { +pub async fn run( + contract: &ReleaseContract, + targets: &[ScoopTarget], +) -> Result, Whatever> { info!(target_count = targets.len(), "starting scoop dist build"); let meta = package_meta(CARGO_NAME)?; let target_dir = target_dir()?; @@ -75,7 +83,9 @@ pub async fn run(targets: &[ScoopTarget]) -> Result, Whatever> for &target in targets { let triple = target.triple(); let span = info_span!("scoop", triple); - let archive = build_one(target, &meta.version, &target_dir, &workspace) + let build_env = resolve_build_env_from_process(contract, PackageKind::Scoop, Some(triple)) + .whatever_context("failed to resolve build environment for scoop target")?; + let archive = build_one(target, &meta.version, &target_dir, &workspace, build_env) .instrument(span) .await?; archives.push(archive); @@ -91,6 +101,7 @@ async fn build_one( version: &str, target_dir: &Path, workspace: &Path, + build_env: BTreeMap, ) -> Result { let triple = target.triple(); info!(triple, "starting cargo-xwin build for scoop target"); @@ -100,6 +111,7 @@ async fn build_one( // Pinning XWIN_ARCH=x86,x86_64 ensures both architectures are present on first splat. run_cmd( tokio::process::Command::new("cargo-xwin") + .envs(&build_env) .env("XWIN_ARCH", "x86,x86_64") .args([ "build", From eff8bdc22f7f5f0ac244cc542af69862ed36c6b4 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 14:58:43 +0800 Subject: [PATCH 08/21] style(identity): format updated identity flows --- genmeta-identity/src/cli.rs | 11 +++++-- genmeta-identity/src/cli/flow/apply.rs | 6 +++- .../src/cli/flow/default_identity.rs | 29 +++++++++---------- genmeta-identity/src/cli/flow/epilogue.rs | 12 ++------ genmeta-identity/src/cli/flow/output.rs | 11 ++++--- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index a9fab0f..5518b50 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -532,7 +532,11 @@ mod tests { }; use clap::{CommandFactory, Parser}; - use dhttp::{home::DhttpHome, identity::Identity, name::DhttpName, name::Name}; + use dhttp::{ + home::DhttpHome, + identity::Identity, + name::{DhttpName, Name}, + }; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use super::{ @@ -752,7 +756,10 @@ mod tests { name: Some("alice.smith".to_string()), }; - let error = command.run(&dhttp_home, &dummy_cert_server()).await.unwrap_err(); + let error = command + .run(&dhttp_home, &dummy_cert_server()) + .await + .unwrap_err(); let rendered = error.to_string(); assert!( diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 0594129..31930e6 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -385,7 +385,11 @@ fn apply_identity_name_opening() -> &'static str { fn explicit_target_from_command( command: &Apply, ) -> Result>, Error> { - command.name.as_deref().map(cli::parse_identity_name).transpose() + command + .name + .as_deref() + .map(cli::parse_identity_name) + .transpose() } async fn prompt_apply_target() -> Result, Error> { diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index d051d78..b5a8995 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -74,22 +74,19 @@ async fn confirm_default_target( summary.target.short_name(), summary.status.label() ); - let confirmed = cli::prompt::sync(move || { - inquire::Confirm::new(&message).with_default(false).prompt() - }) - .await - .require_interactive("--allow-nonready")?; + let confirmed = + cli::prompt::sync(move || inquire::Confirm::new(&message).with_default(false).prompt()) + .await + .require_interactive("--allow-nonready")?; if !confirmed { whatever!("default identity was not changed"); } return Ok(()); } - if let Some(suggestion) = super::epilogue::suggest_default_change( - summary.target.short_name(), - current_default, - ansi, - ) { + if let Some(suggestion) = + super::epilogue::suggest_default_change(summary.target.short_name(), current_default, ansi) + { let accepted = cli::prompt::sync(move || { inquire::Confirm::new(&suggestion.prompt) .with_default(suggestion.default) @@ -144,9 +141,12 @@ async fn summary_for_named_default_target( target: &IdentityTarget, configured_default_name: Option>, ) -> Result { - if let Some(summary) = - local::try_load_summary(dhttp_home, target.dhttp_name(), configured_default_name.clone()) - .await? + if let Some(summary) = local::try_load_summary( + dhttp_home, + target.dhttp_name(), + configured_default_name.clone(), + ) + .await? { return Ok(summary); } @@ -302,12 +302,11 @@ pub(crate) async fn run( #[cfg(test)] mod tests { - use crate::cli::flow::target::IdentityTarget; - use super::{ DefaultOrganizationAction, default_organization_actions, helper_apply_command, organization_action_from_selection, }; + use crate::cli::flow::target::IdentityTarget; #[test] fn organization_action_menu_matches_spec_copy() { diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index 16f9452..91f4648 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -118,11 +118,8 @@ pub(crate) async fn run_lifecycle_epilogue( transcript::print_line(output::format_safekeeping_reminder(ansi)); if interactive - && let Some(suggestion) = suggest_default_change( - name.as_partial(), - current_default.as_ref(), - ansi, - ) + && let Some(suggestion) = + suggest_default_change(name.as_partial(), current_default.as_ref(), ansi) { let accepted = crate::cli::prompt::sync(move || { inquire::Confirm::new(&suggestion.prompt) @@ -175,10 +172,7 @@ mod tests { use tokio::fs; use super::{CurrentDefaultSummary, DefaultSuggestion, default_block, suggest_default_change}; - use crate::cli::flow::{ - local::LocalIdentityStatus, - output::DefaultIdentityBlock, - }; + use crate::cli::flow::{local::LocalIdentityStatus, output::DefaultIdentityBlock}; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 4f32778..308ffc3 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -120,7 +120,10 @@ pub(crate) fn format_current_default_suffix( ansi: bool, ) -> String { render_line( - format!("(current: {})", compact_identity_label_parts(name, status, false)), + format!( + "(current: {})", + compact_identity_label_parts(name, status, false) + ), LineStyle::Dim, ansi, ) @@ -288,9 +291,9 @@ mod tests { use std::path::PathBuf; use super::{ - DefaultIdentityBlock, LineStyle, format_current_default_suffix, - format_default_identity_block, format_default_summary, format_info, - render_choice_label, render_inventory, summary_line_style, compact_identity_label, + DefaultIdentityBlock, LineStyle, compact_identity_label, format_current_default_suffix, + format_default_identity_block, format_default_summary, format_info, render_choice_label, + render_inventory, summary_line_style, }; use crate::cli::flow::{ local::{ From 460151ee6ba5d2b146d7a92f7971550f1ee17d47 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 16:49:21 +0800 Subject: [PATCH 09/21] feat(identity): add scoped dhttp home support --- genmeta-identity/src/cli.rs | 94 +++++++++++++++---- genmeta-identity/src/cli/flow/apply.rs | 10 +- genmeta-identity/src/cli/flow/approval.rs | 12 +-- genmeta-identity/src/cli/flow/create.rs | 19 ++-- .../src/cli/flow/default_identity.rs | 22 ++--- genmeta-identity/src/cli/flow/epilogue.rs | 13 +-- genmeta-identity/src/cli/flow/kind.rs | 5 +- genmeta-identity/src/cli/flow/output.rs | 10 +- genmeta-identity/src/cli/flow/renew.rs | 29 +++--- genmeta-identity/src/lib.rs | 2 +- genmeta-identity/src/main.rs | 6 +- genmeta/src/main.rs | 17 ++-- 12 files changed, 143 insertions(+), 96 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 5518b50..51f5990 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -8,7 +8,7 @@ use clap::Parser; use dhttp::{ certificate::CertificateChainKey, home::{ - DhttpHome, + DhttpHome, HomeScope, LoadDhttpHomeError, identity::{ settings::{DhttpSettingsFile, LoadDhttpSettingsError, SaveDhttpSettingsError}, ssl::{ @@ -90,10 +90,8 @@ pub enum Error { source: Box, }, - #[snafu(transparent)] - LocateDhttpHome { - source: dhttp::home::LocateDhttpHomeError, - }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: LoadDhttpHomeError }, #[snafu(transparent)] Whatever { source: Whatever }, @@ -393,7 +391,7 @@ impl List { ) .await?; if inventory.groups.is_empty() { - flow::transcript::print_line("No local identities found"); + flow::transcript::print_line("No identities found here"); } else { flow::transcript::print_block(&flow::output::render_inventory( &inventory, @@ -441,7 +439,7 @@ impl Info { .await? else { whatever!( - "{} is not saved on this device.\n\nTo inspect it locally, apply {} to this device first.", + "{} is not saved here.\n\nTo inspect it here, apply {} here first.", name.as_partial(), name.as_partial(), ); @@ -454,6 +452,31 @@ impl Info { Ok(()) } } + +#[derive(Parser, Debug, Clone)] +#[command(version, about, disable_help_flag = true, disable_version_flag = true)] +pub struct Cli { + #[arg( + long, + global = true, + help = "use the global dhttp home instead of the default user home" + )] + pub global: bool, + + #[command(subcommand)] + pub options: Options, +} + +impl Cli { + pub fn home_scope(&self) -> HomeScope { + if self.global { + HomeScope::Global + } else { + HomeScope::User + } + } +} + #[derive(Parser, Debug, Clone)] #[command(about, disable_help_flag = true, disable_version_flag = true)] pub enum Options { @@ -467,6 +490,13 @@ pub enum Options { } impl Options { + pub fn writes_home(&self) -> bool { + matches!( + self, + Self::Create(_) | Self::Apply(_) | Self::Renew(_) | Self::Default(_) + ) + } + pub async fn run(&self, dhttp_home: &DhttpHome, cert_server: &CertServer) -> Result<(), Error> { match self { Options::Create(cmd) => flow::run_create(cmd, dhttp_home, cert_server).await, @@ -513,15 +543,22 @@ fn cert_server_base_url() -> &'static str { CERT_SERVER_BASE_URL } -pub async fn run(options: Options) -> Result<(), Error> { +pub async fn run(options: Cli) -> Result<(), Error> { init_tracing(); - let dhttp_home = DhttpHome::load_from_environment()?; + let dhttp_home = DhttpHome::load(options.home_scope()).context(LoadDhttpHomeSnafu)?; + + if options.global && options.options.writes_home() { + tracing::warn!( + path = %dhttp_home.as_path().display(), + "using the global dhttp home; this operation may require elevated privileges" + ); + } _ = rustls::crypto::ring::default_provider().install_default(); let cert_server = CertServer::new(cert_server_base_url())?; - options.run(&dhttp_home, &cert_server).await + options.options.run(&dhttp_home, &cert_server).await } #[cfg(test)] @@ -540,7 +577,8 @@ mod tests { use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use super::{ - Create, Default, Info, Options, cert_server_base_url, certificate_chain_key_from_identity, + Cli, Create, Default, Info, Options, cert_server_base_url, + certificate_chain_key_from_identity, }; use crate::CERT_SERVER_BASE_URL; @@ -763,13 +801,10 @@ mod tests { let rendered = error.to_string(); assert!( - rendered.contains("alice.smith is not saved on this device"), - "{rendered}" - ); - assert!( - rendered.contains("apply alice.smith to this device first"), + rendered.contains("alice.smith is not saved here"), "{rendered}" ); + assert!(rendered.contains("apply alice.smith here first"), "{rendered}"); } #[tokio::test] @@ -788,8 +823,33 @@ mod tests { let rendered = error.to_string(); assert!( - rendered.contains("alice.smith is not saved on this device"), + rendered.contains("alice.smith is not saved here"), "{rendered}" ); } + + #[test] + fn cli_accepts_global_before_and_after_subcommand() { + let before = Cli::try_parse_from(["genmeta", "--global", "list"]).unwrap(); + let after = Cli::try_parse_from(["genmeta", "list", "--global"]).unwrap(); + + assert_eq!(before.home_scope(), dhttp::home::HomeScope::Global); + assert_eq!(after.home_scope(), dhttp::home::HomeScope::Global); + } + + #[test] + fn write_commands_are_marked_for_global_warning() { + for argv in [ + ["genmeta", "create", "alice.smith", "--kind", "primary"].as_slice(), + ["genmeta", "apply", "alice.smith", "--kind", "primary"].as_slice(), + ["genmeta", "renew", "alice.smith"].as_slice(), + ["genmeta", "default", "alice.smith"].as_slice(), + ] { + let cli = Cli::try_parse_from(argv).unwrap(); + assert!(cli.options.writes_home()); + } + + let info = Cli::try_parse_from(["genmeta", "info", "alice.smith"]).unwrap(); + assert!(!info.options.writes_home()); + } } diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 31930e6..49d3fe4 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -379,7 +379,7 @@ async fn resolve_approval_plan( } fn apply_identity_name_opening() -> &'static str { - "Apply an existing identity to this device.\n\nThis will create a new certificate chain for an existing identity\nand save it on this device.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo apply a sub-identity, add one more name before it:\n phone.alice.smith" + "Apply an existing identity here.\n\nThis will create a new certificate chain for an existing identity\nand save it here.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo apply a sub-identity, add one more name before it:\n phone.alice.smith" } fn explicit_target_from_command( @@ -1202,7 +1202,7 @@ mod tests { #[test] fn apply_identity_name_opening_matches_spec_copy() { let opening = apply_identity_name_opening(); - assert!(opening.contains("Apply an existing identity to this device.")); + assert!(opening.contains("Apply an existing identity here.")); assert!(opening.contains(".")); assert!(opening.contains("alice.smith")); assert!(opening.contains("phone.alice.smith")); @@ -1223,7 +1223,7 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Re-apply alice.smith to this device, then verify with alice.smith".to_string(), + "Re-apply alice.smith here, then verify with alice.smith".to_string(), ] ); } @@ -1244,8 +1244,8 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Renew alice.smith on this device, then verify with alice.smith".to_string(), - "Re-apply alice.smith to this device, then verify with alice.smith".to_string(), + "Renew alice.smith here, then verify with alice.smith".to_string(), + "Re-apply alice.smith here, then verify with alice.smith".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/approval.rs b/genmeta-identity/src/cli/flow/approval.rs index 2df8d9b..aee0918 100644 --- a/genmeta-identity/src/cli/flow/approval.rs +++ b/genmeta-identity/src/cli/flow/approval.rs @@ -163,15 +163,15 @@ impl ApprovalHelperOption { pub(crate) fn label(&self) -> String { match self.action { ApprovalHelperAction::Apply => format!( - "Apply {} to this device, then verify with {}", + "Apply {} here, then verify with {}", self.short_name, self.short_name ), ApprovalHelperAction::Reapply => format!( - "Re-apply {} to this device, then verify with {}", + "Re-apply {} here, then verify with {}", self.short_name, self.short_name ), ApprovalHelperAction::Renew => format!( - "Renew {} on this device, then verify with {}", + "Renew {} here, then verify with {}", self.short_name, self.short_name ), } @@ -342,8 +342,8 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Renew alice.smith on this device, then verify with alice.smith".to_string(), - "Re-apply alice.smith to this device, then verify with alice.smith".to_string(), + "Renew alice.smith here, then verify with alice.smith".to_string(), + "Re-apply alice.smith here, then verify with alice.smith".to_string(), ] ); } @@ -354,7 +354,7 @@ mod tests { assert_eq!( option.label(), - "Apply alice.smith to this device, then verify with alice.smith" + "Apply alice.smith here, then verify with alice.smith" ); } } diff --git a/genmeta-identity/src/cli/flow/create.rs b/genmeta-identity/src/cli/flow/create.rs index 1b9f315..3d3a697 100644 --- a/genmeta-identity/src/cli/flow/create.rs +++ b/genmeta-identity/src/cli/flow/create.rs @@ -315,7 +315,7 @@ fn resolve_non_interactive_approval_plan( Some(AuthMethod::Identity) => { let Some(ready_parent_identity) = ready_parent_identity else { whatever!( - "creating {} with --auth identity requires a ready local parent identity on this device", + "creating {} with --auth identity requires a ready parent identity saved here", target.short_name() ); }; @@ -434,7 +434,7 @@ fn ensure_non_interactive_sub_identity_checkout_not_required( } fn create_identity_name_opening() -> &'static str { - "Create a new identity for this device.\n\nThis will create a new identity or sub-identity, complete the required verification,\nand save it on this device.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo create a sub-identity, add one more name before it:\n phone.alice.smith" + "Create a new identity here.\n\nThis will create a new identity or sub-identity, complete the required verification,\nand save it here.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo create a sub-identity, add one more name before it:\n phone.alice.smith" } async fn prompt_create_email_action( @@ -598,7 +598,7 @@ async fn ensure_parent_identity_ready( match summary.status { LocalIdentityStatus::Ready { .. } => Ok(parent), _ => whatever!( - "creating {} with --auth identity requires a ready local parent identity at {}", + "creating {} with --auth identity requires a ready parent identity saved here at {}", target.short_name(), summary.saved_at.display() ), @@ -669,9 +669,9 @@ async fn run_helper_apply_parent( .unwrap_or_else(|_| parent_identity.to_string()); let verb = if replace_local { "re-apply" } else { "apply" }; crate::cli::flow::transcript::print_block(&format!( - "This command needs {short_parent_identity} available on this device first. + "This command needs {short_parent_identity} saved here first. -To continue creating {}, it will {verb} {short_parent_identity} on this device, then return here and continue verification.", +To continue creating {}, it will {verb} {short_parent_identity} here, then return here and continue verification.", target.short_name() )); let command = crate::cli::Apply { @@ -1775,10 +1775,7 @@ mod tests { resolve_non_interactive_approval_plan(&target, Some(AuthMethod::Identity), None) .unwrap_err(); let rendered = error.to_string(); - assert!( - rendered.contains("ready local parent identity"), - "{rendered}" - ); + assert!(rendered.contains("ready parent identity saved here"), "{rendered}"); assert!(rendered.contains("phone.alice.smith"), "{rendered}"); } @@ -1837,7 +1834,7 @@ mod tests { #[test] fn create_identity_name_opening_matches_spec_copy() { let opening = super::create_identity_name_opening(); - assert!(opening.contains("Create a new identity for this device.")); + assert!(opening.contains("Create a new identity here.")); assert!(opening.contains(".")); assert!(opening.contains("alice.smith")); assert!(opening.contains("phone.alice.smith")); @@ -1876,7 +1873,7 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Apply alice.smith to this device, then verify with alice.smith".to_string(), + "Apply alice.smith here, then verify with alice.smith".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index b5a8995..337cf98 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -24,7 +24,7 @@ fn default_organization_actions(target: &str) -> Vec { .map(|target| target.short_name().to_string()) .unwrap_or_else(|_| target.to_string()); vec![ - format!("Apply {short_name} to this device"), + format!("Apply {short_name} here"), "Choose another identity".to_string(), ] } @@ -108,7 +108,7 @@ async fn run_helper_apply( target: &IdentityTarget, ) -> Result<(), Error> { crate::cli::flow::transcript::print_block(&format!( - "{} is not saved on this device.\n\nTo use it as the default identity, this command will first apply {} to this device, then return here and set it as the default identity.", + "{} is not saved here.\n\nTo use it as the default identity, this command will first apply {} here, then return here and set it as the default identity.", target.short_name(), target.short_name() )); @@ -153,7 +153,7 @@ async fn summary_for_named_default_target( if !std::io::stdin().is_terminal() { whatever!( - "{} is not saved on this device.\n\nTo use it as the default identity, apply {} to this device first or rerun this command interactively.", + "{} is not saved here.\n\nTo use it as the default identity, apply {} here first or rerun this command interactively.", target.short_name(), target.short_name(), ); @@ -174,7 +174,7 @@ async fn select_interactive_default_summary( let inventory = local::load_inventory(dhttp_home, configured_default_name.clone()).await?; let choices = local::build_default_inventory_choices(&inventory); if choices.is_empty() { - whatever!("No local identities found"); + whatever!("No identities found here"); } let ansi = std::io::stdout().is_terminal(); @@ -183,7 +183,7 @@ async fn select_interactive_default_summary( .map(|choice| output::render_choice_label(choice, ansi)) .collect::>(); let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to set as the default on this device:", + "Select an identity to set as the default here:", labels.clone(), ) .await @@ -198,10 +198,7 @@ async fn select_interactive_default_summary( InteractiveInventoryChoice::Organization { target } => { let options = default_organization_actions(target.full_name()); let selected = crate::cli::prompt::prompt_select_string( - &format!( - "{} is not saved on this device. Choose what to do next:", - target.short_name() - ), + &format!("{} is not saved here. Choose what to do next:", target.short_name()), options.clone(), ) .await @@ -261,7 +258,7 @@ pub(crate) async fn run( } let switch_default = cli::prompt::sync(|| { - inquire::Confirm::new("Change the default identity on this device?") + inquire::Confirm::new("Change the default identity here?") .with_default(false) .prompt() }) @@ -313,7 +310,7 @@ mod tests { assert_eq!( default_organization_actions("alice.smith.dhttp.net"), vec![ - "Apply alice.smith to this device".to_string(), + "Apply alice.smith here".to_string(), "Choose another identity".to_string(), ] ); @@ -324,8 +321,7 @@ mod tests { let options = default_organization_actions("alice.smith.dhttp.net"); assert_eq!( - organization_action_from_selection(&options, "Apply alice.smith to this device") - .unwrap(), + organization_action_from_selection(&options, "Apply alice.smith here").unwrap(), DefaultOrganizationAction::ApplyToLocalDevice, ); } diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index 91f4648..352b90b 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -26,13 +26,13 @@ pub(crate) fn suggest_default_change( Some(current) if current.name == saved_name => None, Some(current) => Some(DefaultSuggestion { prompt: format!( - "Set {saved_name} as the default identity on this device? {}", + "Set {saved_name} as the default here? {}", output::format_current_default_suffix(¤t.name, ¤t.status, ansi) ), default: false, }), None => Some(DefaultSuggestion { - prompt: format!("Set {saved_name} as the default identity on this device?"), + prompt: format!("Set {saved_name} as the default here?"), default: true, }), } @@ -73,7 +73,7 @@ pub(crate) async fn current_default_summary( let status = match local::try_load_summary(dhttp_home, name.borrow(), None).await? { Some(summary) => summary.status, None => local::LocalIdentityStatus::Invalid { - detail: "identity is not saved on this device".to_string(), + detail: "identity is not saved here".to_string(), }, }; @@ -190,10 +190,7 @@ mod tests { let suggestion = suggest_default_change("alice.smith", None, false).unwrap(); assert!(suggestion.default); - assert_eq!( - suggestion.prompt, - "Set alice.smith as the default identity on this device?" - ); + assert_eq!(suggestion.prompt, "Set alice.smith as the default here?"); } #[test] @@ -213,7 +210,7 @@ mod tests { assert_eq!( suggestion, DefaultSuggestion { - prompt: "Set alice.smith as the default identity on this device? (current: meng.lin [invalid])".to_string(), + prompt: "Set alice.smith as the default here? (current: meng.lin [invalid])".to_string(), default: false, } ); diff --git a/genmeta-identity/src/cli/flow/kind.rs b/genmeta-identity/src/cli/flow/kind.rs index 86c4970..faa6105 100644 --- a/genmeta-identity/src/cli/flow/kind.rs +++ b/genmeta-identity/src/cli/flow/kind.rs @@ -10,8 +10,7 @@ pub(crate) enum IdentityKind { } impl IdentityKind { - pub(crate) const SELECT_PROMPT: &str = - "Choose how this device should be used for this identity."; + pub(crate) const SELECT_PROMPT: &str = "Choose how this identity should be used here."; pub(crate) const PRIMARY_HELP: &str = "Primary\n For a main host, server, desktop, home gateway, or always-on endpoint."; pub(crate) const SECONDARY_HELP: &str = @@ -84,7 +83,7 @@ mod tests { assert_eq!(IdentityKind::Secondary.to_string(), "secondary"); assert_eq!( IdentityKind::SELECT_PROMPT, - "Choose how this device should be used for this identity." + "Choose how this identity should be used here." ); assert!(IdentityKind::PRIMARY_HELP.contains("main host")); assert!(IdentityKind::SECONDARY_HELP.contains("additional device")); diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 308ffc3..3008226 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -144,7 +144,7 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo ) } InteractiveInventoryChoice::Organization { target } => render_line( - format!("{} (not saved locally)", target.short_name()), + format!("{} (not saved here)", target.short_name()), LineStyle::Dim, ansi, ), @@ -229,7 +229,7 @@ fn root_label(root: &LocalInventoryRoot) -> String { match root { LocalInventoryRoot::Saved(summary) => summary_label(summary), LocalInventoryRoot::Organization { target } => { - format!("{} (not saved locally)", target.short_name()) + format!("{} (not saved here)", target.short_name()) } } } @@ -445,7 +445,7 @@ Saved at: /tmp/phone.alice.smith"; alice.smith (default identity) ready primary:0\n\ ├─ phone.alice.smith ready secondary:2\n\ └─ tv.alice.smith incomplete (private key missing)\n\ -reimu.scarlet (not saved locally)\n\ +reimu.scarlet (not saved here)\n\ └─ tablet.reimu.scarlet expired secondary:1"; assert_eq!(render_inventory(&inventory, false), expected); @@ -476,7 +476,7 @@ reimu.scarlet (not saved locally)\n\ assert_eq!( labels, vec![ - "alice.ma (not saved locally)".to_string(), + "alice.ma (not saved here)".to_string(), " shanghai.alice.ma [ready]".to_string(), ] ); @@ -519,7 +519,7 @@ reimu.scarlet (not saved locally)\n\ labels, vec![ "alice.smith [ready] (default identity)".to_string(), - "reimu.scarlet (not saved locally)".to_string(), + "reimu.scarlet (not saved here)".to_string(), " tablet.reimu.scarlet [ready]".to_string(), ] ); diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 6a40694..fe8e5a7 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -163,7 +163,7 @@ fn apply_verification_recovery( fn renew_not_saved_root_message(short_name: &str) -> String { format!( - "The identity {short_name} is not saved on this device.\n\nRenew updates a local identity already saved on this device.\nThis identity has not been applied locally yet.\n\nApply {short_name} to this device first, then return to renew." + "The identity {short_name} is not saved here.\n\nRenew updates an identity already saved here.\nThis identity has not been applied here yet.\n\nApply {short_name} here first, then return to renew." ) } @@ -274,9 +274,7 @@ async fn resolve_target( .await?; let choices = local::build_renew_inventory_choices(&inventory); if choices.is_empty() { - whatever!( - "No local identities found. Renew requires a saved local identity profile." - ); + whatever!("No identities found here. Renew requires an identity saved here."); } let labels: Vec = choices .iter() @@ -285,7 +283,7 @@ async fn resolve_target( }) .collect(); let selected = crate::cli::prompt::prompt_select_string( - "Select a local identity to renew on this device:", + "Select an identity to renew here:", labels.clone(), ) .await @@ -298,7 +296,7 @@ async fn resolve_target( match choice { InteractiveInventoryChoice::Saved(summary) => Ok(summary.target.into_dhttp_name()), InteractiveInventoryChoice::Organization { .. } => { - whatever!("renew requires a saved local identity profile") + whatever!("renew requires an identity already saved here") } } } @@ -388,9 +386,7 @@ async fn run_interactive( .await?; let choices = local::build_renew_inventory_choices(&inventory); if choices.is_empty() { - whatever!( - "No local identities found. Renew requires a saved local identity profile." - ); + whatever!("No identities found here. Renew requires an identity saved here."); } let labels: Vec = choices .iter() @@ -399,7 +395,7 @@ async fn run_interactive( }) .collect(); let selected = crate::cli::prompt::prompt_select_string( - "Select a local identity to renew on this device:", + "Select an identity to renew here:", labels.clone(), ) .await @@ -870,12 +866,12 @@ mod tests { fn renew_not_saved_root_message_mentions_apply_and_return() { assert_eq!( renew_not_saved_root_message("alice.ma"), - "The identity alice.ma is not saved on this device. + "The identity alice.ma is not saved here. -Renew updates a local identity already saved on this device. -This identity has not been applied locally yet. +Renew updates an identity already saved here. +This identity has not been applied here yet. -Apply alice.ma to this device first, then return to renew." +Apply alice.ma here first, then return to renew." ); } @@ -898,10 +894,7 @@ Apply alice.ma to this device first, then return to renew." .unwrap_err(); let rendered = error.to_string(); - assert!( - rendered.contains("Apply alice.smith to this device first"), - "{rendered}" - ); + assert!(rendered.contains("Apply alice.smith here first"), "{rendered}"); } #[test] diff --git a/genmeta-identity/src/lib.rs b/genmeta-identity/src/lib.rs index ffab0f8..d2055b2 100644 --- a/genmeta-identity/src/lib.rs +++ b/genmeta-identity/src/lib.rs @@ -5,7 +5,7 @@ mod bootstrap; #[cfg(feature = "cli")] pub mod cli; #[cfg(feature = "cli")] -pub use cli::{Error, Options, run}; +pub use cli::{Cli, Error, Options, run}; #[cfg(feature = "cli")] pub mod auth; diff --git a/genmeta-identity/src/main.rs b/genmeta-identity/src/main.rs index 2388f07..cba9418 100644 --- a/genmeta-identity/src/main.rs +++ b/genmeta-identity/src/main.rs @@ -1,7 +1,7 @@ #![recursion_limit = "256"] use clap::{Arg, ArgAction, Command as ClapCommand, CommandFactory, FromArgMatches}; -use genmeta_identity::{Error, Options, run}; +use genmeta_identity::{Cli, Error, run}; fn enable_help(mut cmd: ClapCommand) -> ClapCommand { let names: Vec = cmd @@ -33,7 +33,7 @@ fn enable_help(mut cmd: ClapCommand) -> ClapCommand { #[tokio::main] #[snafu::report] async fn main() -> Result<(), Error> { - let mut cmd = Options::command(); + let mut cmd = Cli::command(); let names: Vec = cmd .get_subcommands() @@ -45,7 +45,7 @@ async fn main() -> Result<(), Error> { } let matches = cmd.get_matches(); - let options = Options::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); + let options = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); run(options).await.inspect_err(|error| { tracing::debug!(?error, "Exit with error"); diff --git a/genmeta/src/main.rs b/genmeta/src/main.rs index d4f8408..70d7163 100644 --- a/genmeta/src/main.rs +++ b/genmeta/src/main.rs @@ -11,10 +11,7 @@ enum Options { #[command(subcommand)] options: genmeta_doctor::Options, }, - Identity { - #[command(subcommand)] - options: genmeta_identity::Options, - }, + Identity(genmeta_identity::Cli), Nslookup(genmeta_nslookup::Options), Proxy(genmeta_proxy::Options), Ssh(genmeta_ssh::Options), @@ -119,7 +116,7 @@ async fn run(options: Options) -> Result<(), Error> { Options::Curl(options) => genmeta_curl::run(options).await?, Options::Discover(options) => genmeta_discover::run(options).await?, Options::Doctor { options } => genmeta_doctor::run(options).await?, - Options::Identity { options } => genmeta_identity::run(options).await?, + Options::Identity(options) => genmeta_identity::run(options).await?, Options::Nslookup(options) => genmeta_nslookup::run(options).await?, Options::Proxy(options) => genmeta_proxy::run(options).await?, Options::Ssh(options) => genmeta_ssh::run(options).await?, @@ -130,7 +127,9 @@ async fn run(options: Options) -> Result<(), Error> { #[cfg(test)] mod tests { - use super::install_process_crypto_provider; + use clap::Parser; + + use super::{Options, install_process_crypto_provider}; #[test] fn installs_rustls_crypto_provider() { @@ -138,4 +137,10 @@ mod tests { assert!(rustls::crypto::CryptoProvider::get_default().is_some()); } + + #[test] + fn launcher_accepts_identity_global_flag() { + let parsed = Options::try_parse_from(["genmeta", "identity", "--global", "list"]); + assert!(parsed.is_ok(), "{parsed:?}"); + } } From 0b9f282b0ea57a923a30dd934bee26c08de19a18 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 16:51:52 +0800 Subject: [PATCH 10/21] feat(access): support the global dhttp home --- genmeta-access/src/cli.rs | 32 ++++++++++++++++++++++ genmeta-access/src/lib.rs | 54 +++++++++++++++++++++++++++++++++---- genmeta-access/tests/cli.rs | 12 +++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/genmeta-access/src/cli.rs b/genmeta-access/src/cli.rs index 39a2c82..16a9d8a 100644 --- a/genmeta-access/src/cli.rs +++ b/genmeta-access/src/cli.rs @@ -47,6 +47,13 @@ where after_help = "Examples:\n genmeta access \"/\" allow luffy.pilot\n genmeta access \"/\" list\n genmeta access list --wide\n genmeta access --identity reimu.pilot \"/\" deny \"*?\"" )] pub struct Options { + #[arg( + long, + global = true, + help = "use the global dhttp home instead of the default user home" + )] + global: bool, + #[arg( long, value_name = "NAME", @@ -59,6 +66,14 @@ pub struct Options { } impl Options { + pub(crate) fn home_scope(&self) -> dhttp::home::HomeScope { + if self.global { + dhttp::home::HomeScope::Global + } else { + dhttp::home::HomeScope::User + } + } + pub(crate) fn into_parts(self) -> Result<(Option>, Command), ParseCommandError> { let identity = self.identity.map(|ReportFromStr(identity)| identity); let command = self.command.try_into()?; @@ -122,6 +137,23 @@ pub(crate) enum Command { }, } +impl Command { + pub(crate) fn writes_store(&self, db_exists: bool) -> bool { + match self { + Self::Print { .. } => false, + Self::List { .. } => !db_exists, + Self::RemovePaths { .. } => true, + Self::Path { operation, .. } => match operation { + PathOperation::List => !db_exists, + PathOperation::Remove { .. } + | PathOperation::Clear + | PathOperation::Allow { .. } + | PathOperation::Deny { .. } => true, + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) enum PathOperation { List, diff --git a/genmeta-access/src/lib.rs b/genmeta-access/src/lib.rs index be57bd6..475933d 100644 --- a/genmeta-access/src/lib.rs +++ b/genmeta-access/src/lib.rs @@ -18,7 +18,7 @@ use dhttp::{ }, }, }, - home::{DhttpHome, LocateDhttpHomeError, identity::settings::LoadDhttpSettingsError}, + home::{DhttpHome, LoadDhttpHomeError, identity::settings::LoadDhttpSettingsError}, }; use snafu::{IntoError, OptionExt, ResultExt, Snafu}; use tracing_subscriber::prelude::*; @@ -33,8 +33,8 @@ pub enum Error { #[snafu(transparent)] ParseCommand { source: ParseCommandError }, - #[snafu(display("failed to locate DHTTP_CONFIG"))] - LocateHome { source: LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadHome { source: LoadDhttpHomeError }, #[snafu(display("failed to load default identity config"))] LoadDefaultIdentityConfig { source: LoadDhttpSettingsError }, @@ -96,7 +96,7 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard { pub async fn run(options: Options) -> Result<(), Error> { let _guard = init_tracing(); - let home = DhttpHome::load_from_environment().context(error::LocateHomeSnafu)?; + let home = DhttpHome::load(options.home_scope()).context(error::LoadHomeSnafu)?; let output = run_for_home(&home, options).await?; if !output.is_empty() { @@ -106,6 +106,7 @@ pub async fn run(options: Options) -> Result<(), Error> { } pub async fn run_for_home(home: &DhttpHome, options: Options) -> Result { + let global = matches!(options.home_scope(), dhttp::home::HomeScope::Global); let (identity, command) = options.into_parts()?; if let Command::Print { output } = command { return Ok(output); @@ -114,7 +115,16 @@ pub async fn run_for_home(home: &DhttpHome, options: Options) -> Result Result< Ok(String::new()) } + +#[cfg(test)] +mod tests { + use super::{Command, PathOperation}; + + #[test] + fn command_writes_store_when_store_is_missing() { + assert!(Command::List { wide: false }.writes_store(false)); + assert!(!Command::List { wide: false }.writes_store(true)); + assert!(Command::RemovePaths { patterns: Vec::new() }.writes_store(true)); + } + + #[test] + fn path_command_writes_store_only_for_mutations() { + assert!(!Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::List, + } + .writes_store(true)); + assert!(Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::List, + } + .writes_store(false)); + assert!(Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::Remove { + all: true, + sequence: Vec::new(), + }, + } + .writes_store(true)); + } +} diff --git a/genmeta-access/tests/cli.rs b/genmeta-access/tests/cli.rs index dd35613..97a540f 100644 --- a/genmeta-access/tests/cli.rs +++ b/genmeta-access/tests/cli.rs @@ -247,6 +247,18 @@ fn help_output_shows_inline_usage() { assert!(help.contains("genmeta access \"/\" allow luffy.pilot")); } +#[test] +fn global_flag_parses_and_is_described_in_help() { + assert!(Options::try_parse_from(["access", "--global", "list"]).is_ok()); + + let mut help = Vec::new(); + Options::command().write_long_help(&mut help).unwrap(); + let help = String::from_utf8(help).unwrap(); + + assert!(help.contains("--global")); + assert!(help.contains("global dhttp home")); +} + #[tokio::test] async fn bare_word_is_rejected_as_location_pattern() { let test_home = TestHome::new("bare-word"); From 2d464700bf76294e5a32e0b5cf7c337a7c439119 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 16:55:37 +0800 Subject: [PATCH 11/21] feat(cli): add scoped home loading to curl nslookup and nat --- genmeta-curl/src/lib.rs | 33 +++++++++++++++++++++++++++----- genmeta-nat/src/lib.rs | 31 +++++++++++++++++++++++++----- genmeta-nslookup/src/lib.rs | 38 ++++++++++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/genmeta-curl/src/lib.rs b/genmeta-curl/src/lib.rs index 355408e..bf60570 100644 --- a/genmeta-curl/src/lib.rs +++ b/genmeta-curl/src/lib.rs @@ -98,6 +98,10 @@ pub struct Options { #[arg(short, long, value_name = "client_identity")] id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] anonymous: bool, @@ -131,6 +135,16 @@ pub struct Options { show_error: bool, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] #[snafu(module)] pub enum Error { @@ -145,8 +159,8 @@ pub enum Error { #[snafu(display("failed to construct normalized request uri"))] ConstructRequestUri { source: http::uri::InvalidUriParts }, - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { @@ -424,16 +438,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -908,6 +922,7 @@ mod tests { use std::time::Duration; use super::*; + use clap::Parser; #[test] fn connect_timeout_zero_disables_timeout() { @@ -927,4 +942,12 @@ mod tests { assert_eq!(normalized.to_string(), "https://reimu.pilot.dhttp.net/"); } + + #[test] + fn options_accept_global_flag() { + let options = + Options::try_parse_from(["genmeta-curl", "--global", "https://example.com/"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } } diff --git a/genmeta-nat/src/lib.rs b/genmeta-nat/src/lib.rs index 6bd64c6..32c9f7d 100644 --- a/genmeta-nat/src/lib.rs +++ b/genmeta-nat/src/lib.rs @@ -70,6 +70,10 @@ pub struct Options { #[arg(short, long)] pub id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + pub global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] pub anonymous: bool, @@ -84,11 +88,21 @@ pub struct Options { pub verbose: bool, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, snafu::Snafu)] #[snafu(module)] pub enum Error { - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { @@ -274,16 +288,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -655,6 +669,13 @@ mod tests { use super::*; + #[test] + fn options_accept_global_flag() { + let options = Options::try_parse_from(["nat-detect", "--global"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } + #[test] fn write_nat_report_prints_failures_without_hiding_successes() { let failed_bind_uri = BindUri::from("iface://v4.fail0:0"); diff --git a/genmeta-nslookup/src/lib.rs b/genmeta-nslookup/src/lib.rs index 89f95f1..973fd7d 100644 --- a/genmeta-nslookup/src/lib.rs +++ b/genmeta-nslookup/src/lib.rs @@ -31,6 +31,10 @@ pub struct Options { #[arg(short, long)] id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] anonymous: bool, @@ -45,11 +49,21 @@ pub struct Options { binds: Vec, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] #[snafu(module)] pub enum Error { - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { name: Name<'static>, @@ -100,16 +114,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -198,3 +212,17 @@ pub async fn run(options: Options) -> Result<(), Error> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn options_accept_global_flag() { + let options = Options::try_parse_from(["nslookup", "--global", "alice.smith", "mdns"]) + .unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } +} From e9bd7fc0512a9fbfaa5db5554d763c94b1868674 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 17:05:47 +0800 Subject: [PATCH 12/21] feat(cli): add scoped home loading to proxy and ssh --- genmeta-proxy/src/lib.rs | 37 ++++++++++++++++++++++++++++++++----- genmeta-ssh/src/config.rs | 10 +++++----- genmeta-ssh/src/lib.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/genmeta-proxy/src/lib.rs b/genmeta-proxy/src/lib.rs index a086be2..14a9ce6 100644 --- a/genmeta-proxy/src/lib.rs +++ b/genmeta-proxy/src/lib.rs @@ -26,6 +26,10 @@ pub struct Options { #[arg(short, long, value_name = "client_identity")] pub id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + pub global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] pub anonymous: bool, @@ -51,6 +55,16 @@ pub struct Options { pub log: Option, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub enum Error { @@ -59,8 +73,8 @@ pub enum Error { source: dhttp::message::IntoUriError, }, - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { @@ -282,16 +296,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -449,3 +463,16 @@ pub mod forward; pub mod h3_forward; pub mod route; pub mod tunnel; + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn options_accept_global_flag() { + let options = Options::try_parse_from(["genmeta-proxy", "--global"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } +} diff --git a/genmeta-ssh/src/config.rs b/genmeta-ssh/src/config.rs index 3426992..6d2605f 100644 --- a/genmeta-ssh/src/config.rs +++ b/genmeta-ssh/src/config.rs @@ -36,8 +36,8 @@ pub enum Error { MissingAuthority {}, #[snafu(display("failed to read ssh configuration"))] ReadConfig { source: ssh_config::ReadConfigError }, - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { name: Name<'static>, @@ -85,16 +85,16 @@ async fn load_identity_profile( .map(|name| ("command line options", name)) .or_else(|| ssh_config_id_name.map(|name| ("ssh config", name))); - let home = match DhttpHome::load_from_environment() { + let home = match DhttpHome::load(options.home_scope()) { Ok(home) => home, Err(source) if explicit.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(config_error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(config_error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some((source_name, name)) = explicit { diff --git a/genmeta-ssh/src/lib.rs b/genmeta-ssh/src/lib.rs index 888154e..6c92512 100644 --- a/genmeta-ssh/src/lib.rs +++ b/genmeta-ssh/src/lib.rs @@ -71,6 +71,10 @@ pub struct Options { #[arg(short = 'i', long, value_name = "client_identity")] id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] anonymous: bool, @@ -128,6 +132,16 @@ pub struct Options { commands: Vec, } +impl Options { + pub(crate) fn home_scope(&self) -> dhttp::home::HomeScope { + if self.global { + dhttp::home::HomeScope::Global + } else { + dhttp::home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] pub enum Error { #[snafu(transparent)] @@ -633,3 +647,17 @@ fn sigwinch_stream() -> impl futures::Stream + Unpin + Send { fn sigwinch_stream() -> impl futures::Stream + Unpin + Send { futures::stream::empty() } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn options_accept_global_flag() { + let options = Options::try_parse_from(["genmeta-ssh", "--global", "alice@example"]) + .unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } +} From 917cd411cf19b4a60eb5c7e66f85647a5ba50022 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 22 Jun 2026 17:12:44 +0800 Subject: [PATCH 13/21] chore: verify scoped dhttp home rollout --- Cargo.lock | 6 --- genmeta-access/src/lib.rs | 49 ++++++++++++------- genmeta-curl/src/lib.rs | 3 +- genmeta-identity/src/cli.rs | 5 +- genmeta-identity/src/cli/flow/create.rs | 5 +- .../src/cli/flow/default_identity.rs | 5 +- genmeta-identity/src/cli/flow/epilogue.rs | 3 +- genmeta-identity/src/cli/flow/renew.rs | 5 +- genmeta-nslookup/src/lib.rs | 7 +-- genmeta-proxy/src/lib.rs | 3 +- genmeta-ssh/src/lib.rs | 7 +-- 11 files changed, 60 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fa2585..474cf2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1517,8 +1517,6 @@ dependencies = [ [[package]] name = "dhttp" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff511421b0de59123d2f7dac10743709adf48a991620d4782f1f71f4d8e20966" dependencies = [ "bon", "bytes", @@ -1540,8 +1538,6 @@ dependencies = [ [[package]] name = "dhttp-access" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73da9a414cc0264c4511c4a9ba31f275d9d4ae861ace65ec96206e3ac4b38ad1" dependencies = [ "chrono", "clap", @@ -1563,8 +1559,6 @@ dependencies = [ [[package]] name = "dhttp-home" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecafb614c068796adf0c96af474356d26edec72e0f0ed8d6b7e96489df1e485f" dependencies = [ "dhttp-identity", "dirs", diff --git a/genmeta-access/src/lib.rs b/genmeta-access/src/lib.rs index 475933d..627a9fa 100644 --- a/genmeta-access/src/lib.rs +++ b/genmeta-access/src/lib.rs @@ -238,28 +238,39 @@ mod tests { fn command_writes_store_when_store_is_missing() { assert!(Command::List { wide: false }.writes_store(false)); assert!(!Command::List { wide: false }.writes_store(true)); - assert!(Command::RemovePaths { patterns: Vec::new() }.writes_store(true)); + assert!( + Command::RemovePaths { + patterns: Vec::new() + } + .writes_store(true) + ); } #[test] fn path_command_writes_store_only_for_mutations() { - assert!(!Command::Path { - pattern: "/".parse().unwrap(), - operation: PathOperation::List, - } - .writes_store(true)); - assert!(Command::Path { - pattern: "/".parse().unwrap(), - operation: PathOperation::List, - } - .writes_store(false)); - assert!(Command::Path { - pattern: "/".parse().unwrap(), - operation: PathOperation::Remove { - all: true, - sequence: Vec::new(), - }, - } - .writes_store(true)); + assert!( + !Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::List, + } + .writes_store(true) + ); + assert!( + Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::List, + } + .writes_store(false) + ); + assert!( + Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::Remove { + all: true, + sequence: Vec::new(), + }, + } + .writes_store(true) + ); } } diff --git a/genmeta-curl/src/lib.rs b/genmeta-curl/src/lib.rs index bf60570..6544897 100644 --- a/genmeta-curl/src/lib.rs +++ b/genmeta-curl/src/lib.rs @@ -921,9 +921,10 @@ pub async fn run(mut options: Options) -> Result<(), Error> { mod tests { use std::time::Duration; - use super::*; use clap::Parser; + use super::*; + #[test] fn connect_timeout_zero_disables_timeout() { assert_eq!(connect_timeout_from_secs(0), Duration::MAX); diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 51f5990..01da260 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -804,7 +804,10 @@ mod tests { rendered.contains("alice.smith is not saved here"), "{rendered}" ); - assert!(rendered.contains("apply alice.smith here first"), "{rendered}"); + assert!( + rendered.contains("apply alice.smith here first"), + "{rendered}" + ); } #[tokio::test] diff --git a/genmeta-identity/src/cli/flow/create.rs b/genmeta-identity/src/cli/flow/create.rs index 3d3a697..a60a399 100644 --- a/genmeta-identity/src/cli/flow/create.rs +++ b/genmeta-identity/src/cli/flow/create.rs @@ -1775,7 +1775,10 @@ mod tests { resolve_non_interactive_approval_plan(&target, Some(AuthMethod::Identity), None) .unwrap_err(); let rendered = error.to_string(); - assert!(rendered.contains("ready parent identity saved here"), "{rendered}"); + assert!( + rendered.contains("ready parent identity saved here"), + "{rendered}" + ); assert!(rendered.contains("phone.alice.smith"), "{rendered}"); } diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 337cf98..a450abe 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -198,7 +198,10 @@ async fn select_interactive_default_summary( InteractiveInventoryChoice::Organization { target } => { let options = default_organization_actions(target.full_name()); let selected = crate::cli::prompt::prompt_select_string( - &format!("{} is not saved here. Choose what to do next:", target.short_name()), + &format!( + "{} is not saved here. Choose what to do next:", + target.short_name() + ), options.clone(), ) .await diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index 352b90b..85db3b1 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -210,7 +210,8 @@ mod tests { assert_eq!( suggestion, DefaultSuggestion { - prompt: "Set alice.smith as the default here? (current: meng.lin [invalid])".to_string(), + prompt: "Set alice.smith as the default here? (current: meng.lin [invalid])" + .to_string(), default: false, } ); diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index fe8e5a7..2b0e645 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -894,7 +894,10 @@ Apply alice.ma here first, then return to renew." .unwrap_err(); let rendered = error.to_string(); - assert!(rendered.contains("Apply alice.smith here first"), "{rendered}"); + assert!( + rendered.contains("Apply alice.smith here first"), + "{rendered}" + ); } #[test] diff --git a/genmeta-nslookup/src/lib.rs b/genmeta-nslookup/src/lib.rs index 973fd7d..1d6fcc8 100644 --- a/genmeta-nslookup/src/lib.rs +++ b/genmeta-nslookup/src/lib.rs @@ -215,13 +215,14 @@ pub async fn run(options: Options) -> Result<(), Error> { #[cfg(test)] mod tests { - use super::*; use clap::Parser; + use super::*; + #[test] fn options_accept_global_flag() { - let options = Options::try_parse_from(["nslookup", "--global", "alice.smith", "mdns"]) - .unwrap(); + let options = + Options::try_parse_from(["nslookup", "--global", "alice.smith", "mdns"]).unwrap(); assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); } diff --git a/genmeta-proxy/src/lib.rs b/genmeta-proxy/src/lib.rs index 14a9ce6..8d9ca2a 100644 --- a/genmeta-proxy/src/lib.rs +++ b/genmeta-proxy/src/lib.rs @@ -466,9 +466,10 @@ pub mod tunnel; #[cfg(test)] mod tests { - use super::*; use clap::Parser; + use super::*; + #[test] fn options_accept_global_flag() { let options = Options::try_parse_from(["genmeta-proxy", "--global"]).unwrap(); diff --git a/genmeta-ssh/src/lib.rs b/genmeta-ssh/src/lib.rs index 6c92512..e011c70 100644 --- a/genmeta-ssh/src/lib.rs +++ b/genmeta-ssh/src/lib.rs @@ -650,13 +650,14 @@ fn sigwinch_stream() -> impl futures::Stream + Unpin + Send { #[cfg(test)] mod tests { - use super::*; use clap::Parser; + use super::*; + #[test] fn options_accept_global_flag() { - let options = Options::try_parse_from(["genmeta-ssh", "--global", "alice@example"]) - .unwrap(); + let options = + Options::try_parse_from(["genmeta-ssh", "--global", "alice@example"]).unwrap(); assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); } From f5fb7a010e6731986093cee86eda6ad7faf694d3 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 24 Jun 2026 00:46:17 +0800 Subject: [PATCH 14/21] fix(cli): align identity and packaging integration --- .github/workflows/release.yml | 8 +-- Cargo.lock | 40 ++------------ genmeta-identity/src/cli.rs | 39 ++++++++++++++ .../src/cli/flow/default_identity.rs | 31 +++++++++-- genmeta-nat/src/lib.rs | 52 ++++++------------- xtask/Cargo.toml | 23 ++++---- xtask/deb/rules | 10 ++-- xtask/src/deb.rs | 1 + xtask/src/main.rs | 47 ++++++++++------- xtask/src/release_contract.rs | 2 + xtask/src/template.rs | 2 + xtask/src/version_cmp.rs | 2 + 12 files changed, 147 insertions(+), 110 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03c89e7..58b7d39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,7 +197,7 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi @@ -327,7 +327,7 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi @@ -455,7 +455,7 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi @@ -594,7 +594,7 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi diff --git a/Cargo.lock b/Cargo.lock index 474cf2f..eda4a7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1574,8 +1574,6 @@ dependencies = [ [[package]] name = "dhttp-identity" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9006fe9bffd56d64fbe4260182039f104a84d612acdd30ec19a16583bb4ff671" dependencies = [ "bytes", "futures", @@ -1684,8 +1682,6 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c9472020223c058801106d9d738634ee732476796e6a6345257105c5b036a8" dependencies = [ "arc-swap", "dashmap", @@ -1730,8 +1726,6 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3c560fa266a789403c88224d3d3ca0a2a50fdce255db43765484ec6c5ba953d" dependencies = [ "base64", "bitfield-struct", @@ -2436,9 +2430,7 @@ dependencies = [ [[package]] name = "h3x" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbadb89a6ea8f80675b01720854d756e867f35a0e45b438d932ee1e9194ce07a" +version = "0.4.1" dependencies = [ "arc-swap", "async-channel", @@ -3972,8 +3964,6 @@ dependencies = [ [[package]] name = "qbase" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "814882a54bd00217746c83254e1a4171ec1688b5967881a8ef7c2ec9efa0ef8f" dependencies = [ "bitflags", "bytes", @@ -3995,8 +3985,6 @@ dependencies = [ [[package]] name = "qcongestion" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cebe0c237cd0272ab17881c8f6e4eec2c4d988716aa9ffcdd36dd3c0cb3eb656" dependencies = [ "qbase", "qevent", @@ -4008,9 +3996,7 @@ dependencies = [ [[package]] name = "qconnection" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c78f0a8879438e1b301d913e1d79fb493eb7527aeb88a6ffe35e147ca6410894" +version = "0.5.2" dependencies = [ "bytes", "dashmap", @@ -4037,8 +4023,6 @@ dependencies = [ [[package]] name = "qdatagram" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab3beebe7085ce1ce977a3cb4fd9feb674a2b3c72791cb781a80ebacd6e2594" dependencies = [ "bytes", "qbase", @@ -4049,8 +4033,6 @@ dependencies = [ [[package]] name = "qevent" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a8df4e7d964ab6551451cc27fe0c0e588ec38bfcff8a81afee678692be0b59" dependencies = [ "bytes", "derive_builder", @@ -4068,8 +4050,6 @@ dependencies = [ [[package]] name = "qinterface" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aa66f19a792f2fd42df5c86eb5c8f09033c34a9d5182fd63ea81b68e80103cd" dependencies = [ "bytes", "dashmap", @@ -4092,8 +4072,6 @@ dependencies = [ [[package]] name = "qmacro" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2e2f19a8187c8ff0c0001562c18f17bfa6f82d25ec8316960944b418fc371" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4104,8 +4082,6 @@ dependencies = [ [[package]] name = "qrecovery" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5e4cc4b0c3947ca0383c1d0134315c2a12a4828ea1ebe5c7729489f111c4b3" dependencies = [ "bytes", "derive_more", @@ -4120,8 +4096,6 @@ dependencies = [ [[package]] name = "qresolve" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782eaf7f1c47b007519997f93827f1dd3516547516d52343da1709979be87819" dependencies = [ "futures", "qbase", @@ -4131,8 +4105,6 @@ dependencies = [ [[package]] name = "qtraversal" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77d06edaf1113b528c7dcc00f26c395ba971aa5577b761ece68869d2f04d44f" dependencies = [ "bon", "bytes", @@ -4155,8 +4127,6 @@ dependencies = [ [[package]] name = "qudp" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94767eb1de257b50c7762f46286d14112a1b9611794b0aa57189d702ba5f06a8" dependencies = [ "bytes", "cfg-if", @@ -5325,7 +5295,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -5337,7 +5307,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -6538,7 +6508,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 01da260..50ea863 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -679,6 +679,15 @@ mod tests { ); } + #[test] + fn helper_style_read_and_write_subcommands_are_rejected() { + for command in ["read", "write"] { + let error = Options::try_parse_from(["genmeta", command]).unwrap_err(); + let rendered = error.to_string(); + assert!(rendered.contains(command), "{rendered}"); + } + } + #[test] fn renew_rejects_kind_and_sequence_flags() { for (flag, value) in [("--kind", "primary"), ("--sequence", "1")] { @@ -831,6 +840,36 @@ mod tests { ); } + #[tokio::test] + async fn default_named_saved_identity_sets_default_non_interactively() { + let home_path = unique_test_home_path("default-saved-noninteractive"); + let dhttp_home = DhttpHome::new(home_path.clone()); + let name = DhttpName::try_from("alice.smith").unwrap(); + let profile = dhttp_home.identity_profile(name.borrow()); + tokio::fs::create_dir_all(profile.path()).await.unwrap(); + + let command = Default { + name: Some("alice.smith".to_string()), + allow_nonready: true, + }; + + command + .run(&dhttp_home, &dummy_cert_server()) + .await + .unwrap(); + + let settings = dhttp_home.load_settings().await.unwrap(); + assert_eq!( + settings + .settings() + .default_identity_name() + .map(|name| name.as_partial()), + Some("alice.smith") + ); + + tokio::fs::remove_dir_all(home_path).await.unwrap(); + } + #[test] fn cli_accepts_global_before_and_after_subcommand() { let before = Cli::try_parse_from(["genmeta", "--global", "list"]).unwrap(); diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index a450abe..f41bebf 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -67,6 +67,7 @@ async fn confirm_default_target( summary: &LocalIdentitySummary, current_default: Option<&super::epilogue::CurrentDefaultSummary>, ansi: bool, + stdin_is_terminal: bool, ) -> Result<(), Error> { if !summary.status.is_ready() && !command.allow_nonready { let message = format!( @@ -84,6 +85,14 @@ async fn confirm_default_target( return Ok(()); } + // Non-interactive default changes must go through the explicit + // `genmeta identity default ` command, not through create/apply + // side effects. When the user has already named the target here, treat + // that explicit command as sufficient confirmation. + if command.name.is_some() && !stdin_is_terminal { + return Ok(()); + } + if let Some(suggestion) = super::epilogue::suggest_default_change(summary.target.short_name(), current_default, ansi) { @@ -234,6 +243,7 @@ pub(crate) async fn run( .and_then(|config| config.settings().default_identity_name().cloned()); let current_default = super::epilogue::current_default_summary(dhttp_home).await?; let ansi = std::io::stdout().is_terminal(); + let stdin_is_terminal = std::io::stdin().is_terminal(); match command.name.as_ref() { None => { @@ -256,7 +266,7 @@ pub(crate) async fn run( std::io::stdout().is_terminal(), )); - if !std::io::stdin().is_terminal() { + if !stdin_is_terminal { return Ok(()); } @@ -279,8 +289,14 @@ pub(crate) async fn run( .map(|default| default.borrow()), ) .await?; - confirm_default_target(command, &selected_summary, current_default.as_ref(), ansi) - .await?; + confirm_default_target( + command, + &selected_summary, + current_default.as_ref(), + ansi, + stdin_is_terminal, + ) + .await?; set_default_summary(dhttp_home, current_config, selected_summary).await } Some(name) => { @@ -294,7 +310,14 @@ pub(crate) async fn run( .map(|default| default.borrow()), ) .await?; - confirm_default_target(command, &summary, current_default.as_ref(), ansi).await?; + confirm_default_target( + command, + &summary, + current_default.as_ref(), + ansi, + stdin_is_terminal, + ) + .await?; set_default_summary(dhttp_home, current_config, summary).await } } diff --git a/genmeta-nat/src/lib.rs b/genmeta-nat/src/lib.rs index 32c9f7d..10dcbcd 100644 --- a/genmeta-nat/src/lib.rs +++ b/genmeta-nat/src/lib.rs @@ -1,6 +1,5 @@ use std::{ collections::BTreeMap, - fmt, io::{IsTerminal, Write as _}, net::SocketAddr, str::FromStr, @@ -20,7 +19,6 @@ use dhttp::{ DetectNatTypeError, DetectOuterAddrError, StunClientsComponent, StunProbeError, }, }, - resolver::{Resolve, ResolveFuture, handy::SystemResolver}, }, endpoint::Endpoint, home::{self, DhttpHome, identity::IdentityProfile}, @@ -35,34 +33,6 @@ use tracing_subscriber::prelude::*; const STUN_AGENT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); const STUN_AGENT_DISCOVERY_INTERVAL: Duration = Duration::from_millis(100); -#[derive(Debug, Clone)] -struct FirstEndpointResolver { - inner: Arc, -} - -impl FirstEndpointResolver { - fn system() -> Arc { - Arc::new(Self { - inner: Arc::new(SystemResolver), - }) - } -} - -impl fmt::Display for FirstEndpointResolver { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "first endpoint of {}", self.inner) - } -} - -impl Resolve for FirstEndpointResolver { - fn lookup<'l>(&'l self, name: &'l str) -> ResolveFuture<'l> { - Box::pin(async move { - let records = self.inner.lookup(name).await?; - Ok(records.take(1).boxed()) - }) - } -} - #[derive(Parser, Debug, Clone)] #[command(name = "nat-detect", version, about)] pub struct Options { @@ -343,11 +313,9 @@ async fn diagnose_nat(options: &mut Options) -> Result<(), Error> { let bind_patterns = Arc::new(options.binds.clone()); let network = DhttpNetwork::builder() - // NAT filtering tests are stateful: probing multiple STUN agents in - // parallel can open holes and misclassify restricted NATs. A single - // bootstrap STUN endpoint is enough because the STUN changed-address - // attribute supplies alternate servers for classification. - .stun_resolver(FirstEndpointResolver::system()) + .bind(bind_patterns.clone()) + .dns(DnsScheme::H3) + .dns(DnsScheme::System) .build() .await .context(error::BuildDhttpNetworkSnafu)?; @@ -854,4 +822,18 @@ mod tests { ); assert!(!wrote_summary); } + + #[test] + fn diagnose_nat_uses_dhttp_dns_plan_without_endpoint_truncation() { + let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")); + let first_endpoint_resolver = ["First", "Endpoint", "Resolver"].concat(); + let take_one = [".take", "(1)"].concat(); + let single_resolver = ["single_stun", "_endpoint_resolver("].concat(); + + assert!(!source.contains(&first_endpoint_resolver)); + assert!(!source.contains(&take_one)); + assert!(!source.contains(&single_resolver)); + assert!(source.contains(".dns(DnsScheme::H3)")); + assert!(source.contains(".dns(DnsScheme::System)")); + } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index fd78721..921cb13 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -9,15 +9,6 @@ homepage.workspace = true publish = false [dependencies] -aws-credential-types = "1" -# Keep default `default-https-client` disabled. It pulls aws-lc, and aws-lc-sys -# fails to assemble on i686-pc-windows-msvc under clang-cl during release -# cross-builds. Enable only the rustls/ring-backed features used by S3 publish. -aws-sdk-s3 = { version = "1", default-features = false, features = [ - "rt-tokio", - "rustls", - "sigv4a", -] } bollard = "0.21" cargo_metadata = "0.23" clap = { version = "4", features = ["derive", "env"] } @@ -39,3 +30,17 @@ rpm-version = "0.5.0" tokio = { version = "1", features = ["full"] } walkdir = "2" zip = { version = "8", default-features = false, features = ["deflate"] } + +[target.'cfg(xtask_s3_publish)'.dependencies] +aws-credential-types = "1" +# Keep default `default-https-client` disabled. It pulls aws-lc, and aws-lc-sys +# fails to assemble on i686-pc-windows-msvc under clang-cl during release +# cross-builds. Enable only the rustls/ring-backed features used by S3 publish. +aws-sdk-s3 = { version = "1", default-features = false, features = [ + "rt-tokio", + "rustls", + "sigv4a", +] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(xtask_s3_publish)'] } diff --git a/xtask/deb/rules b/xtask/deb/rules index d84201a..c294b42 100755 --- a/xtask/deb/rules +++ b/xtask/deb/rules @@ -6,11 +6,13 @@ # cargo-zigbuild (e.g. x86_64-unknown-linux-gnu.2.28). # # This file lives in xtask/deb/ and is copied into target/.../deb/src/debian/ -# before dpkg-buildpackage runs. All paths use /workspace (the bind mount root). +# before dpkg-buildpackage runs. xtask points SOURCE_ROOT at the primary source +# bind mount inside the build container. export DH_VERBOSE = 1 BUILD_PROFILE ?= release CARGO_PROFILE_ARGS ?= --release +SOURCE_ROOT ?= /workspace %: dh $@ @@ -19,7 +21,7 @@ CARGO_PROFILE_ARGS ?= --release # Cortex-A53 843419 mitigation linker argument. override_dh_auto_build: ifdef TRIPLE - cd /workspace && \ + cd $(SOURCE_ROOT) && \ export RUSTFLAGS="$${RUSTFLAGS:-} -L /usr/lib/$(DEB_HOST_MULTIARCH)" && \ if [ "$(TRIPLE)" = "aarch64-unknown-linux-gnu" ]; then \ { \ @@ -51,8 +53,8 @@ endif override_dh_auto_install: ifdef TRIPLE mkdir -p debian/gmutils/usr/bin - cp /workspace/target/$(TRIPLE)/$(BUILD_PROFILE)/genmeta debian/gmutils/usr/bin/ - cp /workspace/genmeta-ssh.sh debian/gmutils/usr/bin/ + cp $(SOURCE_ROOT)/target/$(TRIPLE)/$(BUILD_PROFILE)/genmeta debian/gmutils/usr/bin/ + cp $(SOURCE_ROOT)/genmeta-ssh.sh debian/gmutils/usr/bin/ chmod 755 debian/gmutils/usr/bin/* endif diff --git a/xtask/src/deb.rs b/xtask/src/deb.rs index fa323aa..cee9986 100644 --- a/xtask/src/deb.rs +++ b/xtask/src/deb.rs @@ -440,6 +440,7 @@ export ZIG_TARGET={triple}.{ZIG_GLIBC_VERSION} export BUILD_PROFILE={profile_dir} export CARGO_PROFILE_ARGS="{cargo_profile_args}" export DEB_HOST_MULTIARCH={gnu} +export SOURCE_ROOT={primary_source} {dhttp_bootstrap_exports} SRC={primary_source}/target/{triple}/{profile_dir}/deb/src mkdir -p "$SRC/debian" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 57f7419..4381aa6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,6 +3,7 @@ mod container; mod deb; mod grouped; mod package; +#[cfg(xtask_s3_publish)] mod publish; mod release_contract; mod rpm; @@ -45,6 +46,7 @@ enum Command { targets: Vec, }, /// Publish package manifests to a backend + #[cfg(xtask_s3_publish)] Publish { #[command(subcommand)] command: publish::PublishCommand, @@ -267,7 +269,9 @@ pub async fn run_cmd(cmd: &mut tokio::process::Command) -> Result<(), Whatever> mod tests { use clap::{CommandFactory, Parser, error::ErrorKind}; - use super::{BuildProfile, Cli, Command, publish}; + #[cfg(xtask_s3_publish)] + use super::publish; + use super::{BuildProfile, Cli, Command}; const RELEASE_WORKFLOW: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -299,7 +303,10 @@ mod tests { let names = subcommand_names(&command); assert!(names.contains(&"package")); + #[cfg(xtask_s3_publish)] assert!(names.contains(&"publish")); + #[cfg(not(xtask_s3_publish))] + assert!(!names.contains(&"publish")); assert!(!names.contains(&"dist")); assert!(!names.contains(&"stage")); assert!(!names.contains(&"verify")); @@ -320,30 +327,31 @@ mod tests { ]) .expect("package command should parse"); - match cli.command { + let (overwrite_manifest, targets) = match cli.command { Command::Package { overwrite_manifest, targets, - } => { - assert!(overwrite_manifest); - assert_eq!( - targets, - [ - "deb", - "--target", - "x86_64-unknown-linux-gnu", - "rpm", - "--target", - "aarch64-unknown-linux-gnu", - ] - .map(std::ffi::OsString::from) - ); - } + } => (overwrite_manifest, targets), + #[cfg(xtask_s3_publish)] _ => panic!("expected package command"), - } + }; + assert!(overwrite_manifest); + assert_eq!( + targets, + [ + "deb", + "--target", + "x86_64-unknown-linux-gnu", + "rpm", + "--target", + "aarch64-unknown-linux-gnu", + ] + .map(std::ffi::OsString::from) + ); } #[test] + #[cfg(xtask_s3_publish)] fn publish_s3_accepts_grouped_targets() { let cli = Cli::try_parse_from(["xtask", "publish", "s3", "--dry-run", "deb", "brew"]) .expect("publish command should parse"); @@ -386,7 +394,7 @@ mod tests { assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" brew")); assert_eq!( RELEASE_WORKFLOW - .matches("publish_cmd=(cargo xtask publish s3)") + .matches(r#"publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3)"#) .count(), 4 ); @@ -525,6 +533,7 @@ async fn main() -> Result<(), Whatever> { }) .await? } + #[cfg(xtask_s3_publish)] Command::Publish { command } => publish::run(command).await?, } Ok(()) diff --git a/xtask/src/release_contract.rs b/xtask/src/release_contract.rs index 7190ebb..3b19c76 100644 --- a/xtask/src/release_contract.rs +++ b/xtask/src/release_contract.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(xtask_s3_publish), allow(dead_code))] + use std::{ collections::BTreeMap, path::{Path, PathBuf}, diff --git a/xtask/src/template.rs b/xtask/src/template.rs index 4295f53..ca569ed 100644 --- a/xtask/src/template.rs +++ b/xtask/src/template.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(xtask_s3_publish), allow(dead_code))] + use std::collections::BTreeMap; use snafu::{Snafu, ensure}; diff --git a/xtask/src/version_cmp.rs b/xtask/src/version_cmp.rs index 5adba3d..eb94684 100644 --- a/xtask/src/version_cmp.rs +++ b/xtask/src/version_cmp.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(xtask_s3_publish), allow(dead_code))] + use std::{cmp::Ordering, str::FromStr}; use snafu::{ResultExt, Snafu}; From 5e193188ecdbf4c6ce448e9236b6898a4d624f05 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 24 Jun 2026 22:37:36 +0800 Subject: [PATCH 15/21] feat(identity): add welcome onboarding flow --- Cargo.toml | 1 + genmeta-identity/Cargo.toml | 3 + genmeta-identity/src/cli.rs | 43 ++- genmeta-identity/src/cli/flow.rs | 12 +- genmeta-identity/src/cli/flow/apply.rs | 51 ++- genmeta-identity/src/cli/flow/create.rs | 64 +++- .../src/cli/flow/default_identity.rs | 36 +- genmeta-identity/src/cli/flow/epilogue.rs | 33 +- genmeta-identity/src/cli/flow/output.rs | 189 ++++++---- genmeta-identity/src/cli/flow/renew.rs | 16 +- genmeta-identity/src/cli/flow/welcome.rs | 350 ++++++++++++++++++ 11 files changed, 690 insertions(+), 108 deletions(-) create mode 100644 genmeta-identity/src/cli/flow/welcome.rs diff --git a/Cargo.toml b/Cargo.toml index 1bce35c..8dcf44f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ dirs = { version = "6" } sea-orm = { version = "1", features = ["runtime-tokio-rustls"] } snafu = "0.9" socket2 = "0.6" +nix = { version = "0.31", default-features = false, features = ["user"] } time = "0.3" async-compression = { version = "0.4", features = [ "tokio", diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index 665897c..23e8b70 100644 --- a/genmeta-identity/Cargo.toml +++ b/genmeta-identity/Cargo.toml @@ -48,3 +48,6 @@ cli = [ "dep:crossterm", "dep:whoami", ] + +[target.'cfg(unix)'.dependencies] +nix = { workspace = true } diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 50ea863..8aca28e 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -19,6 +19,7 @@ use dhttp::{ }, name::DhttpName as Name, }; +pub use flow::welcome::WelcomeServiceError; use indicatif::ProgressStyle; use rankey::EncodePem; use snafu::{ResultExt, Snafu, Whatever, whatever}; @@ -71,6 +72,8 @@ pub enum Error { source: flow::kind::ParseIdentityKindError, }, #[snafu(transparent)] + WelcomeService { source: WelcomeServiceError }, + #[snafu(transparent)] LocalIdentity { source: crate::local_identity::Error, }, @@ -362,8 +365,13 @@ pub struct Default { } impl Default { - pub async fn run(&self, dhttp_home: &DhttpHome, cert_server: &CertServer) -> Result<(), Error> { - flow::default_identity::run(self, dhttp_home, cert_server).await + pub async fn run( + &self, + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, + ) -> Result<(), Error> { + flow::default_identity::run(self, dhttp_home, home_scope, cert_server).await } } @@ -497,12 +505,21 @@ impl Options { ) } - pub async fn run(&self, dhttp_home: &DhttpHome, cert_server: &CertServer) -> Result<(), Error> { + pub async fn run( + &self, + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, + ) -> Result<(), Error> { match self { - Options::Create(cmd) => flow::run_create(cmd, dhttp_home, cert_server).await, - Options::Apply(cmd) => flow::run_apply(cmd, dhttp_home, cert_server).await, + Options::Create(cmd) => { + flow::run_create(cmd, dhttp_home, home_scope, cert_server).await + } + Options::Apply(cmd) => flow::run_apply(cmd, dhttp_home, home_scope, cert_server).await, Options::Renew(cmd) => flow::run_renew(cmd, dhttp_home, cert_server).await, - Options::Default(cmd) => flow::run_default(cmd, dhttp_home, cert_server).await, + Options::Default(cmd) => { + flow::run_default(cmd, dhttp_home, home_scope, cert_server).await + } Options::Info(cmd) => flow::run_info(cmd, dhttp_home, cert_server).await, Options::List(cmd) => flow::run_list(cmd, dhttp_home, cert_server).await, Options::Version {} => { @@ -546,7 +563,8 @@ fn cert_server_base_url() -> &'static str { pub async fn run(options: Cli) -> Result<(), Error> { init_tracing(); - let dhttp_home = DhttpHome::load(options.home_scope()).context(LoadDhttpHomeSnafu)?; + let home_scope = options.home_scope(); + let dhttp_home = DhttpHome::load(home_scope).context(LoadDhttpHomeSnafu)?; if options.global && options.options.writes_home() { tracing::warn!( @@ -558,7 +576,10 @@ pub async fn run(options: Cli) -> Result<(), Error> { _ = rustls::crypto::ring::default_provider().install_default(); let cert_server = CertServer::new(cert_server_base_url())?; - options.options.run(&dhttp_home, &cert_server).await + options + .options + .run(&dhttp_home, home_scope, &cert_server) + .await } #[cfg(test)] @@ -570,7 +591,7 @@ mod tests { use clap::{CommandFactory, Parser}; use dhttp::{ - home::DhttpHome, + home::{DhttpHome, HomeScope}, identity::Identity, name::{DhttpName, Name}, }; @@ -829,7 +850,7 @@ mod tests { }; let error = command - .run(&dhttp_home, &dummy_cert_server()) + .run(&dhttp_home, HomeScope::User, &dummy_cert_server()) .await .unwrap_err(); let rendered = error.to_string(); @@ -854,7 +875,7 @@ mod tests { }; command - .run(&dhttp_home, &dummy_cert_server()) + .run(&dhttp_home, HomeScope::User, &dummy_cert_server()) .await .unwrap(); diff --git a/genmeta-identity/src/cli/flow.rs b/genmeta-identity/src/cli/flow.rs index 1dcd709..a1542c5 100644 --- a/genmeta-identity/src/cli/flow.rs +++ b/genmeta-identity/src/cli/flow.rs @@ -13,8 +13,9 @@ pub(crate) mod recovery; pub(crate) mod renew; pub(crate) mod target; pub(crate) mod transcript; +pub(crate) mod welcome; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use crate::{ cert_server::CertServer, @@ -24,17 +25,19 @@ use crate::{ pub(crate) async fn run_create( command: &Create, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - create::run(command, dhttp_home, cert_server).await + create::run(command, dhttp_home, home_scope, cert_server).await } pub(crate) async fn run_apply( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - apply::run(command, dhttp_home, cert_server).await + apply::run(command, dhttp_home, home_scope, cert_server).await } pub(crate) async fn run_renew( @@ -48,9 +51,10 @@ pub(crate) async fn run_renew( pub(crate) async fn run_default( command: &Default, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - default_identity::run(command, dhttp_home, cert_server).await + default_identity::run(command, dhttp_home, home_scope, cert_server).await } pub(crate) async fn run_info( diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 49d3fe4..a7b330f 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; use tracing::{Instrument, info_span}; @@ -504,6 +504,7 @@ async fn resolve_apply_candidate( async fn run_helper_apply_action( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, auth_domain: &str, action: approval::ApprovalHelperAction, @@ -524,6 +525,7 @@ async fn run_helper_apply_action( match Box::pin(run_interactive( &command, dhttp_home, + home_scope, cert_server, return_to, )) @@ -603,6 +605,7 @@ async fn run_post_save_epilogue( domain: dhttp::name::DhttpName<'_>, default_identity_when_command_started: Option>, interactive: bool, + welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { match post_save { ApplyPostSavePolicy::ManageDefaultSuggestion => { @@ -611,11 +614,19 @@ async fn run_post_save_epilogue( domain, default_identity_when_command_started, interactive, + super::output::SavedIdentityAction::Applied, + welcome, ) .await } ApplyPostSavePolicy::SkipDefaultSuggestion => { - crate::cli::flow::epilogue::run_local_epilogue(dhttp_home, domain).await + crate::cli::flow::epilogue::run_local_epilogue( + dhttp_home, + domain, + super::output::SavedIdentityAction::Applied, + welcome, + ) + .await } } } @@ -623,6 +634,7 @@ async fn run_post_save_epilogue( async fn run_interactive_with_policy( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, return_to: Option<&str>, post_save: ApplyPostSavePolicy, @@ -711,8 +723,15 @@ async fn run_interactive_with_policy( action, } = approval_plan.clone() { - if !run_helper_apply_action(dhttp_home, cert_server, &auth_domain, action, return_to) - .await? + if !run_helper_apply_action( + dhttp_home, + home_scope, + cert_server, + &auth_domain, + action, + return_to, + ) + .await? { state.approval_plan = None; state.revisit_verification_method(); @@ -905,12 +924,16 @@ async fn run_interactive_with_policy( ) .instrument(info_span!("save_identity")) .await?; + let welcome = + super::welcome::maybe_create_welcome_service(dhttp_home, domain.borrow(), home_scope) + .await?; run_post_save_epilogue( post_save, dhttp_home, domain.borrow(), default_identity_when_command_started.clone(), std::io::stdin().is_terminal(), + welcome.as_ref(), ) .await?; return Ok(ApplyRunOutcome::Applied); @@ -920,12 +943,14 @@ async fn run_interactive_with_policy( pub(crate) async fn run_interactive( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, return_to: Option<&str>, ) -> Result { run_interactive_with_policy( command, dhttp_home, + home_scope, cert_server, return_to, ApplyPostSavePolicy::ManageDefaultSuggestion, @@ -936,13 +961,21 @@ pub(crate) async fn run_interactive( pub(crate) async fn run_with_policy( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, post_save: ApplyPostSavePolicy, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); if is_interactive && !command.send_code { - return match run_interactive_with_policy(command, dhttp_home, cert_server, None, post_save) - .await? + return match run_interactive_with_policy( + command, + dhttp_home, + home_scope, + cert_server, + None, + post_save, + ) + .await? { ApplyRunOutcome::Applied => Ok(()), ApplyRunOutcome::ReturnedToCaller => whatever!("apply was cancelled"), @@ -1029,12 +1062,16 @@ pub(crate) async fn run_with_policy( ) .instrument(info_span!("save_identity")) .await?; + let welcome = + super::welcome::maybe_create_welcome_service(dhttp_home, domain.borrow(), home_scope) + .await?; run_post_save_epilogue( post_save, dhttp_home, domain.borrow(), default_identity_when_command_started, is_interactive, + welcome.as_ref(), ) .await } @@ -1042,11 +1079,13 @@ pub(crate) async fn run_with_policy( pub(crate) async fn run( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { run_with_policy( command, dhttp_home, + home_scope, cert_server, ApplyPostSavePolicy::ManageDefaultSuggestion, ) diff --git a/genmeta-identity/src/cli/flow/create.rs b/genmeta-identity/src/cli/flow/create.rs index a60a399..bca7426 100644 --- a/genmeta-identity/src/cli/flow/create.rs +++ b/genmeta-identity/src/cli/flow/create.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use snafu::{FromString, OptionExt, whatever}; use tracing::{Instrument, info_span}; @@ -659,6 +659,7 @@ async fn resolve_parent_candidate( async fn run_helper_apply_parent( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, parent_identity: &str, @@ -687,6 +688,7 @@ To continue creating {}, it will {verb} {short_parent_identity} here, then retur match super::apply::run_interactive( &command, dhttp_home, + home_scope, cert_server, Some(&format!("create {}", target.short_name())), ) @@ -699,6 +701,7 @@ To continue creating {}, it will {verb} {short_parent_identity} here, then retur async fn run_helper_parent_action( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, parent_identity: &str, @@ -706,10 +709,26 @@ async fn run_helper_parent_action( ) -> Result { match action { approval::ApprovalHelperAction::Apply => { - run_helper_apply_parent(dhttp_home, cert_server, target, parent_identity, false).await + run_helper_apply_parent( + dhttp_home, + home_scope, + cert_server, + target, + parent_identity, + false, + ) + .await } approval::ApprovalHelperAction::Reapply => { - run_helper_apply_parent(dhttp_home, cert_server, target, parent_identity, true).await + run_helper_apply_parent( + dhttp_home, + home_scope, + cert_server, + target, + parent_identity, + true, + ) + .await } approval::ApprovalHelperAction::Renew => { super::renew::run_helper_for_verification( @@ -916,6 +935,7 @@ async fn create_sub_identity_with_email_interactively( async fn run_interactive( command: &Create, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { let default_identity_when_command_started = cli::load_current_settings(dhttp_home) @@ -1032,8 +1052,15 @@ async fn run_interactive( .parent_identity .as_deref() .whatever_context::<_, Error>("helper apply path is missing its parent identity")?; - if !run_helper_parent_action(dhttp_home, cert_server, &target, parent_identity, action) - .await? + if !run_helper_parent_action( + dhttp_home, + home_scope, + cert_server, + &target, + parent_identity, + action, + ) + .await? { state.approval_plan = None; state.reset_after_approval_change(); @@ -1389,11 +1416,19 @@ async fn run_interactive( } } + let welcome = super::welcome::maybe_create_welcome_service( + dhttp_home, + target.dhttp_name(), + home_scope, + ) + .await?; crate::cli::flow::epilogue::run_lifecycle_epilogue( dhttp_home, target.dhttp_name(), default_identity_when_command_started.clone(), std::io::stdin().is_terminal(), + super::output::SavedIdentityAction::Created, + welcome.as_ref(), ) .await?; return Ok(()); @@ -1403,11 +1438,12 @@ async fn run_interactive( pub(crate) async fn run( command: &Create, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); if is_interactive && !command.send_code { - return run_interactive(command, dhttp_home, cert_server).await; + return run_interactive(command, dhttp_home, home_scope, cert_server).await; } let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? @@ -1442,8 +1478,15 @@ pub(crate) async fn run( .parent_identity .as_deref() .whatever_context::<_, Error>("helper apply path is missing its parent identity")?; - if !run_helper_parent_action(dhttp_home, cert_server, &target, parent_identity, action) - .await? + if !run_helper_parent_action( + dhttp_home, + home_scope, + cert_server, + &target, + parent_identity, + action, + ) + .await? { whatever!("create was cancelled"); } @@ -1604,11 +1647,16 @@ pub(crate) async fn run( } } + let welcome = + super::welcome::maybe_create_welcome_service(dhttp_home, target.dhttp_name(), home_scope) + .await?; crate::cli::flow::epilogue::run_lifecycle_epilogue( dhttp_home, target.dhttp_name(), default_identity_when_command_started, is_interactive, + super::output::SavedIdentityAction::Created, + welcome.as_ref(), ) .await } diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index f41bebf..206cdc0 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; use super::{ @@ -113,6 +113,7 @@ async fn confirm_default_target( async fn run_helper_apply( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, ) -> Result<(), Error> { @@ -125,6 +126,7 @@ async fn run_helper_apply( super::apply::run_with_policy( &command, dhttp_home, + home_scope, cert_server, super::apply::ApplyPostSavePolicy::SkipDefaultSuggestion, ) @@ -146,6 +148,7 @@ fn helper_apply_command(target: &IdentityTarget) -> crate::cli::Apply { async fn summary_for_named_default_target( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, configured_default_name: Option>, @@ -168,7 +171,7 @@ async fn summary_for_named_default_target( ); } - run_helper_apply(dhttp_home, cert_server, target).await?; + run_helper_apply(dhttp_home, home_scope, cert_server, target).await?; local::try_load_summary(dhttp_home, target.dhttp_name(), configured_default_name) .await? .whatever_context::<_, Error>("helper apply did not save the requested identity") @@ -176,6 +179,7 @@ async fn summary_for_named_default_target( async fn select_interactive_default_summary( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, configured_default_name: Option>, ) -> Result { @@ -219,6 +223,7 @@ async fn select_interactive_default_summary( DefaultOrganizationAction::ApplyToLocalDevice => { return summary_for_named_default_target( dhttp_home, + home_scope, cert_server, &target, configured_default_name, @@ -235,6 +240,7 @@ async fn select_interactive_default_summary( pub(crate) async fn run( command: &Default, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { let current_config = cli::load_current_settings(dhttp_home).await?; @@ -283,6 +289,7 @@ pub(crate) async fn run( let selected_summary = select_interactive_default_summary( dhttp_home, + home_scope, cert_server, configured_default_name .as_ref() @@ -297,12 +304,23 @@ pub(crate) async fn run( stdin_is_terminal, ) .await?; - set_default_summary(dhttp_home, current_config, selected_summary).await + set_default_summary(dhttp_home, current_config, selected_summary.clone()).await?; + let block = super::epilogue::default_block( + configured_default_name + .as_ref() + .map(|default| default.as_partial()), + Some(selected_summary.target.short_name()), + ); + crate::cli::flow::transcript::print_line(output::format_default_identity_sentence( + &block, + )); + Ok(()) } Some(name) => { let target = IdentityTarget::parse(name)?; let summary = summary_for_named_default_target( dhttp_home, + home_scope, cert_server, &target, configured_default_name @@ -318,7 +336,17 @@ pub(crate) async fn run( stdin_is_terminal, ) .await?; - set_default_summary(dhttp_home, current_config, summary).await + set_default_summary(dhttp_home, current_config, summary.clone()).await?; + let block = super::epilogue::default_block( + configured_default_name + .as_ref() + .map(|default| default.as_partial()), + Some(summary.target.short_name()), + ); + crate::cli::flow::transcript::print_line(output::format_default_identity_sentence( + &block, + )); + Ok(()) } } } diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index 85db3b1..f3ce19f 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -103,6 +103,8 @@ pub(crate) async fn run_lifecycle_epilogue( name: DhttpName<'_>, default_at_start: Option>, interactive: bool, + action: output::SavedIdentityAction, + welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { let ansi = std::io::stdout().is_terminal(); let mut default_after = current_default_name(dhttp_home).await?; @@ -114,7 +116,9 @@ pub(crate) async fn run_lifecycle_epilogue( ) .await?; - transcript::print_block(&output::format_info(&summary, ansi)); + transcript::print_block(&output::format_saved_identity_result( + action, &summary, ansi, + )); transcript::print_line(output::format_safekeeping_reminder(ansi)); if interactive @@ -140,13 +144,18 @@ pub(crate) async fn run_lifecycle_epilogue( .map(|default| default.as_partial()), default_after.as_ref().map(|default| default.as_partial()), ); - transcript::print_line(output::format_default_identity_block(&block)); + transcript::print_line(output::format_default_identity_sentence(&block)); + if let Some(welcome) = welcome { + transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); + } Ok(()) } pub(crate) async fn run_local_epilogue( dhttp_home: &DhttpHome, name: DhttpName<'_>, + action: output::SavedIdentityAction, + welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { let ansi = std::io::stdout().is_terminal(); let default_name = current_default_name(dhttp_home).await?; @@ -156,8 +165,13 @@ pub(crate) async fn run_local_epilogue( default_name.as_ref().map(|default| default.borrow()), ) .await?; - transcript::print_block(&output::format_info(&summary, ansi)); + transcript::print_block(&output::format_saved_identity_result( + action, &summary, ansi, + )); transcript::print_line(output::format_safekeeping_reminder(ansi)); + if let Some(welcome) = welcome { + transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); + } Ok(()) } @@ -236,9 +250,16 @@ mod tests { let profile = dhttp_home.identity_profile(name.borrow()); fs::create_dir_all(profile.ssl_dir()).await.unwrap(); - super::run_lifecycle_epilogue(&dhttp_home, name.borrow(), None, false) - .await - .unwrap(); + super::run_lifecycle_epilogue( + &dhttp_home, + name.borrow(), + None, + false, + crate::cli::flow::output::SavedIdentityAction::Created, + None, + ) + .await + .unwrap(); assert!( super::current_default_name(&dhttp_home) diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 3008226..3306a83 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -1,5 +1,4 @@ use crossterm::style::Stylize; -use time::OffsetDateTime; use super::{ local::{ @@ -64,6 +63,23 @@ pub(crate) enum DefaultIdentityBlock { Changed { old: String, new: String }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SavedIdentityAction { + Created, + Applied, + Renewed, +} + +impl SavedIdentityAction { + fn verb(self) -> &'static str { + match self { + Self::Created => "Created", + Self::Applied => "Applied", + Self::Renewed => "Renewed", + } + } +} + pub(crate) fn summary_line_style(summary: &LocalIdentitySummary) -> LineStyle { match status_line_style(&summary.status) { LineStyle::Dim => LineStyle::Dim, @@ -151,17 +167,13 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo } } -pub(crate) fn format_default_identity_block(block: &DefaultIdentityBlock) -> String { +pub(crate) fn format_default_identity_sentence(block: &DefaultIdentityBlock) -> String { match block { - DefaultIdentityBlock::None => "Default identity: (none)".to_string(), - DefaultIdentityBlock::NewlySet { name } => { - format!("Default identity: {name} (newly set)") - } - DefaultIdentityBlock::Unchanged { name } => { - format!("Default identity: {name} (unchanged)") - } + DefaultIdentityBlock::None => "No default identity is set here".to_string(), + DefaultIdentityBlock::NewlySet { name } => format!("Default identity set to {name}"), + DefaultIdentityBlock::Unchanged { name } => format!("Default identity remains {name}"), DefaultIdentityBlock::Changed { old, new } => { - format!("Default identity: {new} (changed from {old})") + format!("Default identity changed from {old} to {new}") } } } @@ -174,18 +186,33 @@ pub(crate) fn format_safekeeping_reminder(ansi: bool) -> String { ) } +pub(crate) fn format_saved_identity_result( + action: SavedIdentityAction, + summary: &LocalIdentitySummary, + ansi: bool, +) -> String { + let mut lines = Vec::new(); + lines.push(render_line( + format!( + "{} identity {}", + action.verb(), + compact_identity_label(summary) + ), + summary_line_style(summary), + ansi, + )); + lines.extend(detail_lines(summary)); + lines.join("\n") +} + pub(crate) fn format_info(summary: &LocalIdentitySummary, ansi: bool) -> String { let mut lines = Vec::new(); lines.push(render_line( - format!("Name: {}", summary_name(summary)), + compact_identity_label(summary), summary_line_style(summary), ansi, )); - if let Some(certificate_chain) = summary.certificate_chain.as_ref() { - lines.push(format!("Certificate chain: {certificate_chain}")); - } - lines.push(format!("Status: {}", format_status(summary.status.clone()))); - lines.push(format!("Saved at: {}", summary.saved_at.display())); + lines.extend(detail_lines(summary)); lines.join("\n") } @@ -193,6 +220,23 @@ pub(crate) fn format_default_summary(summary: &LocalIdentitySummary, ansi: bool) format_info(summary, ansi) } +fn detail_lines(summary: &LocalIdentitySummary) -> Vec { + let mut lines = Vec::new(); + match (&summary.status, summary.certificate_chain.as_deref()) { + (LocalIdentityStatus::Ready { .. }, Some(chain)) + | (LocalIdentityStatus::Expired { .. }, Some(chain)) => { + lines.push(format!(" uses certificate chain {chain}")); + } + (LocalIdentityStatus::Incomplete { detail }, _) + | (LocalIdentityStatus::Invalid { detail }, _) => { + lines.push(format!(" {detail}")); + } + _ => {} + } + lines.push(format!(" saved at {}", summary.saved_at.display())); + lines +} + fn render_root(root: &LocalInventoryRoot, width: usize, ansi: bool) -> String { match root { LocalInventoryRoot::Saved(summary) => render_line( @@ -246,54 +290,15 @@ fn summary_label(summary: &LocalIdentitySummary) -> String { label } -fn summary_name(summary: &LocalIdentitySummary) -> String { - let mut name = summary.target.short_name().to_string(); - if matches!(summary.target.level(), IdentityLevel::SubIdentity) - && let Some(parent) = summary.target.parent() - { - name.push_str(&format!(" (sub-identity of {})", parent.as_partial())); - } - if summary.is_default { - name.push_str(" (default identity)"); - } - name -} - -fn format_status(status: LocalIdentityStatus) -> String { - match status { - LocalIdentityStatus::Ready { expires_at } => { - format!("ready (expires after {})", format_timestamp(expires_at)) - } - LocalIdentityStatus::Expired { expired_at } => { - format!("expired (expired after {})", format_timestamp(expired_at)) - } - LocalIdentityStatus::Incomplete { detail } => format!("incomplete ({detail})"), - LocalIdentityStatus::Invalid { detail } => format!("invalid ({detail})"), - } -} - -fn format_timestamp(timestamp: i64) -> String { - let datetime = OffsetDateTime::from_unix_timestamp(timestamp) - .expect("BUG: timestamps should be representable"); - format!( - "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", - datetime.year(), - u8::from(datetime.month()), - datetime.day(), - datetime.hour(), - datetime.minute(), - datetime.second() - ) -} - #[cfg(test)] mod tests { use std::path::PathBuf; use super::{ - DefaultIdentityBlock, LineStyle, compact_identity_label, format_current_default_suffix, - format_default_identity_block, format_default_summary, format_info, render_choice_label, - render_inventory, summary_line_style, + DefaultIdentityBlock, LineStyle, SavedIdentityAction, compact_identity_label, + format_current_default_suffix, format_default_identity_sentence, format_default_summary, + format_info, format_saved_identity_result, render_choice_label, render_inventory, + summary_line_style, }; use crate::cli::flow::{ local::{ @@ -330,16 +335,31 @@ mod tests { Some("secondary:2"), ); - let expected = "\ -Name: phone.alice.smith (sub-identity of alice.smith) (default identity)\n\ -Certificate chain: secondary:2\n\ -Status: ready (expires after 2026-11-10 08:12:44 UTC)\n\ -Saved at: /tmp/phone.alice.smith"; + let expected = "phone.alice.smith [ready] (default identity)\n uses certificate chain secondary:2\n saved at /tmp/phone.alice.smith"; assert_eq!(format_info(&profile, false), expected); assert_eq!(format_default_summary(&profile, false), expected); } + #[test] + fn formats_created_identity_result() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + ); + + let expected = "Created identity alice.smith [ready]\n uses certificate chain primary:0\n saved at /tmp/alice.smith"; + + assert_eq!( + format_saved_identity_result(SavedIdentityAction::Created, &profile, false), + expected, + ); + } + #[test] fn ready_default_line_prefers_bold() { let profile = summary( @@ -382,6 +402,22 @@ Saved at: /tmp/phone.alice.smith"; assert_eq!(summary_line_style(&profile), LineStyle::Dim); } + #[test] + fn formats_invalid_identity_without_field_labels() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Invalid { + detail: "certificate chain metadata is invalid".to_string(), + }, + None, + ); + + let expected = "alice.smith [invalid]\n certificate chain metadata is invalid\n saved at /tmp/alice.smith"; + + assert_eq!(format_info(&profile, false), expected); + } + #[test] fn current_default_suffix_uses_compact_label_text() { assert_eq!( @@ -397,10 +433,29 @@ Saved at: /tmp/phone.alice.smith"; } #[test] - fn formats_none_default_block() { + fn formats_default_identity_sentences() { + assert_eq!( + format_default_identity_sentence(&DefaultIdentityBlock::NewlySet { + name: "alice.smith".to_string(), + }), + "Default identity set to alice.smith" + ); + assert_eq!( + format_default_identity_sentence(&DefaultIdentityBlock::Changed { + old: "meng.lin".to_string(), + new: "alice.smith".to_string(), + }), + "Default identity changed from meng.lin to alice.smith" + ); + assert_eq!( + format_default_identity_sentence(&DefaultIdentityBlock::Unchanged { + name: "alice.smith".to_string(), + }), + "Default identity remains alice.smith" + ); assert_eq!( - format_default_identity_block(&DefaultIdentityBlock::None), - "Default identity: (none)" + format_default_identity_sentence(&DefaultIdentityBlock::None), + "No default identity is set here" ); } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 2b0e645..1045165 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -634,7 +634,13 @@ async fn run_interactive( ) .instrument(info_span!("save_identity")) .await?; - return crate::cli::flow::epilogue::run_local_epilogue(dhttp_home, domain.borrow()).await; + return crate::cli::flow::epilogue::run_local_epilogue( + dhttp_home, + domain.borrow(), + crate::cli::flow::output::SavedIdentityAction::Renewed, + None, + ) + .await; } } @@ -737,7 +743,13 @@ pub(crate) async fn run( detail.cert_pem.as_bytes(), ) .await?; - crate::cli::flow::epilogue::run_local_epilogue(dhttp_home, domain.borrow()).await + crate::cli::flow::epilogue::run_local_epilogue( + dhttp_home, + domain.borrow(), + crate::cli::flow::output::SavedIdentityAction::Renewed, + None, + ) + .await } #[cfg(test)] diff --git a/genmeta-identity/src/cli/flow/welcome.rs b/genmeta-identity/src/cli/flow/welcome.rs new file mode 100644 index 0000000..cd41731 --- /dev/null +++ b/genmeta-identity/src/cli/flow/welcome.rs @@ -0,0 +1,350 @@ +use std::path::{Path, PathBuf}; + +use dhttp::{ + home::{DhttpHome, HomeScope}, + name::DhttpName, +}; +use snafu::{IntoError, ResultExt, Snafu}; +use tokio::{fs, io::AsyncWriteExt}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WelcomeServiceCreated { + pub(crate) server_conf_path: PathBuf, + pub(crate) index_html_path: PathBuf, + pub(crate) url: String, +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum WelcomeServiceError { + #[cfg(unix)] + #[snafu(display("failed to determine whether welcome service onboarding is allowed"))] + EligibilityLookup { source: nix::errno::Errno }, + + #[snafu(display("failed to create identity profile directory at {}", path.display()))] + CreateProfileDir { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to inspect welcome service file {}", path.display()))] + Metadata { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to create welcome service file {}", path.display()))] + CreateFile { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to write welcome service file {}", path.display()))] + WriteFile { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to roll back incomplete welcome service file {}", path.display()))] + RollbackDelete { + path: PathBuf, + source: std::io::Error, + }, +} + +const SERVER_CONF_TEMPLATE: &str = "server { + listen all 0; + + location / { + root .; + index index.html; + } +} +"; + +pub(crate) async fn maybe_create_welcome_service( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, + home_scope: HomeScope, +) -> Result, WelcomeServiceError> { + maybe_create_welcome_service_with_probe(dhttp_home, name, home_scope, user_in_pishoo_group) + .await +} + +pub(crate) async fn maybe_create_welcome_service_with_probe( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, + home_scope: HomeScope, + user_in_pishoo_group: F, +) -> Result, WelcomeServiceError> +where + F: Fn() -> Result, +{ + if !welcome_onboarding_allowed(home_scope, &user_in_pishoo_group)? { + return Ok(None); + } + + let profile = dhttp_home.identity_profile(name.borrow()); + let server_conf_path = profile.server_conf_path(); + let index_html_path = profile.join("index.html"); + + if path_exists(&server_conf_path).await? || path_exists(&index_html_path).await? { + return Ok(None); + } + + fs::create_dir_all(profile.path()).await.context( + welcome_service_error::CreateProfileDirSnafu { + path: profile.path().to_path_buf(), + }, + )?; + + write_new_file(&server_conf_path, SERVER_CONF_TEMPLATE.as_bytes()).await?; + + let index_html = render_index_html(name.as_partial()); + + if let Err(error) = write_new_file(&index_html_path, index_html.as_bytes()).await { + if let Err(source) = fs::remove_file(&server_conf_path).await { + return Err(welcome_service_error::RollbackDeleteSnafu { + path: server_conf_path.clone(), + } + .into_error(source)); + } + return Err(error); + } + + Ok(Some(WelcomeServiceCreated { + server_conf_path, + index_html_path, + url: format!("https://{}/", name.as_partial()), + })) +} + +pub(crate) fn format_welcome_service_created(created: &WelcomeServiceCreated) -> String { + format!( + "Welcome service created\n Created server.conf at {}\n Created index.html at {}\n Open {} after pishoo starts or reloads", + created.server_conf_path.display(), + created.index_html_path.display(), + created.url, + ) +} + +fn render_index_html(name: &str) -> String { + format!( + "\n\ +\n\ + \n\ + \n\ + {name}\n\ + \n\ + \n\ +

Welcome to {name}

\n\ +

This page was created by genmeta identity.

\n\ +

If you can open this page, pishoo is serving this identity.

\n\ + \n\ +\n" + ) +} + +fn welcome_onboarding_allowed( + home_scope: HomeScope, + user_in_pishoo_group: &F, +) -> Result +where + F: Fn() -> Result, +{ + #[cfg(unix)] + { + match home_scope { + HomeScope::Global => Ok(true), + HomeScope::User => user_in_pishoo_group(), + } + } + + #[cfg(not(unix))] + { + let _ = home_scope; + let _ = user_in_pishoo_group; + Ok(false) + } +} + +#[cfg(unix)] +fn user_in_pishoo_group() -> Result { + use nix::unistd::{Group, getegid, getgroups}; + + let Some(group) = + Group::from_name("pishoo").context(welcome_service_error::EligibilityLookupSnafu)? + else { + return Ok(false); + }; + + if getegid() == group.gid { + return Ok(true); + } + + let groups = getgroups().context(welcome_service_error::EligibilityLookupSnafu)?; + Ok(groups.into_iter().any(|gid| gid == group.gid)) +} + +#[cfg(not(unix))] +fn user_in_pishoo_group() -> Result { + Ok(false) +} + +async fn path_exists(path: &Path) -> Result { + match fs::try_exists(path).await { + Ok(exists) => Ok(exists), + Err(source) => Err(welcome_service_error::MetadataSnafu { + path: path.to_path_buf(), + } + .into_error(source)), + } +} + +async fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), WelcomeServiceError> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + let mut file = options + .open(path) + .await + .context(welcome_service_error::CreateFileSnafu { + path: path.to_path_buf(), + })?; + file.write_all(contents) + .await + .context(welcome_service_error::WriteFileSnafu { + path: path.to_path_buf(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use dhttp::{ + home::{DhttpHome, HomeScope}, + name::DhttpName, + }; + + use super::{format_welcome_service_created, maybe_create_welcome_service_with_probe}; + + fn unique_test_home_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-welcome-{label}-{}-{nonce}", + std::process::id() + )) + } + + #[tokio::test] + async fn user_scope_requires_pishoo_group_for_welcome_onboarding() { + let home = DhttpHome::new(unique_test_home_path("user-scope-no-group")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + + let created = + maybe_create_welcome_service_with_probe(&home, name.borrow(), HomeScope::User, || { + Ok(false) + }) + .await + .unwrap(); + + assert!(created.is_none()); + assert!( + !home + .identity_profile(name.borrow()) + .server_conf_path() + .exists() + ); + } + + #[tokio::test] + async fn global_scope_bypasses_group_gate() { + let home = DhttpHome::new(unique_test_home_path("global-scope")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + + let created = maybe_create_welcome_service_with_probe( + &home, + name.borrow(), + HomeScope::Global, + || Ok(false), + ) + .await + .unwrap(); + + let created = created.expect("global scope should create welcome files"); + assert!(created.server_conf_path.exists()); + assert!(created.index_html_path.exists()); + } + + #[tokio::test] + async fn skips_pair_creation_when_server_conf_already_exists() { + let home = DhttpHome::new(unique_test_home_path("server-conf-exists")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + let profile = home.identity_profile(name.borrow()); + tokio::fs::create_dir_all(profile.path()).await.unwrap(); + tokio::fs::write(profile.server_conf_path(), "server { listen all 0; }") + .await + .unwrap(); + + let created = maybe_create_welcome_service_with_probe( + &home, + name.borrow(), + HomeScope::Global, + || Ok(true), + ) + .await + .unwrap(); + + assert!(created.is_none()); + assert!(!profile.join("index.html").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn rolls_back_server_conf_when_index_html_creation_fails() { + use std::os::unix::fs::symlink; + + let home = DhttpHome::new(unique_test_home_path("rollback")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + let profile = home.identity_profile(name.borrow()); + tokio::fs::create_dir_all(profile.path()).await.unwrap(); + symlink( + profile.join("missing-index-html-target"), + profile.join("index.html"), + ) + .unwrap(); + + let error = maybe_create_welcome_service_with_probe( + &home, + name.borrow(), + HomeScope::Global, + || Ok(true), + ) + .await + .expect_err("index.html directory should make file creation fail"); + + let rendered = error.to_string(); + assert!(rendered.contains("welcome service"), "{rendered}"); + assert!(!profile.server_conf_path().exists()); + } + + #[test] + fn renders_welcome_service_created_block() { + let created = super::WelcomeServiceCreated { + server_conf_path: PathBuf::from("/tmp/alice/server.conf"), + index_html_path: PathBuf::from("/tmp/alice/index.html"), + url: "https://alice.smith/".to_string(), + }; + + let expected = "Welcome service created\n Created server.conf at /tmp/alice/server.conf\n Created index.html at /tmp/alice/index.html\n Open https://alice.smith/ after pishoo starts or reloads"; + + assert_eq!(format_welcome_service_created(&created), expected); + } +} From 4c9c70d83e0ba8ae386f5a55d7ccf1546d818eda Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 25 Jun 2026 17:44:26 +0800 Subject: [PATCH 16/21] chore: prepare gmutils 0.6.1 release --- .github/workflows/publish-crates.yml | 3 ++ CHANGELOG.md | 40 +++++++++++++++++ Cargo.lock | 65 +++++++++++++++------------- Cargo.toml | 24 +++++----- genmeta-access/Cargo.toml | 2 +- genmeta-curl/Cargo.toml | 2 +- genmeta-discover/Cargo.toml | 2 +- genmeta-doctor/Cargo.toml | 2 +- genmeta-identity/Cargo.toml | 2 +- genmeta-nat/Cargo.toml | 2 +- genmeta-nslookup/Cargo.toml | 2 +- genmeta-proxy/Cargo.toml | 2 +- genmeta-ssh/Cargo.toml | 2 +- genmeta/Cargo.toml | 2 +- 14 files changed, 99 insertions(+), 53 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 9d7257b..e6fc56b 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -39,6 +39,9 @@ jobs: - name: Run tests run: cargo test --workspace --all-targets --all-features + - name: Validate workspace publish graph + run: cargo publish --workspace --exclude xtask --dry-run --locked + - name: Plan crates.io publish packages id: publish_plan shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cb748..6e0c971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] - 2026-06-24 + +### Added + +- CLI tools now support scoped dhttp home selection across identity, access, + curl, nslookup, NAT, proxy, and SSH flows. +- Release packaging now reads its build and destination contract from + `xtask/release.toml`, including per-target build environment bindings. + +### Changed + +- Identity flows use explicit apply targets, replacement-aware default prompts, + and selected-home local-state behavior for missing identity homes. +- Homebrew and S3/R2 package generation use the normalized manifest-first + packaging contract. + +### Fixed + +- Identity `default` and `renew` commands now report missing selected-home state + through user-facing business errors instead of raw filesystem/profile errors. +- CLI and package integration are aligned with the scoped dhttp home rollout. + +### Dependencies + +- Release manifests now target `h3x` v0.5.0, `dhttp` v0.3.0, + `dhttp-access` v0.2.0, `dshell` v0.5.0, `dyns` v0.5.0, and `rankey` v0.2.1. + +### Components + +- `genmeta` v0.6.1 +- `genmeta-curl` v0.5.1 +- `genmeta-ssh` v0.6.1 +- `genmeta-access` v0.2.1 +- `genmeta-identity` v0.2.1 +- `genmeta-proxy` v0.2.1 +- `genmeta-discover` v0.3.1 +- `genmeta-doctor` v0.3.1 +- `genmeta-nat` v0.3.1 +- `genmeta-nslookup` v0.3.1 + ## [0.6.0] - 2026-06-15 This release brings the command-line tool family onto the public DHTTP diff --git a/Cargo.lock b/Cargo.lock index eda4a7e..2b54992 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "dhttp" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bon", "bytes", @@ -1681,7 +1681,7 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" -version = "0.5.1" +version = "0.6.0" dependencies = [ "arc-swap", "dashmap", @@ -1697,9 +1697,7 @@ dependencies = [ [[package]] name = "dshell" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "210e6b448354575feab2fa1f5245a7702c651004a57a0888d4607edc9b7c1bdd" +version = "0.5.0" dependencies = [ "base64", "bytes", @@ -1725,7 +1723,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", "bitfield-struct", @@ -2136,7 +2134,7 @@ dependencies = [ [[package]] name = "genmeta" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "genmeta-access", @@ -2155,7 +2153,7 @@ dependencies = [ [[package]] name = "genmeta-access" -version = "0.2.0" +version = "0.2.1" dependencies = [ "clap", "dhttp", @@ -2170,7 +2168,7 @@ dependencies = [ [[package]] name = "genmeta-curl" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-compression", "bytes", @@ -2187,7 +2185,7 @@ dependencies = [ [[package]] name = "genmeta-discover" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "dhttp", @@ -2201,7 +2199,7 @@ dependencies = [ [[package]] name = "genmeta-doctor" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "genmeta-nat", @@ -2212,7 +2210,7 @@ dependencies = [ [[package]] name = "genmeta-identity" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base64", "bytes", @@ -2223,6 +2221,7 @@ dependencies = [ "http 1.4.2", "indicatif", "inquire", + "nix 0.31.3", "p384", "pkcs8 0.11.0-rc.11", "rankey", @@ -2243,7 +2242,7 @@ dependencies = [ [[package]] name = "genmeta-nat" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "dhttp", @@ -2257,7 +2256,7 @@ dependencies = [ [[package]] name = "genmeta-nslookup" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "dhttp", @@ -2271,7 +2270,7 @@ dependencies = [ [[package]] name = "genmeta-proxy" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bytes", "clap", @@ -2291,7 +2290,7 @@ dependencies = [ [[package]] name = "genmeta-ssh" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "crossterm", @@ -2430,7 +2429,7 @@ dependencies = [ [[package]] name = "h3x" -version = "0.4.1" +version = "0.5.0" dependencies = [ "arc-swap", "async-channel", @@ -2747,7 +2746,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3963,7 +3962,7 @@ dependencies = [ [[package]] name = "qbase" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bitflags", "bytes", @@ -3984,7 +3983,7 @@ dependencies = [ [[package]] name = "qcongestion" -version = "0.5.1" +version = "0.6.0" dependencies = [ "qbase", "qevent", @@ -3996,7 +3995,7 @@ dependencies = [ [[package]] name = "qconnection" -version = "0.5.2" +version = "0.6.0" dependencies = [ "bytes", "dashmap", @@ -4022,7 +4021,7 @@ dependencies = [ [[package]] name = "qdatagram" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bytes", "qbase", @@ -4032,7 +4031,7 @@ dependencies = [ [[package]] name = "qevent" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bytes", "derive_builder", @@ -4049,7 +4048,7 @@ dependencies = [ [[package]] name = "qinterface" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bytes", "dashmap", @@ -4081,7 +4080,7 @@ dependencies = [ [[package]] name = "qrecovery" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bytes", "derive_more", @@ -4095,7 +4094,7 @@ dependencies = [ [[package]] name = "qresolve" -version = "0.5.1" +version = "0.6.0" dependencies = [ "futures", "qbase", @@ -4104,7 +4103,7 @@ dependencies = [ [[package]] name = "qtraversal" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bon", "bytes", @@ -4126,7 +4125,7 @@ dependencies = [ [[package]] name = "qudp" -version = "0.5.1" +version = "0.6.0" dependencies = [ "bytes", "cfg-if", @@ -5295,7 +5294,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5307,7 +5306,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6508,7 +6507,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7169,3 +7168,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "qprotocol" +version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 8dcf44f..5f52d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ indicatif = "0.18" whoami = "2" # network -h3x = "0.4.0" +h3x = "0.5.0" http = "1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["server", "tokio"] } @@ -96,19 +96,19 @@ async-compression = { version = "0.4", features = [ # workspace dhttp-access = "0.2.0" -genmeta-access = { path = "genmeta-access", version = "0.2.0" } -dhttp = "0.2.0" -genmeta-curl = { path = "genmeta-curl", version = "0.5.0" } -genmeta-discover = { path = "genmeta-discover", version = "0.3.0" } -genmeta-doctor = { path = "genmeta-doctor", version = "0.3.0" } -genmeta-identity = { path = "genmeta-identity", version = "0.2.0" } -genmeta-nat = { path = "genmeta-nat", version = "0.3.0" } -genmeta-nslookup = { path = "genmeta-nslookup", version = "0.3.0" } -genmeta-ssh = { path = "genmeta-ssh", version = "0.6.0" } -genmeta-proxy = { path = "genmeta-proxy", version = "0.2.0" } +genmeta-access = { path = "genmeta-access", version = "0.2.1" } +dhttp = "0.3.0" +genmeta-curl = { path = "genmeta-curl", version = "0.5.1" } +genmeta-discover = { path = "genmeta-discover", version = "0.3.1" } +genmeta-doctor = { path = "genmeta-doctor", version = "0.3.1" } +genmeta-identity = { path = "genmeta-identity", version = "0.2.1" } +genmeta-nat = { path = "genmeta-nat", version = "0.3.1" } +genmeta-nslookup = { path = "genmeta-nslookup", version = "0.3.1" } +genmeta-ssh = { path = "genmeta-ssh", version = "0.6.1" } +genmeta-proxy = { path = "genmeta-proxy", version = "0.2.1" } # DShell -dshell = { version = "0.4.0", features = [ +dshell = { version = "0.5.0", features = [ "config", ] } diff --git a/genmeta-access/Cargo.toml b/genmeta-access/Cargo.toml index e96c530..f3a63fd 100644 --- a/genmeta-access/Cargo.toml +++ b/genmeta-access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-access" description = "access control rule management" -version = "0.2.0" +version = "0.2.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-curl/Cargo.toml b/genmeta-curl/Cargo.toml index a334542..91a76be 100644 --- a/genmeta-curl/Cargo.toml +++ b/genmeta-curl/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-curl" description = "curl-like DHTTP/3 client" -version = "0.5.0" +version = "0.5.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-discover/Cargo.toml b/genmeta-discover/Cargo.toml index 5c748cb..a4de053 100644 --- a/genmeta-discover/Cargo.toml +++ b/genmeta-discover/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-discover" description = "mdns discover services" -version = "0.3.0" +version = "0.3.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-doctor/Cargo.toml b/genmeta-doctor/Cargo.toml index e0b0a17..6ccd29e 100644 --- a/genmeta-doctor/Cargo.toml +++ b/genmeta-doctor/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-doctor" description = "diagnosing and fixing environment issues" -version = "0.3.0" +version = "0.3.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index 23e8b70..c16fbeb 100644 --- a/genmeta-identity/Cargo.toml +++ b/genmeta-identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-identity" description = "managing identities for genmeta network" -version = "0.2.0" +version = "0.2.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-nat/Cargo.toml b/genmeta-nat/Cargo.toml index 2b4ae4f..d4b7008 100644 --- a/genmeta-nat/Cargo.toml +++ b/genmeta-nat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nat" description = "Diagnose network and environment issues" -version = "0.3.0" +version = "0.3.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-nslookup/Cargo.toml b/genmeta-nslookup/Cargo.toml index ab597ec..89b194a 100644 --- a/genmeta-nslookup/Cargo.toml +++ b/genmeta-nslookup/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nslookup" description = "resolve domain names" -version = "0.3.0" +version = "0.3.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-proxy/Cargo.toml b/genmeta-proxy/Cargo.toml index 6ca3d94..d012a1e 100644 --- a/genmeta-proxy/Cargo.toml +++ b/genmeta-proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-proxy" description = "forward proxy routing .dhttp.net requests over DHTTP/3" -version = "0.2.0" +version = "0.2.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-ssh/Cargo.toml b/genmeta-ssh/Cargo.toml index d494891..c2fdfb5 100644 --- a/genmeta-ssh/Cargo.toml +++ b/genmeta-ssh/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-ssh" description = "DShell client" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta/Cargo.toml b/genmeta/Cargo.toml index f77659c..305958c 100644 --- a/genmeta/Cargo.toml +++ b/genmeta/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta" description = "Genmeta Binary Utilities" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository.workspace = true From 84b2eef3a1962f7167549969bab155f4957d2968 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 25 Jun 2026 17:52:42 +0800 Subject: [PATCH 17/21] feat(identity): serve welcome page from template path --- genmeta-identity/src/cli/flow/welcome.rs | 159 +++++++++++++++++++---- 1 file changed, 137 insertions(+), 22 deletions(-) diff --git a/genmeta-identity/src/cli/flow/welcome.rs b/genmeta-identity/src/cli/flow/welcome.rs index cd41731..fc95655 100644 --- a/genmeta-identity/src/cli/flow/welcome.rs +++ b/genmeta-identity/src/cli/flow/welcome.rs @@ -10,7 +10,7 @@ use tokio::{fs, io::AsyncWriteExt}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WelcomeServiceCreated { pub(crate) server_conf_path: PathBuf, - pub(crate) index_html_path: PathBuf, + pub(crate) welcome_page_path: PathBuf, pub(crate) url: String, } @@ -27,6 +27,12 @@ pub enum WelcomeServiceError { source: std::io::Error, }, + #[snafu(display("failed to create welcome page directory at {}", path.display()))] + CreateWelcomePageDir { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to inspect welcome service file {}", path.display()))] Metadata { path: PathBuf, @@ -56,12 +62,14 @@ const SERVER_CONF_TEMPLATE: &str = "server { listen all 0; location / { - root .; + root templates/welcome; index index.html; } } "; +const WELCOME_PAGE_PATH: &str = "templates/welcome/index.html"; + pub(crate) async fn maybe_create_welcome_service( dhttp_home: &DhttpHome, name: DhttpName<'_>, @@ -86,9 +94,9 @@ where let profile = dhttp_home.identity_profile(name.borrow()); let server_conf_path = profile.server_conf_path(); - let index_html_path = profile.join("index.html"); + let welcome_page_path = profile.join(WELCOME_PAGE_PATH); - if path_exists(&server_conf_path).await? || path_exists(&index_html_path).await? { + if path_exists(&server_conf_path).await? || path_exists(&welcome_page_path).await? { return Ok(None); } @@ -98,11 +106,20 @@ where }, )?; + let welcome_page_dir = welcome_page_path + .parent() + .expect("welcome page path should have a parent directory"); + fs::create_dir_all(welcome_page_dir).await.context( + welcome_service_error::CreateWelcomePageDirSnafu { + path: welcome_page_dir.to_path_buf(), + }, + )?; + write_new_file(&server_conf_path, SERVER_CONF_TEMPLATE.as_bytes()).await?; - let index_html = render_index_html(name.as_partial()); + let welcome_page = render_welcome_page(); - if let Err(error) = write_new_file(&index_html_path, index_html.as_bytes()).await { + if let Err(error) = write_new_file(&welcome_page_path, welcome_page.as_bytes()).await { if let Err(source) = fs::remove_file(&server_conf_path).await { return Err(welcome_service_error::RollbackDeleteSnafu { path: server_conf_path.clone(), @@ -114,35 +131,96 @@ where Ok(Some(WelcomeServiceCreated { server_conf_path, - index_html_path, + welcome_page_path, url: format!("https://{}/", name.as_partial()), })) } pub(crate) fn format_welcome_service_created(created: &WelcomeServiceCreated) -> String { format!( - "Welcome service created\n Created server.conf at {}\n Created index.html at {}\n Open {} after pishoo starts or reloads", + "Welcome service created\n Created server.conf at {}\n Created welcome page at {}\n Open {} after pishoo starts or reloads", created.server_conf_path.display(), - created.index_html_path.display(), + created.welcome_page_path.display(), created.url, ) } -fn render_index_html(name: &str) -> String { - format!( - "\n\ +fn render_welcome_page() -> &'static str { + "\n\ \n\ \n\ \n\ - {name}\n\ + \n\ + Hello from DHTTP\n\ + \n\ \n\ \n\ -

Welcome to {name}

\n\ -

This page was created by genmeta identity.

\n\ -

If you can open this page, pishoo is serving this identity.

\n\ +
\n\ +

Hello from DHTTP.

\n\ +

This identity is ready to serve.

\n\ +\n\ +

Next steps

\n\ +
    \n\ +
  • Replace this page with your own site.
  • \n\ +
  • Add routes in server.conf to serve files or proxy services.
  • \n\ +
  • Reload pishoo after changing your service configuration.
  • \n\ +
\n\ +\n\ +

Generated by genmeta identity.

\n\ +
\n\ \n\ \n" - ) } fn welcome_onboarding_allowed( @@ -279,8 +357,42 @@ mod tests { .unwrap(); let created = created.expect("global scope should create welcome files"); + let profile = home.identity_profile(name.borrow()); assert!(created.server_conf_path.exists()); - assert!(created.index_html_path.exists()); + assert!(created.welcome_page_path.exists()); + assert_eq!( + created.welcome_page_path, + profile.join("templates/welcome/index.html") + ); + assert!(!profile.join("index.html").exists()); + + let server_conf = tokio::fs::read_to_string(&created.server_conf_path) + .await + .unwrap(); + assert!( + server_conf.contains("root templates/welcome;"), + "{server_conf}" + ); + + let welcome_page = tokio::fs::read_to_string(&created.welcome_page_path) + .await + .unwrap(); + assert!( + welcome_page.contains("

Hello from DHTTP.

"), + "{welcome_page}" + ); + assert!( + welcome_page.contains("This identity is ready to serve."), + "{welcome_page}" + ); + assert!( + welcome_page.contains("Add routes in server.conf"), + "{welcome_page}" + ); + assert!( + !welcome_page.contains("templates/welcome"), + "{welcome_page}" + ); } #[tokio::test] @@ -303,7 +415,7 @@ mod tests { .unwrap(); assert!(created.is_none()); - assert!(!profile.join("index.html").exists()); + assert!(!profile.join("templates/welcome/index.html").exists()); } #[cfg(unix)] @@ -315,9 +427,12 @@ mod tests { let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); let profile = home.identity_profile(name.borrow()); tokio::fs::create_dir_all(profile.path()).await.unwrap(); + tokio::fs::create_dir_all(profile.join("templates/welcome")) + .await + .unwrap(); symlink( profile.join("missing-index-html-target"), - profile.join("index.html"), + profile.join("templates/welcome/index.html"), ) .unwrap(); @@ -339,11 +454,11 @@ mod tests { fn renders_welcome_service_created_block() { let created = super::WelcomeServiceCreated { server_conf_path: PathBuf::from("/tmp/alice/server.conf"), - index_html_path: PathBuf::from("/tmp/alice/index.html"), + welcome_page_path: PathBuf::from("/tmp/alice/templates/welcome/index.html"), url: "https://alice.smith/".to_string(), }; - let expected = "Welcome service created\n Created server.conf at /tmp/alice/server.conf\n Created index.html at /tmp/alice/index.html\n Open https://alice.smith/ after pishoo starts or reloads"; + let expected = "Welcome service created\n Created server.conf at /tmp/alice/server.conf\n Created welcome page at /tmp/alice/templates/welcome/index.html\n Open https://alice.smith/ after pishoo starts or reloads"; assert_eq!(format_welcome_service_created(&created), expected); } From ef95f9b15b1bb61e4eddc627d3e73c47b250d0be Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 25 Jun 2026 18:13:35 +0800 Subject: [PATCH 18/21] ci: materialize release root CA from variable --- .github/workflows/publish-crates.yml | 21 ++++---- .github/workflows/release.yml | 79 ++++++++++++++++++++++++---- xtask/src/main.rs | 11 +++- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index e6fc56b..99f29b9 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -22,25 +22,22 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Install Rust stable toolchain + - name: Install Rust nightly toolchain run: | - rustup toolchain install stable --profile minimal --component clippy - rustup default stable - - - name: Install Rust nightly for formatting - run: rustup toolchain install nightly --profile minimal --component rustfmt + rustup toolchain install nightly --profile minimal --component rustfmt --component clippy + rustup default nightly - name: Check formatting run: cargo +nightly fmt -- --check - name: Run clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test --workspace --all-targets --all-features + run: cargo +nightly test --workspace --all-targets --all-features - name: Validate workspace publish graph - run: cargo publish --workspace --exclude xtask --dry-run --locked + run: cargo +nightly publish --workspace --exclude xtask --dry-run --locked - name: Plan crates.io publish packages id: publish_plan @@ -90,7 +87,7 @@ jobs: exit 0 fi - cargo metadata --format-version 1 > "$RUNNER_TEMP/workspace-metadata.json" + cargo +nightly metadata --format-version 1 > "$RUNNER_TEMP/workspace-metadata.json" package_versions="$( PACKAGES="$(printf '%s\n' "${packages[@]}")" RUNNER_TEMP="$RUNNER_TEMP" python3 - <<'PY' import json @@ -163,10 +160,10 @@ jobs: fi if [[ "$mode" == "dry-run" ]]; then - publish_args=(cargo publish --dry-run --locked) + publish_args=(cargo +nightly publish --dry-run --locked) echo "dry-run publish packages: ${packages_to_publish[*]}" else - publish_args=(cargo publish --locked) + publish_args=(cargo +nightly publish --locked) echo "publish packages: ${packages_to_publish[*]}" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58b7d39..fc04edc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,8 @@ concurrency: env: CARGO_TERM_COLOR: always XTASK_RELEASE_S3_ENDPOINT_URL: ${{ vars.XTASK_RELEASE_S3_ENDPOINT_URL }} - DHTTP_ROOT_CA: ${{ vars.DHTTP_ROOT_CA }} + DHTTP_ROOT_CA_PEM: ${{ vars.DHTTP_ROOT_CA_PEM }} + DHTTP_ROOT_CA: ${{ github.workspace }}/.release/dhttp-root-ca.pem DHTTP_STUN_SERVER: ${{ vars.DHTTP_STUN_SERVER }} DHTTP_H3_DNS_SERVER: ${{ vars.DHTTP_H3_DNS_SERVER }} DHTTP_HTTP_DNS_SERVER: ${{ vars.DHTTP_HTTP_DNS_SERVER }} @@ -102,11 +103,26 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly - name: Cache cargo downloads uses: actions/cache@v5 @@ -255,11 +271,26 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly - name: Cache cargo downloads uses: actions/cache@v5 @@ -382,11 +413,26 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly rustup target add x86_64-pc-windows-msvc i686-pc-windows-msvc - name: Cache cargo downloads @@ -510,6 +556,21 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Read Homebrew tap destination id: homebrew_destination run: | @@ -563,8 +624,8 @@ jobs: - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly rustup target add aarch64-apple-darwin x86_64-apple-darwin - name: Cache cargo downloads diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4381aa6..6bc6215 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -381,9 +381,16 @@ mod tests { fn release_workflow_publish_commands_are_tag_mode_safe() { assert!(!RELEASE_WORKFLOW.contains("publish_args=()")); assert!(!RELEASE_WORKFLOW.contains("\"${publish_args[@]}\"")); - assert!(RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA: ${{ vars.DHTTP_ROOT_CA }}")); + assert!(RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA_PEM: ${{ vars.DHTTP_ROOT_CA_PEM }}")); + assert!( + RELEASE_WORKFLOW + .contains("DHTTP_ROOT_CA: ${{ github.workspace }}/.release/dhttp-root-ca.pem") + ); + assert!(RELEASE_WORKFLOW.contains("Materialize DHTTP root CA")); + assert!( + RELEASE_WORKFLOW.contains("missing required release configuration: DHTTP_ROOT_CA_PEM") + ); assert!(!RELEASE_WORKFLOW.contains("keychain/root.crt")); - assert!(!RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA: ${{ github.workspace }}")); assert!(!RELEASE_WORKFLOW.contains("--endpoint-url")); assert!(!RELEASE_WORKFLOW.contains("--bucket")); assert!(!RELEASE_WORKFLOW.contains("--prefix")); From 5921add617eab0e852e85aa9d4e1c80fd7e81394 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 25 Jun 2026 18:25:40 +0800 Subject: [PATCH 19/21] ci: read release root CA PEM from secret --- .github/workflows/release.yml | 2 +- xtask/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc04edc..82c58d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ concurrency: env: CARGO_TERM_COLOR: always XTASK_RELEASE_S3_ENDPOINT_URL: ${{ vars.XTASK_RELEASE_S3_ENDPOINT_URL }} - DHTTP_ROOT_CA_PEM: ${{ vars.DHTTP_ROOT_CA_PEM }} + DHTTP_ROOT_CA_PEM: ${{ secrets.DHTTP_ROOT_CA_PEM }} DHTTP_ROOT_CA: ${{ github.workspace }}/.release/dhttp-root-ca.pem DHTTP_STUN_SERVER: ${{ vars.DHTTP_STUN_SERVER }} DHTTP_H3_DNS_SERVER: ${{ vars.DHTTP_H3_DNS_SERVER }} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6bc6215..ace6f95 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -381,7 +381,7 @@ mod tests { fn release_workflow_publish_commands_are_tag_mode_safe() { assert!(!RELEASE_WORKFLOW.contains("publish_args=()")); assert!(!RELEASE_WORKFLOW.contains("\"${publish_args[@]}\"")); - assert!(RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA_PEM: ${{ vars.DHTTP_ROOT_CA_PEM }}")); + assert!(RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA_PEM: ${{ secrets.DHTTP_ROOT_CA_PEM }}")); assert!( RELEASE_WORKFLOW .contains("DHTTP_ROOT_CA: ${{ github.workspace }}/.release/dhttp-root-ca.pem") From 462ce51523dea7555deae1ff4c375ea8e4e68244 Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 26 Jun 2026 07:42:32 +0800 Subject: [PATCH 20/21] ci: validate only publishable crate candidates --- .github/workflows/publish-crates.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 99f29b9..870b283 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -36,9 +36,6 @@ jobs: - name: Run tests run: cargo +nightly test --workspace --all-targets --all-features - - name: Validate workspace publish graph - run: cargo +nightly publish --workspace --exclude xtask --dry-run --locked - - name: Plan crates.io publish packages id: publish_plan shell: bash From 22c193513e10027b583231dc882ec84e6adc2e48 Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 26 Jun 2026 15:06:50 +0800 Subject: [PATCH 21/21] chore: align release dependencies --- CHANGELOG.md | 16 +- Cargo.lock | 241 ++++++++++++++++++----- Cargo.toml | 16 +- genmeta-access/Cargo.toml | 2 +- genmeta-curl/Cargo.toml | 2 +- genmeta-identity/Cargo.toml | 2 +- genmeta-identity/src/cli/flow/welcome.rs | 7 +- genmeta-nat/Cargo.toml | 2 +- genmeta-nslookup/Cargo.toml | 2 +- genmeta-proxy/Cargo.toml | 2 +- 10 files changed, 223 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0c971..7cdd6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,21 +31,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Dependencies -- Release manifests now target `h3x` v0.5.0, `dhttp` v0.3.0, - `dhttp-access` v0.2.0, `dshell` v0.5.0, `dyns` v0.5.0, and `rankey` v0.2.1. +- Release manifests now target `h3x` v0.5.0, `dhttp` v0.4.0, + `dhttp-access` v0.3.0, `dshell` v0.5.0, `dyns` v0.5.0, and `rankey` v0.2.1. ### Components - `genmeta` v0.6.1 -- `genmeta-curl` v0.5.1 +- `genmeta-curl` v0.6.0 - `genmeta-ssh` v0.6.1 -- `genmeta-access` v0.2.1 -- `genmeta-identity` v0.2.1 -- `genmeta-proxy` v0.2.1 +- `genmeta-access` v0.3.0 +- `genmeta-identity` v0.3.0 +- `genmeta-proxy` v0.3.0 - `genmeta-discover` v0.3.1 - `genmeta-doctor` v0.3.1 -- `genmeta-nat` v0.3.1 -- `genmeta-nslookup` v0.3.1 +- `genmeta-nat` v0.4.0 +- `genmeta-nslookup` v0.4.0 ## [0.6.0] - 2026-06-15 diff --git a/Cargo.lock b/Cargo.lock index 2b54992..c8c7b9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -730,7 +730,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tower-service", @@ -900,7 +900,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -915,6 +915,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1516,7 +1522,9 @@ dependencies = [ [[package]] name = "dhttp" -version = "0.3.0" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cac718ccb8db7ba6069fcc92adb7a3b5a24267ce704969bcf8336ecf29e822" dependencies = [ "bon", "bytes", @@ -1537,7 +1545,9 @@ dependencies = [ [[package]] name = "dhttp-access" -version = "0.2.0" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5dee91d9ec4bf5f8fbd49a2761b1c5454aa9b088f6c53fc278f7b7934de9f38" dependencies = [ "chrono", "clap", @@ -1558,7 +1568,9 @@ dependencies = [ [[package]] name = "dhttp-home" -version = "0.2.0" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81393cfab5863c0f1b7994c5da57c0a1e941cb62886e558c4832cfea37d1ea68" dependencies = [ "dhttp-identity", "dirs", @@ -1574,6 +1586,8 @@ dependencies = [ [[package]] name = "dhttp-identity" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9006fe9bffd56d64fbe4260182039f104a84d612acdd30ec19a16583bb4ff671" dependencies = [ "bytes", "futures", @@ -1682,6 +1696,8 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8837fb8f07c5915a23b2e2ebd2ec594c0ccf2036815598c57fb998b8480347" dependencies = [ "arc-swap", "dashmap", @@ -1689,7 +1705,7 @@ dependencies = [ "qconnection", "qresolve", "rustls 0.23.40", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -1698,6 +1714,8 @@ dependencies = [ [[package]] name = "dshell" version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c9c1969d89ebe8ebccccbfe345ac98bc6171f4d279adebdd24d763266988ec" dependencies = [ "base64", "bytes", @@ -1724,6 +1742,8 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6cf3d0f58906886609f22154cba85632af0df9661517b2380b0b740fc6f89d3" dependencies = [ "base64", "bitfield-struct", @@ -2153,7 +2173,7 @@ dependencies = [ [[package]] name = "genmeta-access" -version = "0.2.1" +version = "0.3.0" dependencies = [ "clap", "dhttp", @@ -2168,7 +2188,7 @@ dependencies = [ [[package]] name = "genmeta-curl" -version = "0.5.1" +version = "0.6.0" dependencies = [ "async-compression", "bytes", @@ -2210,7 +2230,7 @@ dependencies = [ [[package]] name = "genmeta-identity" -version = "0.2.1" +version = "0.3.0" dependencies = [ "base64", "bytes", @@ -2242,7 +2262,7 @@ dependencies = [ [[package]] name = "genmeta-nat" -version = "0.3.1" +version = "0.4.0" dependencies = [ "clap", "dhttp", @@ -2256,7 +2276,7 @@ dependencies = [ [[package]] name = "genmeta-nslookup" -version = "0.3.1" +version = "0.4.0" dependencies = [ "clap", "dhttp", @@ -2270,7 +2290,7 @@ dependencies = [ [[package]] name = "genmeta-proxy" -version = "0.2.1" +version = "0.3.0" dependencies = [ "bytes", "clap", @@ -2430,6 +2450,8 @@ dependencies = [ [[package]] name = "h3x" version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec18423b1a2995c1bbdae48684d12e26886775fe7299e11849d9b60215684f3" dependencies = [ "arc-swap", "async-channel", @@ -2988,6 +3010,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -2997,10 +3035,10 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -3018,6 +3056,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -3272,19 +3319,22 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "netdev" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" dependencies = [ "block2", "dispatch2", "dlopen2", "ipnet", + "jni 0.21.1", "libc", "mac-addr", + "ndk-context", "netlink-packet-core", "netlink-packet-route", "netlink-sys", + "objc2", "objc2-core-foundation", "objc2-core-wlan", "objc2-foundation", @@ -3305,9 +3355,9 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ "bitflags", "libc", @@ -3333,7 +3383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85950142187da505903b9167147149ef0c71273f2dd92252039691413ce68e1c" dependencies = [ "android-build", - "jni", + "jni 0.22.4", "ndk-context", "nix 0.31.3", "windows", @@ -3525,7 +3575,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", "objc2", "objc2-core-foundation", ] @@ -3547,11 +3596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ "bitflags", - "dispatch2", - "libc", - "objc2", "objc2-core-foundation", - "objc2-security", ] [[package]] @@ -3963,6 +4008,8 @@ dependencies = [ [[package]] name = "qbase" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d008096ac877ed51917bec865eb512113df9e9cc0d99ade74285060b695bea5c" dependencies = [ "bitflags", "bytes", @@ -3976,7 +4023,7 @@ dependencies = [ "rustls 0.23.40", "serde", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -3984,11 +4031,13 @@ dependencies = [ [[package]] name = "qcongestion" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ff2c0dd91cf0e4cacca9e3b1ab8c2bd4bff93c17ba39f509e53f238a1fda42" dependencies = [ "qbase", "qevent", "rand 0.10.1", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -3996,6 +4045,8 @@ dependencies = [ [[package]] name = "qconnection" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77febb7882e4bb2cab5fa85c145ebdd3a7350816545155dc44e9e9900595b40" dependencies = [ "bytes", "dashmap", @@ -4012,7 +4063,7 @@ dependencies = [ "qtraversal", "ring", "rustls 0.23.40", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4022,6 +4073,8 @@ dependencies = [ [[package]] name = "qdatagram" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d891c28529949b435a5eb4ec0e4eb67e33d514dc993138e2da36bbff5c3f77a" dependencies = [ "bytes", "qbase", @@ -4032,6 +4085,8 @@ dependencies = [ [[package]] name = "qevent" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13122bb529c62512d876c08681d57f2ab0be4b5e9943657a19b74c0c83298638" dependencies = [ "bytes", "derive_builder", @@ -4049,6 +4104,8 @@ dependencies = [ [[package]] name = "qinterface" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d6859183d5d4547470fd3ea641d700b79e416269f229b03b0c8709e2c98575" dependencies = [ "bytes", "dashmap", @@ -4062,7 +4119,7 @@ dependencies = [ "qevent", "qudp", "rustls 0.23.40", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4071,6 +4128,8 @@ dependencies = [ [[package]] name = "qmacro" version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2e2f19a8187c8ff0c0001562c18f17bfa6f82d25ec8316960944b418fc371" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4081,13 +4140,15 @@ dependencies = [ [[package]] name = "qrecovery" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4adfff2dbe1f591662695e3d91a4a0c3159b22a6c7a86aa754f07894c9e946da" dependencies = [ "bytes", "derive_more", "futures", "qbase", "qevent", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -4095,6 +4156,8 @@ dependencies = [ [[package]] name = "qresolve" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1919a853d70998ed4b230e94883dfbe2bd065c3eb5e5fe058540a8489956519d" dependencies = [ "futures", "qbase", @@ -4104,6 +4167,8 @@ dependencies = [ [[package]] name = "qtraversal" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9846e09295d5d5977be88a3c8a478c264acd91cbbc86deeca6d9fc0d71d2ace8" dependencies = [ "bon", "bytes", @@ -4117,7 +4182,7 @@ dependencies = [ "rand 0.10.1", "smallvec", "snafu 0.9.1", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4126,6 +4191,8 @@ dependencies = [ [[package]] name = "qudp" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce9009cdd2de1ef116833beeccf1a5426a0a8f426ee836fe6c05cba4322b915" dependencies = [ "bytes", "cfg-if", @@ -4303,7 +4370,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -4664,7 +4731,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls 0.23.40", @@ -4811,7 +4878,7 @@ dependencies = [ "serde_json", "sqlx", "strum", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -4911,7 +4978,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -5415,7 +5482,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -5503,7 +5570,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -5546,7 +5613,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -5573,7 +5640,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -5720,13 +5787,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -6022,7 +6109,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -6628,6 +6715,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -6655,6 +6751,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -6695,6 +6806,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6707,6 +6824,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6719,6 +6842,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6737,6 +6866,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6749,6 +6884,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6761,6 +6902,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6773,6 +6920,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6931,7 +7084,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -7168,7 +7321,3 @@ dependencies = [ "cc", "pkg-config", ] - -[[patch.unused]] -name = "qprotocol" -version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 5f52d61..4e3068f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,17 +95,17 @@ async-compression = { version = "0.4", features = [ ] } # workspace -dhttp-access = "0.2.0" -genmeta-access = { path = "genmeta-access", version = "0.2.1" } -dhttp = "0.3.0" -genmeta-curl = { path = "genmeta-curl", version = "0.5.1" } +dhttp-access = "0.3.0" +genmeta-access = { path = "genmeta-access", version = "0.3.0" } +dhttp = "0.4.0" +genmeta-curl = { path = "genmeta-curl", version = "0.6.0" } genmeta-discover = { path = "genmeta-discover", version = "0.3.1" } genmeta-doctor = { path = "genmeta-doctor", version = "0.3.1" } -genmeta-identity = { path = "genmeta-identity", version = "0.2.1" } -genmeta-nat = { path = "genmeta-nat", version = "0.3.1" } -genmeta-nslookup = { path = "genmeta-nslookup", version = "0.3.1" } +genmeta-identity = { path = "genmeta-identity", version = "0.3.0" } +genmeta-nat = { path = "genmeta-nat", version = "0.4.0" } +genmeta-nslookup = { path = "genmeta-nslookup", version = "0.4.0" } genmeta-ssh = { path = "genmeta-ssh", version = "0.6.1" } -genmeta-proxy = { path = "genmeta-proxy", version = "0.2.1" } +genmeta-proxy = { path = "genmeta-proxy", version = "0.3.0" } # DShell dshell = { version = "0.5.0", features = [ diff --git a/genmeta-access/Cargo.toml b/genmeta-access/Cargo.toml index f3a63fd..684f5f7 100644 --- a/genmeta-access/Cargo.toml +++ b/genmeta-access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-access" description = "access control rule management" -version = "0.2.1" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-curl/Cargo.toml b/genmeta-curl/Cargo.toml index 91a76be..8ec4481 100644 --- a/genmeta-curl/Cargo.toml +++ b/genmeta-curl/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-curl" description = "curl-like DHTTP/3 client" -version = "0.5.1" +version = "0.6.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index c16fbeb..718250d 100644 --- a/genmeta-identity/Cargo.toml +++ b/genmeta-identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-identity" description = "managing identities for genmeta network" -version = "0.2.1" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-identity/src/cli/flow/welcome.rs b/genmeta-identity/src/cli/flow/welcome.rs index fc95655..46f9750 100644 --- a/genmeta-identity/src/cli/flow/welcome.rs +++ b/genmeta-identity/src/cli/flow/welcome.rs @@ -246,7 +246,7 @@ where } } -#[cfg(unix)] +#[cfg(all(unix, not(target_vendor = "apple")))] fn user_in_pishoo_group() -> Result { use nix::unistd::{Group, getegid, getgroups}; @@ -264,6 +264,11 @@ fn user_in_pishoo_group() -> Result { Ok(groups.into_iter().any(|gid| gid == group.gid)) } +#[cfg(all(unix, target_vendor = "apple"))] +fn user_in_pishoo_group() -> Result { + Ok(false) +} + #[cfg(not(unix))] fn user_in_pishoo_group() -> Result { Ok(false) diff --git a/genmeta-nat/Cargo.toml b/genmeta-nat/Cargo.toml index d4b7008..11f8ee2 100644 --- a/genmeta-nat/Cargo.toml +++ b/genmeta-nat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nat" description = "Diagnose network and environment issues" -version = "0.3.1" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-nslookup/Cargo.toml b/genmeta-nslookup/Cargo.toml index 89b194a..2aab49c 100644 --- a/genmeta-nslookup/Cargo.toml +++ b/genmeta-nslookup/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nslookup" description = "resolve domain names" -version = "0.3.1" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-proxy/Cargo.toml b/genmeta-proxy/Cargo.toml index d012a1e..d0714f0 100644 --- a/genmeta-proxy/Cargo.toml +++ b/genmeta-proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-proxy" description = "forward proxy routing .dhttp.net requests over DHTTP/3" -version = "0.2.1" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true