Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fad5abf1d | ||
|
|
34f0e1eeca | ||
|
|
882ab10e75 | ||
|
|
9ad361ed37 | ||
|
|
ae7d761e03 | ||
|
|
c15d665f65 | ||
|
|
425f2d7efe | ||
|
|
7d79a858f6 | ||
|
|
18ed976a1b | ||
|
|
9d815638c7 | ||
|
|
34483ba969 | ||
|
|
bc0dcaa8b9 | ||
|
|
540bf2b9e4 | ||
|
|
2048eebfc9 | ||
|
|
65d4870c00 | ||
|
|
3f2b88f307 | ||
|
|
58416dcbd4 | ||
|
|
bee0e6c546 | ||
|
|
76418e4592 | ||
|
|
5bc3fee2c8 | ||
|
|
d856159ce8 | ||
|
|
11684e5064 | ||
|
|
485d627042 | ||
|
|
d4a2021b1f | ||
|
|
94f35f8e5a | ||
|
|
b7d1c230e2 | ||
|
|
5129950f7e | ||
|
|
b372e09f9f | ||
|
|
167eb677e6 | ||
|
|
c8f202201b | ||
|
|
6e56e6ff14 | ||
|
|
58a0400a6e | ||
|
|
a1a5ef6c39 | ||
|
|
b0948c0341 | ||
|
|
d3e3abd367 | ||
|
|
9f556f118e | ||
|
|
786a35d099 | ||
|
|
c7e3d94b66 | ||
|
|
7589f52b81 | ||
|
|
029d652972 | ||
|
|
c821be8dfc | ||
|
|
0c18281d4e | ||
|
|
8dca3436f8 | ||
|
|
39410cd265 | ||
|
|
2765225598 | ||
|
|
b6a6403506 | ||
|
|
93cba89d64 | ||
|
|
9ebfec987c | ||
|
|
8d3edf5b28 | ||
|
|
f41a1ee183 | ||
|
|
307a2acb28 | ||
|
|
0a0d41243d | ||
|
|
ae6a0f143e | ||
|
|
bd8d313383 | ||
|
|
4e54c420a2 | ||
|
|
9e841fc193 | ||
|
|
3a15a3e053 | ||
|
|
6e27c776ac | ||
|
|
a0c9ba476c | ||
|
|
1e4592dd90 | ||
|
|
8238cecf5c | ||
|
|
f9aaa2c77d | ||
|
|
57984fa578 | ||
|
|
d0f14053a2 | ||
|
|
3e377bba84 | ||
|
|
c8d2cac0eb | ||
|
|
86f14c0997 | ||
|
|
8ebd307452 | ||
|
|
2562c5ecfe | ||
|
|
d28e3199fe | ||
|
|
7105bc46e4 | ||
|
|
f4222dec61 | ||
|
|
70f2e14f44 | ||
|
|
0fc34d7067 | ||
|
|
33a43031e2 | ||
|
|
aa046ab0c7 | ||
|
|
e276c42ede | ||
|
|
04576b88f5 | ||
|
|
11c4556b1d | ||
|
|
3950e833ad | ||
|
|
af1788ca18 | ||
|
|
12b646bf56 | ||
|
|
0274b638d4 | ||
|
|
e27b734c53 | ||
|
|
dd954ccfc1 | ||
|
|
240c978340 | ||
|
|
aab8ceccb7 | ||
|
|
4113149d01 | ||
|
|
21c48914dd | ||
|
|
e2fedffd0f | ||
|
|
9c5f7d072f | ||
|
|
f0da2a5262 | ||
|
|
f3a2eb940c | ||
|
|
d93e73b3d7 | ||
|
|
9d67c5c624 | ||
|
|
3fd79f1928 | ||
|
|
52e1b9c9a1 | ||
|
|
843a9ac2be | ||
|
|
152ec20140 | ||
|
|
8677b43be3 | ||
|
|
4407564458 | ||
|
|
14f33ab531 | ||
|
|
1a9453a3bc | ||
|
|
869b9929c5 | ||
|
|
5a6d422034 | ||
|
|
8fa502120b | ||
|
|
67d408bfca | ||
|
|
b4ff55127a | ||
|
|
f438cdbd85 | ||
|
|
564541f02b | ||
|
|
d1ef41b535 | ||
|
|
9e920b4373 | ||
|
|
d6b75fe6ee | ||
|
|
d212b57054 | ||
|
|
514684743a | ||
|
|
6b5c869114 | ||
|
|
7d0687b431 | ||
|
|
8dc9926c38 | ||
|
|
d3841ca493 | ||
|
|
3a25ecf3d1 | ||
|
|
0c443b5520 | ||
|
|
3f6a681ba2 | ||
|
|
48730ca18d | ||
|
|
708bc6b89b | ||
|
|
4364c2d23f | ||
|
|
c32c6311b7 | ||
|
|
d9a4ea5747 | ||
|
|
d34c247866 | ||
|
|
9800bddc17 | ||
|
|
1fa42306cf | ||
|
|
3607cc8f69 | ||
|
|
c9be5246a7 | ||
|
|
9aae5b34b8 | ||
|
|
38ffb32c68 | ||
|
|
e6d722d5a8 | ||
|
|
71818d7367 | ||
|
|
1834813593 | ||
|
|
2f0e084e10 | ||
|
|
253e6707fd | ||
|
|
dc379323d2 | ||
|
|
ed6e5e0297 | ||
|
|
4666aa7b75 | ||
|
|
c9cc7c1309 | ||
|
|
8a982c134a | ||
|
|
8a8fb0b835 | ||
|
|
d7ebaae630 | ||
|
|
c33317abdf | ||
|
|
e599af99e5 | ||
|
|
86c89f5515 | ||
|
|
890b6b9531 | ||
|
|
9b0f659a9e | ||
|
|
61b1afca7a | ||
|
|
601904189a | ||
|
|
01fdd8ef3a | ||
|
|
5528198cc1 | ||
|
|
6832304b12 | ||
|
|
0cfa6daac2 | ||
|
|
6e98cee5e1 | ||
|
|
1f9c96da50 | ||
|
|
faa340e657 | ||
|
|
f3b831f81d | ||
|
|
1130e64a88 | ||
|
|
25ba6fd6a6 | ||
|
|
87c0033eb3 | ||
|
|
75937e4297 | ||
|
|
8546b3dbd7 | ||
|
|
eb29f93ebb | ||
|
|
a441e85c73 | ||
|
|
015f35393b | ||
|
|
197fb04942 | ||
|
|
62a72a7f3c | ||
|
|
84bd0cc51f | ||
|
|
f1582d5c35 | ||
|
|
7c6ef3c047 | ||
|
|
72d7ee68b4 | ||
|
|
81f03ee161 | ||
|
|
15f23ddb43 | ||
|
|
fa2d641624 | ||
|
|
86311258d8 | ||
|
|
cb1914233c | ||
|
|
59ded957b5 | ||
|
|
c2c06d7b2f | ||
|
|
a9d3eecc7a | ||
|
|
bf861abda8 | ||
|
|
be494245e2 | ||
|
|
abe6ac92a5 | ||
|
|
18c558420d | ||
|
|
886a28cdb2 | ||
|
|
845883cac1 | ||
|
|
e8a3df177b | ||
|
|
b93b7e06a4 | ||
|
|
6bdd586e0d | ||
|
|
f5c966427e | ||
|
|
9c94426350 | ||
|
|
66b9763e37 | ||
|
|
5f2c42b6d3 | ||
|
|
15270dcd88 | ||
|
|
cdffcba67e | ||
|
|
d5818e9124 | ||
|
|
0e9a3fa33e | ||
|
|
7c7f86f967 | ||
|
|
87da95bb3a | ||
|
|
9177de8354 | ||
|
|
0b2802b5e4 | ||
|
|
ebcdf1ca61 | ||
|
|
88b75b085b | ||
|
|
d86f774bf5 | ||
|
|
4379d55394 | ||
|
|
9f86b86b51 | ||
|
|
63007514af | ||
|
|
8bde795686 | ||
|
|
90a97c1921 | ||
|
|
4a5301de45 | ||
|
|
09868c8c42 | ||
|
|
5bc3c10d76 | ||
|
|
e679e16b52 | ||
|
|
57bef7d12b | ||
|
|
4492cf1eb3 | ||
|
|
5f4c064122 | ||
|
|
7f4f7d3526 | ||
|
|
6b698958e7 | ||
|
|
a162e51119 | ||
|
|
653a43d98a | ||
|
|
12cf0c540b | ||
|
|
63ed21a31a | ||
|
|
207491f365 | ||
|
|
952586ecc5 | ||
|
|
d2d527a54e | ||
|
|
e7c9a6a7b4 | ||
|
|
b4625c423e |
@@ -19,4 +19,5 @@ self-hosted-runner:
|
||||
# Linux: Blacksmith primary (LINUX_RUNNER), WarpBuild overflow fallback.
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- warp-ubuntu-latest-x64-4x
|
||||
|
||||
@@ -124,6 +124,9 @@ jobs:
|
||||
- name: Install workflow guard Python dependencies
|
||||
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3 bashlex==0.18
|
||||
|
||||
- name: Validate Blacksmith Testbox broker trust boundary
|
||||
run: python3 tests/test_ci_testbox_broker_guard.py
|
||||
|
||||
- name: Validate nightly prune Python compatibility
|
||||
run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
name: cmux-tui Rust Testbox setup
|
||||
|
||||
# Main-controlled broker. This workflow runs only from refs/heads/main and
|
||||
# hydrates only main, so no candidate branch can edit the guards that run
|
||||
# before begin-testbox exposes its auth token, and no candidate build script
|
||||
# executes inside this token-bearing job. A candidate revision reaches the
|
||||
# Testbox later, through `blacksmith testbox run`, which synchronizes a
|
||||
# maintainer's local worktree onto the already-warm VM.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
description: "Testbox session ID supplied by blacksmith testbox warmup"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
# A Testbox is a mutable shared workspace. Serialize every request for the
|
||||
# same ID so two dispatches cannot corrupt one cache.
|
||||
group: cmux-tui-testbox-${{ inputs.testbox_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cmux-tui-rust:
|
||||
name: cmux-tui Rust setup
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
environment:
|
||||
# Configure this environment with required reviewers, no secrets, and a
|
||||
# deployment branch rule of exactly `main`. Approval is evaluated before
|
||||
# the first step, so it precedes begin-testbox.
|
||||
name: blacksmith-testbox-trusted
|
||||
permissions:
|
||||
contents: read
|
||||
# Hydration plus three sequential 20-minute bounded remote builds happen
|
||||
# after setup. Keep the GitHub job alive long enough for all stages and
|
||||
# cleanup; the Testbox itself still has its separate idle timeout.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
# Every check here is main-controlled. `blacksmith testbox warmup`
|
||||
# resolves both this file and the hydrated source from the same --ref, so
|
||||
# refusing any ref other than refs/heads/main is what keeps a candidate
|
||||
# branch outside the trust boundary.
|
||||
- name: Validate broker ref
|
||||
env:
|
||||
DISPATCH_REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
TESTBOX_ID: ${{ inputs.testbox_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$REPOSITORY" == "manaflow-ai/cmux" ]] || {
|
||||
echo "::error::this Testbox lane is only valid for manaflow-ai/cmux" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$EVENT_NAME" == "workflow_dispatch" ]] || {
|
||||
echo "::error::Testbox setup must be dispatched manually, never from a PR event" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$DISPATCH_REF" == "refs/heads/main" ]] || {
|
||||
echo "::error::this broker lane runs only from refs/heads/main, got $DISPATCH_REF; warm up with --ref main and sync the candidate through blacksmith testbox run" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$TESTBOX_ID" =~ ^tbx_[A-Za-z0-9_-]+$ ]] || {
|
||||
echo "::error::malformed Testbox ID" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043 # v2
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
|
||||
# main can move while a reviewer approves the deployment. Record which
|
||||
# commit this job actually hydrates and fail closed if the dispatch ref
|
||||
# changed under it.
|
||||
- name: Revalidate broker identity after token exposure
|
||||
env:
|
||||
DISPATCH_REF: ${{ github.ref }}
|
||||
DISPATCH_SHA: ${{ github.sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$DISPATCH_REF" == "refs/heads/main" ]] || {
|
||||
echo "::error::broker ref changed during setup" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$DISPATCH_SHA" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "::error::dispatch SHA is malformed" >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'hydrating main at %s\n' "$DISPATCH_SHA"
|
||||
|
||||
- name: Checkout hydration commit
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Require exact checkout and clean source
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
[[ "$actual_sha" == "$EXPECTED_SHA" ]] || {
|
||||
echo "::error::checked out $actual_sha, expected hydration SHA $EXPECTED_SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || {
|
||||
echo "::error::source checkout is dirty before hydration" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
}
|
||||
source_tree_sha="$(git rev-parse 'HEAD^{tree}')"
|
||||
ghostty_entry="$(git ls-tree HEAD ghostty)"
|
||||
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || {
|
||||
echo "::error::HEAD:ghostty is not a gitlink" >&2
|
||||
exit 1
|
||||
}
|
||||
ghostty_gitlink_sha="$(git rev-parse 'HEAD:ghostty')"
|
||||
printf 'source_sha=%s\nsource_tree_sha=%s\nghostty_gitlink_sha=%s\n' \
|
||||
"$actual_sha" "$source_tree_sha" "$ghostty_gitlink_sha"
|
||||
|
||||
- name: Initialize Ghostty source submodule
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule update --init --depth 1 ghostty
|
||||
[[ "$(git -C ghostty rev-parse --show-toplevel)" == "$GITHUB_WORKSPACE/ghostty" ]] || {
|
||||
echo "::error::ghostty did not initialize as its own submodule checkout" >&2
|
||||
exit 1
|
||||
}
|
||||
ghostty_entry="$(git ls-tree HEAD ghostty)"
|
||||
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || {
|
||||
echo "::error::HEAD:ghostty is not a gitlink" >&2
|
||||
exit 1
|
||||
}
|
||||
expected_ghostty_sha="$(git rev-parse HEAD:ghostty)"
|
||||
actual_ghostty_sha="$(git -C ghostty rev-parse HEAD)"
|
||||
[[ "$actual_ghostty_sha" == "$expected_ghostty_sha" ]] || {
|
||||
echo "::error::Ghostty checkout $actual_ghostty_sha does not match gitlink $expected_ghostty_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || {
|
||||
echo "::error::Ghostty submodule is dirty after initialization" >&2
|
||||
git -C ghostty status --short >&2
|
||||
exit 1
|
||||
}
|
||||
test -f ghostty/build.zig.zon
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang libclang-dev pkg-config
|
||||
|
||||
- name: Cache Zig package downloads
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/zig
|
||||
key: cmux-tui-zig-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }}
|
||||
restore-keys: |
|
||||
cmux-tui-zig-
|
||||
|
||||
- name: Install repository-pinned Zig
|
||||
shell: bash
|
||||
run: ./scripts/install-zig-ci.sh
|
||||
|
||||
- name: Fetch Ghostty Zig dependencies without compiling
|
||||
working-directory: ghostty
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `--fetch` hydrates the package cache and exits before a build.
|
||||
"$CMUX_ZIG" build --fetch
|
||||
|
||||
- name: Cache Cargo registry and git dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: cmux-tui-cargo-${{ runner.os }}-${{ hashFiles('cmux-tui/Cargo.lock', 'cmux-tui/rust-toolchain.toml') }}
|
||||
restore-keys: |
|
||||
cmux-tui-cargo-${{ runner.os }}-
|
||||
|
||||
- name: Set up repository-pinned cmux-tui Rust
|
||||
uses: ./.github/actions/setup-cmux-tui-rust
|
||||
|
||||
- name: Fetch Cargo dependencies without compiling
|
||||
working-directory: cmux-tui
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo fetch --locked
|
||||
|
||||
- name: Record runner, toolchain, and Ghostty identity
|
||||
env:
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
TESTBOX_ID: ${{ inputs.testbox_id }}
|
||||
RUNNER_LABEL: blacksmith-32vcpu-ubuntu-2404
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tree_sha="$(git rev-parse 'HEAD^{tree}')"
|
||||
ghostty_entry="$(git ls-tree HEAD ghostty)"
|
||||
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || exit 1
|
||||
ghostty_gitlink_sha="$(git rev-parse 'HEAD:ghostty')"
|
||||
ghostty_head_sha="$(git -C ghostty rev-parse HEAD)"
|
||||
[[ "$SOURCE_SHA" == "$(git rev-parse HEAD)" ]] || exit 1
|
||||
[[ "$ghostty_gitlink_sha" == "$ghostty_head_sha" ]] || exit 1
|
||||
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || exit 1
|
||||
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || exit 1
|
||||
pushd cmux-tui >/dev/null
|
||||
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cmux-tui-cargo-metadata.json"
|
||||
test -s "$RUNNER_TEMP/cmux-tui-cargo-metadata.json"
|
||||
RUST_TOOLCHAIN="$(rustup show active-toolchain)"
|
||||
RUSTC_VERSION="$(rustc --version)"
|
||||
CARGO_VERSION="$(cargo --version)"
|
||||
popd >/dev/null
|
||||
mkdir -p testbox-benchmark
|
||||
SOURCE_TREE_SHA="$source_tree_sha"
|
||||
GHOSTTY_GITLINK_SHA="$ghostty_gitlink_sha"
|
||||
GHOSTTY_HEAD_SHA="$ghostty_head_sha"
|
||||
ZIG_VERSION="$("$CMUX_ZIG" version)"
|
||||
ZIG_PATH="$CMUX_ZIG"
|
||||
RUNNER_UNAME="$(uname -a)"
|
||||
RUNNER_CPU_COUNT="$(nproc)"
|
||||
[[ "$RUNNER_ARCH" == "X64" && "$RUNNER_CPU_COUNT" == "32" ]] || {
|
||||
echo "::error::expected x64 32-vCPU runner, got arch=$RUNNER_ARCH cpu_count=$RUNNER_CPU_COUNT" >&2
|
||||
exit 1
|
||||
}
|
||||
CARGO_METADATA_SHA256="$(sha256sum "$RUNNER_TEMP/cmux-tui-cargo-metadata.json" | cut -d ' ' -f 1)"
|
||||
RUST_TOOLCHAIN_FILE_SHA256="$(sha256sum cmux-tui/rust-toolchain.toml | cut -d ' ' -f 1)"
|
||||
CARGO_LOCK_SHA256="$(sha256sum cmux-tui/Cargo.lock | cut -d ' ' -f 1)"
|
||||
GHOSTTY_ZON_SHA256="$(sha256sum ghostty/build.zig.zon | cut -d ' ' -f 1)"
|
||||
export SOURCE_TREE_SHA GHOSTTY_GITLINK_SHA GHOSTTY_HEAD_SHA RUST_TOOLCHAIN RUSTC_VERSION CARGO_VERSION ZIG_VERSION ZIG_PATH RUNNER_UNAME RUNNER_CPU_COUNT CARGO_METADATA_SHA256 RUST_TOOLCHAIN_FILE_SHA256 CARGO_LOCK_SHA256 GHOSTTY_ZON_SHA256
|
||||
python3 - <<'PY' > testbox-benchmark/setup-identity.json
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
|
||||
print(json.dumps({
|
||||
"schema": 3,
|
||||
"source": {
|
||||
"ref": os.environ["SOURCE_REF"],
|
||||
"commit_sha": os.environ["SOURCE_SHA"],
|
||||
"tree_sha": os.environ["SOURCE_TREE_SHA"],
|
||||
"ghostty_gitlink_sha": os.environ["GHOSTTY_GITLINK_SHA"],
|
||||
"ghostty_head_sha": os.environ["GHOSTTY_HEAD_SHA"],
|
||||
},
|
||||
"broker": {
|
||||
"workflow_ref": os.environ["GITHUB_REF"],
|
||||
"workflow_sha": os.environ["SOURCE_SHA"],
|
||||
},
|
||||
"testbox": {
|
||||
"id": os.environ["TESTBOX_ID"],
|
||||
"setup_workflow_run_id": os.environ["GITHUB_RUN_ID"],
|
||||
},
|
||||
"runner": {
|
||||
"label": os.environ["RUNNER_LABEL"],
|
||||
"name": os.environ.get("RUNNER_NAME"),
|
||||
"os": os.environ.get("RUNNER_OS"),
|
||||
"arch": os.environ.get("RUNNER_ARCH"),
|
||||
"hostname": platform.node(),
|
||||
"uname": os.environ["RUNNER_UNAME"],
|
||||
"cpu_count": int(os.environ["RUNNER_CPU_COUNT"]),
|
||||
},
|
||||
"toolchain": {
|
||||
"rust_toolchain": os.environ["RUST_TOOLCHAIN"],
|
||||
"rustc": os.environ["RUSTC_VERSION"],
|
||||
"cargo": os.environ["CARGO_VERSION"],
|
||||
"rust_toolchain_file_sha256": os.environ["RUST_TOOLCHAIN_FILE_SHA256"],
|
||||
"cargo_lock_sha256": os.environ["CARGO_LOCK_SHA256"],
|
||||
"cargo_metadata_sha256": os.environ["CARGO_METADATA_SHA256"],
|
||||
"zig_path": os.environ["ZIG_PATH"],
|
||||
"zig": os.environ["ZIG_VERSION"],
|
||||
"ghostty_build_zig_zon_sha256": os.environ["GHOSTTY_ZON_SHA256"],
|
||||
},
|
||||
}, sort_keys=True, indent=2))
|
||||
PY
|
||||
test -s testbox-benchmark/setup-identity.json
|
||||
cat testbox-benchmark/setup-identity.json
|
||||
|
||||
- name: Require clean hydrated source
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || {
|
||||
echo "::error::source became dirty during hydration" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || {
|
||||
echo "::error::Ghostty became dirty during hydration" >&2
|
||||
git -C ghostty status --short >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload setup identity JSON
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-tui-testbox-setup-${{ github.run_id }}
|
||||
path: testbox-benchmark/setup-identity.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Missing `/tmp/.testbox` means begin-testbox could not register the VM.
|
||||
# No auth token or API URL exists, so neither this workflow nor the pinned
|
||||
# action can report hydration_failed. Fail explicitly; the CLI's bounded
|
||||
# readiness wait and receipt-aware cleanup remain the only safe recovery.
|
||||
- name: Require Testbox registration state
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -d /tmp/.testbox ]]; then
|
||||
echo "::error::begin-testbox produced no registration state; hydration failure cannot be reported from this runner" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Confirm Testbox registration and mark setup ready
|
||||
if: success()
|
||||
env:
|
||||
EXPECTED_TESTBOX_ID: ${{ inputs.testbox_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state=/tmp/.testbox
|
||||
rm -f /tmp/.testbox/cmux-tui-rust-setup-identity.json
|
||||
for required_file in testbox_id installation_model_id auth_token api_url runner_host runner_ssh_port adopted_run_id ssh_public_key; do
|
||||
test -s "$state/$required_file"
|
||||
done
|
||||
test -s "$state/working_directory"
|
||||
test "$(<"$state/testbox_id")" = "$EXPECTED_TESTBOX_ID"
|
||||
installation_model_id="$(<"$state/installation_model_id")"
|
||||
[[ "$installation_model_id" =~ ^[0-9]+$ ]]
|
||||
test -s "$state/ssh_public_key"
|
||||
# begin-testbox deliberately continues after degraded phone-home.
|
||||
# Validate the SSH handoff and install the identity marker before the
|
||||
# pinned run-testbox action publishes ready. That action remains the
|
||||
# sole readiness publisher, so a CLI run cannot race this marker.
|
||||
marker="$state/cmux-tui-rust-setup-identity.json"
|
||||
rm -f "$marker"
|
||||
install -m 600 testbox-benchmark/setup-identity.json "$marker"
|
||||
test -s "$marker"
|
||||
|
||||
# Keepalive is checked into this main-controlled workflow instead of
|
||||
# importing an upstream composite that performs an unbounded duplicate
|
||||
# ready curl. It reports
|
||||
# hydration_failed on any failed setup or readiness path and bounds every
|
||||
# phone-home request before entering its idle loop.
|
||||
- name: Run trusted Testbox keepalive
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
shell: bash
|
||||
run: ./scripts/blacksmith-testbox-keepalive.sh
|
||||
@@ -63,6 +63,9 @@ jobs:
|
||||
env:
|
||||
# The ghostty CLI helper zig build is skipped; GhosttyKit comes prebuilt.
|
||||
CMUX_SKIP_ZIG_BUILD: "1"
|
||||
# A degraded cache must lose to a cold build instead of stalling this
|
||||
# latency-sensitive lane for actions/cache's ten-minute default.
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MINS: "2"
|
||||
SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no"
|
||||
steps:
|
||||
- name: Checkout source ref
|
||||
@@ -80,12 +83,333 @@ jobs:
|
||||
set -euo pipefail
|
||||
./scripts/select-ci-xcode.sh
|
||||
|
||||
- name: Install zig
|
||||
run: ./scripts/install-zig-ci.sh
|
||||
|
||||
- name: Provision GhosttyKit (macOS)
|
||||
- name: Prepare macOS cache metadata
|
||||
if: ${{ inputs.platform == 'macos' }}
|
||||
run: ./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh
|
||||
id: cache_meta
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.ref || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
xcode_build="$(xcodebuild -version | awk 'NR == 2 { print $3 }')"
|
||||
xcode_key="$(printf 'xcode-%s-%s' "$xcode_version" "$xcode_build" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
|
||||
branch_name="${SOURCE_REF#refs/heads/}"
|
||||
branch_name="${branch_name#refs/tags/}"
|
||||
if [ -z "$branch_name" ]; then
|
||||
branch_name="$(git branch --show-current)"
|
||||
fi
|
||||
if [ -z "$branch_name" ]; then
|
||||
branch_name="detached"
|
||||
fi
|
||||
branch_slug="$(printf '%s' "$branch_name" | tr -c 'A-Za-z0-9._-' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//')"
|
||||
branch_digest="$(printf '%s' "$branch_name" | shasum -a 256 | awk '{ print substr($1, 1, 12) }')"
|
||||
if [ "$branch_name" = "main" ]; then
|
||||
branch_key="main"
|
||||
else
|
||||
branch_key="${branch_slug:0:48}-${branch_digest}"
|
||||
fi
|
||||
# Xcode's SourcePackages workspace state and DerivedData build
|
||||
# database contain absolute checkout paths. Keep exact-state caches
|
||||
# tied to the path contract; a different runner workspace must cold
|
||||
# resolve/build instead of consuming stale absolute paths.
|
||||
workspace_digest="$(printf '%s' "$GITHUB_WORKSPACE" | shasum -a 256 | awk '{ print substr($1, 1, 12) }')"
|
||||
workspace_key="workspace-${workspace_digest}"
|
||||
runner_os="$(printf '%s' "$(uname -s)" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
runner_os_version="$(sw_vers -productVersion 2>/dev/null || uname -r)"
|
||||
runner_os_version="$(printf '%s' "$runner_os_version" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
runner_arch="$(printf '%s' "$(uname -m)" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
runner_key="${runner_os}-${runner_os_version}-${runner_arch}"
|
||||
|
||||
resolved_file="cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
# Cache entries are immutable. A run-scoped save key plus a stable
|
||||
# source prefix lets a cold retry publish a newer repaired entry.
|
||||
cache_run_key="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
spm_prefix="reload-build-macos-spm-v2-${xcode_key}-${workspace_key}-"
|
||||
# DerivedData contains ONLY_ACTIVE_ARCH products and object files;
|
||||
# never restore those across OS/architecture changes.
|
||||
derived_prefix="reload-build-macos-deriveddata-v3-${xcode_key}-${runner_key}-${workspace_key}-"
|
||||
if [ -f "$resolved_file" ]; then
|
||||
resolved_hash="$(shasum -a 256 "$resolved_file" | awk '{ print $1 }')"
|
||||
spm_cache_enabled=true
|
||||
spm_key_base="${spm_prefix}${resolved_hash}"
|
||||
spm_key="${spm_key_base}-run-${cache_run_key}"
|
||||
spm_source_prefix="${spm_key_base}-run-"
|
||||
else
|
||||
echo "::warning::$resolved_file absent in source ref; disabling the SPM cache"
|
||||
spm_cache_enabled=false
|
||||
spm_key_base=""
|
||||
spm_key=""
|
||||
spm_source_prefix=""
|
||||
fi
|
||||
derived_data_key_base="${derived_prefix}${branch_key}-${source_sha}"
|
||||
|
||||
{
|
||||
echo "xcode_key=$xcode_key"
|
||||
echo "branch_key=$branch_key"
|
||||
echo "workspace_key=$workspace_key"
|
||||
echo "runner_key=$runner_key"
|
||||
echo "source_sha=$source_sha"
|
||||
echo "spm_cache_enabled=$spm_cache_enabled"
|
||||
echo "spm_prefix=$spm_prefix"
|
||||
echo "spm_key_base=$spm_key_base"
|
||||
echo "spm_key=$spm_key"
|
||||
echo "spm_source_prefix=$spm_source_prefix"
|
||||
echo "derived_data_key_base=$derived_data_key_base"
|
||||
echo "derived_data_key=${derived_data_key_base}-run-${cache_run_key}"
|
||||
echo "derived_data_source_prefix=${derived_data_key_base}-run-"
|
||||
echo "derived_data_branch_prefix=${derived_prefix}${branch_key}-"
|
||||
echo "derived_data_main_prefix=${derived_prefix}main-"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The verified prebuilt is the happy path for both platforms. Zig is
|
||||
# installed only after this attempt fails, immediately before the local
|
||||
# ensure-ghosttykit.sh fallback that needs it.
|
||||
- name: Download prebuilt GhosttyKit
|
||||
id: ghosttykit_prebuilt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
start=$(date +%s)
|
||||
# Keep a transient release outage from consuming the whole 60-minute
|
||||
# job before the Zig-backed fallback gets a chance to run. The script
|
||||
# still owns checksum/archive validation and preserves its normal
|
||||
# defaults for local and fleet callers.
|
||||
if GHOSTTYKIT_DOWNLOAD_RETRIES=2 \
|
||||
GHOSTTYKIT_DOWNLOAD_RETRY_DELAY=5 \
|
||||
GHOSTTYKIT_DOWNLOAD_MAX_TIME=60 \
|
||||
./scripts/download-prebuilt-ghosttykit.sh; then
|
||||
available=true
|
||||
else
|
||||
available=false
|
||||
echo "::warning::Prebuilt GhosttyKit download failed; enabling the Zig-backed fallback"
|
||||
fi
|
||||
end=$(date +%s)
|
||||
echo "available=$available" >> "$GITHUB_OUTPUT"
|
||||
echo "seconds=$(( end - start ))" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install zig for GhosttyKit fallback
|
||||
if: ${{ steps.ghosttykit_prebuilt.outputs.available != 'true' }}
|
||||
id: zig_fallback
|
||||
run: |
|
||||
set -euo pipefail
|
||||
start=$(date +%s)
|
||||
./scripts/install-zig-ci.sh
|
||||
end=$(date +%s)
|
||||
seconds="$(awk -v start="$start" -v end="$end" 'BEGIN { print end - start }')"
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build fallback GhosttyKit
|
||||
if: ${{ steps.ghosttykit_prebuilt.outputs.available != 'true' }}
|
||||
id: ghosttykit_fallback
|
||||
env:
|
||||
# The bounded download step already proved the prebuilt path failed;
|
||||
# do not let ensure-ghosttykit retry that URL with its long defaults.
|
||||
CMUX_GHOSTTYKIT_NO_PREBUILT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
start=$(date +%s)
|
||||
./scripts/ensure-ghosttykit.sh
|
||||
end=$(date +%s)
|
||||
echo "seconds=$(( end - start ))" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify provisioned GhosttyKit
|
||||
run: test -d GhosttyKit.xcframework
|
||||
|
||||
- name: Start SPM cache restore timer
|
||||
if: ${{ inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' }}
|
||||
id: spm_restore_start
|
||||
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore SPM SourcePackages cache
|
||||
if: ${{ inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' }}
|
||||
id: restore_spm
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .ci-source-packages
|
||||
key: ${{ steps.cache_meta.outputs.spm_key }}
|
||||
restore-keys: ${{ steps.cache_meta.outputs.spm_source_prefix }}
|
||||
|
||||
- name: Finish SPM cache restore
|
||||
if: ${{ always() && inputs.platform == 'macos' }}
|
||||
id: spm_restore
|
||||
env:
|
||||
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
|
||||
CACHE_SOURCE_PREFIX: ${{ steps.cache_meta.outputs.spm_source_prefix }}
|
||||
START_EPOCH: ${{ steps.spm_restore_start.outputs.epoch }}
|
||||
CACHE_OUTCOME: ${{ steps.restore_spm.outcome }}
|
||||
CACHE_HIT: ${{ steps.restore_spm.outputs.cache-hit }}
|
||||
CACHE_MATCHED_KEY: ${{ steps.restore_spm.outputs.cache-matched-key }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
now=$(date +%s)
|
||||
start=${START_EPOCH:-0}
|
||||
if ! [[ "$start" =~ ^[0-9]+$ ]] || [ "$start" -eq 0 ]; then
|
||||
start=$now
|
||||
fi
|
||||
cache_hit=${CACHE_HIT:-false}
|
||||
matched_key=${CACHE_MATCHED_KEY:-}
|
||||
exact_hit=false
|
||||
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
|
||||
status=disabled_missing_resolved
|
||||
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
|
||||
elif [ "${CACHE_OUTCOME:-failure}" != "success" ]; then
|
||||
status=restore_error
|
||||
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
|
||||
elif [ "$cache_hit" = "true" ] || { [ -n "${CACHE_SOURCE_PREFIX:-}" ] && [[ "$matched_key" == "$CACHE_SOURCE_PREFIX"* ]]; }; then
|
||||
status=exact_hit
|
||||
exact_hit=true
|
||||
elif [ -n "$matched_key" ]; then
|
||||
status=fallback_hit
|
||||
else
|
||||
status=miss
|
||||
fi
|
||||
mkdir -p "$GITHUB_WORKSPACE/.ci-source-packages"
|
||||
{
|
||||
echo "seconds=$(( now - start ))"
|
||||
echo "status=$status"
|
||||
echo "exact_hit=$exact_hit"
|
||||
echo "matched_key=$matched_key"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Sanitize restored SPM cache
|
||||
if: ${{ inputs.platform == 'macos' }}
|
||||
id: spm_sanitize
|
||||
env:
|
||||
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
|
||||
CACHE_STATUS: ${{ steps.spm_restore.outputs.status }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status=${CACHE_STATUS:-restore_error}
|
||||
discarded=false
|
||||
sanitizer=scripts/ci/sanitize-xcode-source-packages-cache.py
|
||||
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
|
||||
status=disabled_missing_resolved
|
||||
elif [ "$status" = "exact_hit" ]; then
|
||||
# The exact key is tied to Package.resolved, Xcode, source SHA, and
|
||||
# the fixed Blacksmith checkout path. Preserve its workspace state
|
||||
# so Xcode can reuse the resolved package graph; a bad state still
|
||||
# trips the guarded cold retry below.
|
||||
echo "preserving exact-hit SourcePackages workspace state"
|
||||
elif [ ! -f "$sanitizer" ]; then
|
||||
echo "::warning::$sanitizer absent in source ref; discarding the restored SPM cache"
|
||||
status=sanitize_error
|
||||
discarded=true
|
||||
elif ! python3 "$sanitizer" .ci-source-packages; then
|
||||
echo "::warning::SPM cache sanitize failed; discarding it and building cold"
|
||||
status=sanitize_error
|
||||
discarded=true
|
||||
fi
|
||||
if [ "$discarded" = "true" ]; then
|
||||
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
|
||||
mkdir -p "$GITHUB_WORKSPACE/.ci-source-packages"
|
||||
fi
|
||||
echo "status=$status" >> "$GITHUB_OUTPUT"
|
||||
echo "discarded=$discarded" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Start DerivedData cache restore timer
|
||||
if: ${{ inputs.platform == 'macos' }}
|
||||
id: derived_data_restore_start
|
||||
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore DerivedData cache
|
||||
if: ${{ inputs.platform == 'macos' }}
|
||||
id: restore_derived_data
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
# Keep the build database, compiler outputs, and Debug products that
|
||||
# Xcode needs for a true incremental build. Archives, indexes, logs,
|
||||
# SourcePackages, and every non-Debug product remain excluded.
|
||||
path: |
|
||||
.ci-reload-derived-data/Build/Intermediates.noindex
|
||||
.ci-reload-derived-data/Build/Products/Debug
|
||||
.ci-reload-derived-data/ModuleCache.noindex
|
||||
.ci-reload-derived-data/SDKStatCaches.noindex
|
||||
key: ${{ steps.cache_meta.outputs.derived_data_key }}
|
||||
restore-keys: |
|
||||
${{ steps.cache_meta.outputs.derived_data_source_prefix }}
|
||||
${{ steps.cache_meta.outputs.derived_data_branch_prefix }}
|
||||
${{ steps.cache_meta.outputs.derived_data_main_prefix }}
|
||||
|
||||
- name: Finish DerivedData cache restore
|
||||
if: ${{ always() && inputs.platform == 'macos' }}
|
||||
id: derived_data_restore
|
||||
env:
|
||||
CACHE_SOURCE_PREFIX: ${{ steps.cache_meta.outputs.derived_data_source_prefix }}
|
||||
START_EPOCH: ${{ steps.derived_data_restore_start.outputs.epoch }}
|
||||
CACHE_OUTCOME: ${{ steps.restore_derived_data.outcome }}
|
||||
CACHE_HIT: ${{ steps.restore_derived_data.outputs.cache-hit }}
|
||||
CACHE_MATCHED_KEY: ${{ steps.restore_derived_data.outputs.cache-matched-key }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
now=$(date +%s)
|
||||
start=${START_EPOCH:-0}
|
||||
if ! [[ "$start" =~ ^[0-9]+$ ]] || [ "$start" -eq 0 ]; then
|
||||
start=$now
|
||||
fi
|
||||
cache_hit=${CACHE_HIT:-false}
|
||||
matched_key=${CACHE_MATCHED_KEY:-}
|
||||
exact_hit=false
|
||||
if [ "${CACHE_OUTCOME:-failure}" != "success" ]; then
|
||||
status=restore_error
|
||||
rm -rf "$GITHUB_WORKSPACE/.ci-reload-derived-data"
|
||||
elif [ "$cache_hit" = "true" ] || { [ -n "${CACHE_SOURCE_PREFIX:-}" ] && [[ "$matched_key" == "$CACHE_SOURCE_PREFIX"* ]]; }; then
|
||||
status=exact_hit
|
||||
exact_hit=true
|
||||
elif [ -n "$matched_key" ]; then
|
||||
status=fallback_hit
|
||||
else
|
||||
status=miss
|
||||
fi
|
||||
mkdir -p "$GITHUB_WORKSPACE/.ci-reload-derived-data"
|
||||
{
|
||||
echo "seconds=$(( now - start ))"
|
||||
echo "status=$status"
|
||||
echo "exact_hit=$exact_hit"
|
||||
echo "matched_key=$matched_key"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# actions/checkout gives every tracked source a fresh mtime, newer than
|
||||
# restored compiler outputs. Xcode then recompiles an exact cache hit as
|
||||
# if it were cold. Derive a stable, pre-2020 mtime from each Git blob id:
|
||||
# unchanged files stay unchanged across runs, while changed blobs receive
|
||||
# a different file signature and are still invalidated correctly.
|
||||
- name: Normalize tracked source mtimes for Xcode
|
||||
if: ${{ inputs.platform == 'macos' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# 2001-01-01 plus at most fifteen years keeps source mtimes safely
|
||||
# behind newly generated outputs while preserving nanosecond entropy.
|
||||
base_seconds = 978_307_200
|
||||
span_seconds = 15 * 365 * 24 * 60 * 60
|
||||
records = subprocess.check_output(
|
||||
["git", "ls-files", "--recurse-submodules", "--stage", "-z"]
|
||||
).split(b"\0")
|
||||
normalized = 0
|
||||
for record in records:
|
||||
if not record:
|
||||
continue
|
||||
metadata, raw_path = record.split(b"\t", 1)
|
||||
mode, object_id, stage = metadata.split()
|
||||
if mode == b"160000" or stage != b"0":
|
||||
continue
|
||||
path = os.fsdecode(raw_path)
|
||||
if not os.path.lexists(path):
|
||||
continue
|
||||
digest = object_id.decode("ascii")
|
||||
seconds = base_seconds + int(digest[:12], 16) % span_seconds
|
||||
nanoseconds = int(digest[12:20], 16) % 1_000_000_000
|
||||
mtime_ns = seconds * 1_000_000_000 + nanoseconds
|
||||
os.utime(path, ns=(mtime_ns, mtime_ns), follow_symlinks=False)
|
||||
normalized += 1
|
||||
print(f"normalized {normalized} Git-tracked source mtimes")
|
||||
PY
|
||||
|
||||
- name: Mark deps-ready
|
||||
id: t1
|
||||
@@ -95,12 +419,64 @@ jobs:
|
||||
- name: Build tagged macOS app
|
||||
if: ${{ inputs.platform == 'macos' }}
|
||||
id: build_macos
|
||||
env:
|
||||
BUILD_TAG: ${{ inputs.tag }}
|
||||
CMUX_GHOSTTYKIT_PREPROVISIONED: "1"
|
||||
CMUX_SOURCE_PACKAGES_DIR: ${{ github.workspace }}/.ci-source-packages
|
||||
SPM_CACHE_STATUS: ${{ steps.spm_sanitize.outputs.status }}
|
||||
DERIVED_DATA_CACHE_STATUS: ${{ steps.derived_data_restore.outputs.status }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Per-run log path: /tmp/reload.log collides across tenants on the
|
||||
# multi-tenant self-hosted fleet Macs (tee: Permission denied).
|
||||
log="$RUNNER_TEMP/reload.log"
|
||||
./scripts/reload.sh --tag "${{ inputs.tag }}" --swift-frontend-workaround 2>&1 | tee "$log"
|
||||
derived_data="$GITHUB_WORKSPACE/.ci-reload-derived-data"
|
||||
echo "cache_retry=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
run_reload() {
|
||||
if [ "$SPM_CACHE_STATUS" = "exact_hit" ]; then
|
||||
# The exact Package.resolved/state pair is safe to reuse. If it
|
||||
# is stale or corrupt, the caller clears both caches and changes
|
||||
# this marker before retrying from a normal cold resolve.
|
||||
export CMUX_DISABLE_AUTOMATIC_PACKAGE_RESOLUTION=1
|
||||
else
|
||||
export CMUX_DISABLE_AUTOMATIC_PACKAGE_RESOLUTION=0
|
||||
fi
|
||||
./scripts/reload.sh \
|
||||
--tag "$BUILD_TAG" \
|
||||
--derived-data "$derived_data" \
|
||||
--swift-frontend-workaround
|
||||
}
|
||||
|
||||
set +e
|
||||
run_reload 2>&1 | tee "$log"
|
||||
build_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
# A successfully restored but stale/corrupt cache must cost at most
|
||||
# one failed attempt: clear both cache roots and retry once from cold.
|
||||
if [ "$build_status" -ne 0 ] && {
|
||||
[ "$SPM_CACHE_STATUS" = "exact_hit" ] ||
|
||||
[ "$SPM_CACHE_STATUS" = "fallback_hit" ] ||
|
||||
[ "$DERIVED_DATA_CACHE_STATUS" = "exact_hit" ] ||
|
||||
[ "$DERIVED_DATA_CACHE_STATUS" = "fallback_hit" ];
|
||||
}; then
|
||||
echo "::warning::Cached macOS build failed; clearing SPM and DerivedData caches and retrying cold"
|
||||
rm -rf "$CMUX_SOURCE_PACKAGES_DIR" "$derived_data"
|
||||
mkdir -p "$CMUX_SOURCE_PACKAGES_DIR" "$derived_data"
|
||||
SPM_CACHE_STATUS=miss
|
||||
DERIVED_DATA_CACHE_STATUS=miss
|
||||
: > "$log"
|
||||
set +e
|
||||
run_reload 2>&1 | tee "$log"
|
||||
build_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "cache_retry=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [ "$build_status" -ne 0 ]; then
|
||||
exit "$build_status"
|
||||
fi
|
||||
|
||||
app_path="$(awk '/^App path:/{getline; sub(/^ /,""); print; exit}' "$log")"
|
||||
[ -n "$app_path" ] && [ -d "$app_path" ] || { echo "could not locate built app" >&2; exit 1; }
|
||||
echo "app_path=$app_path" >> "$GITHUB_OUTPUT"
|
||||
@@ -147,6 +523,7 @@ jobs:
|
||||
|
||||
# Match ios/scripts/reload.sh --swift-frontend-workaround. Optimized
|
||||
# Debug builds need this Xcode 26 codegen path disabled on every lane.
|
||||
# shellcheck disable=SC2016 # Xcode expands $(inherited), not this shell.
|
||||
swift_workaround_args=(
|
||||
SWIFT_ENABLE_BATCH_MODE=NO
|
||||
DEBUG_INFORMATION_FORMAT=
|
||||
@@ -162,7 +539,7 @@ jobs:
|
||||
ios_ready || { echo "iOS platform still not registered; archive would fail" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
./scripts/ensure-ghosttykit.sh
|
||||
test -d GhosttyKit.xcframework
|
||||
out="$GITHUB_WORKSPACE/build"
|
||||
mkdir -p "$out"
|
||||
archive="$out/cmux-ios-$slug.xcarchive"
|
||||
@@ -227,36 +604,254 @@ jobs:
|
||||
ditto "$sim_app" "$pkg/simulator/cmux.app"
|
||||
ditto -c -k "$pkg" "$GITHUB_WORKSPACE/artifact/archive.zip"
|
||||
|
||||
- name: Mark build-ready
|
||||
id: t2
|
||||
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Trim tag-specific products before DerivedData cache save
|
||||
if: ${{ success() && inputs.platform == 'macos' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
products="$GITHUB_WORKSPACE/.ci-reload-derived-data/Build/Products/Debug"
|
||||
[ -d "$products" ] || exit 0
|
||||
# reload.sh stages the requested tagged app beside the stable base
|
||||
# product. Keep only the base product in the cache; otherwise every
|
||||
# tag accumulates another app bundle under the branch/SHA key.
|
||||
for product in "$products"/cmux\ DEV\ *.app "$products"/.cmux\ DEV\ *.reload-*.app; do
|
||||
[ -d "$product" ] || continue
|
||||
[ "$(basename "$product")" = "cmux DEV.app" ] || rm -rf -- "$product"
|
||||
done
|
||||
|
||||
- name: Start SPM cache save timer
|
||||
if: ${{ success() && inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' && (steps.spm_restore.outputs.exact_hit != 'true' || steps.spm_sanitize.outputs.discarded == 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
|
||||
id: spm_save_start
|
||||
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Save SPM SourcePackages cache
|
||||
if: ${{ success() && inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' && (steps.spm_restore.outputs.exact_hit != 'true' || steps.spm_sanitize.outputs.discarded == 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
|
||||
id: save_spm
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .ci-source-packages
|
||||
key: ${{ steps.cache_meta.outputs.spm_key }}
|
||||
|
||||
- name: Finish SPM cache save
|
||||
if: ${{ always() && inputs.platform == 'macos' }}
|
||||
id: spm_save
|
||||
env:
|
||||
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
|
||||
START_EPOCH: ${{ steps.spm_save_start.outputs.epoch }}
|
||||
SAVE_OUTCOME: ${{ steps.save_spm.outcome }}
|
||||
RESTORE_EXACT_HIT: ${{ steps.spm_restore.outputs.exact_hit }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
now=$(date +%s)
|
||||
start=${START_EPOCH:-0}
|
||||
if [[ "$start" =~ ^[0-9]+$ ]] && [ "$start" -gt 0 ]; then
|
||||
seconds=$(( now - start ))
|
||||
else
|
||||
seconds=0
|
||||
fi
|
||||
case "${SAVE_OUTCOME:-skipped}" in
|
||||
success) status=saved ;;
|
||||
failure) status=save_error ;;
|
||||
*)
|
||||
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
|
||||
status=skipped_disabled
|
||||
elif [ "${RESTORE_EXACT_HIT:-false}" = "true" ]; then
|
||||
status=skipped_exact_hit
|
||||
else
|
||||
status=skipped
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
echo "status=$status" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Start DerivedData cache save timer
|
||||
if: ${{ success() && inputs.platform == 'macos' && (steps.derived_data_restore.outputs.exact_hit != 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
|
||||
id: derived_data_save_start
|
||||
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Save DerivedData cache
|
||||
if: ${{ success() && inputs.platform == 'macos' && (steps.derived_data_restore.outputs.exact_hit != 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
|
||||
id: save_derived_data
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
# Match the bounded restore set; only Debug products are included.
|
||||
path: |
|
||||
.ci-reload-derived-data/Build/Intermediates.noindex
|
||||
.ci-reload-derived-data/Build/Products/Debug
|
||||
.ci-reload-derived-data/ModuleCache.noindex
|
||||
.ci-reload-derived-data/SDKStatCaches.noindex
|
||||
key: ${{ steps.cache_meta.outputs.derived_data_key }}
|
||||
|
||||
- name: Finish DerivedData cache save
|
||||
if: ${{ always() && inputs.platform == 'macos' }}
|
||||
id: derived_data_save
|
||||
env:
|
||||
START_EPOCH: ${{ steps.derived_data_save_start.outputs.epoch }}
|
||||
SAVE_OUTCOME: ${{ steps.save_derived_data.outcome }}
|
||||
RESTORE_EXACT_HIT: ${{ steps.derived_data_restore.outputs.exact_hit }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
now=$(date +%s)
|
||||
start=${START_EPOCH:-0}
|
||||
if [[ "$start" =~ ^[0-9]+$ ]] && [ "$start" -gt 0 ]; then
|
||||
seconds=$(( now - start ))
|
||||
else
|
||||
seconds=0
|
||||
fi
|
||||
case "${SAVE_OUTCOME:-skipped}" in
|
||||
success) status=saved ;;
|
||||
failure) status=save_error ;;
|
||||
*)
|
||||
if [ "${RESTORE_EXACT_HIT:-false}" = "true" ]; then
|
||||
status=skipped_exact_hit
|
||||
else
|
||||
status=skipped
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
echo "status=$status" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write timings.json
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
TIMING_TAG: ${{ inputs.tag }}
|
||||
TIMING_PLATFORM: ${{ inputs.platform }}
|
||||
TIMING_RUNNER: ${{ inputs.runner }}
|
||||
TIMING_REF: ${{ inputs.ref || github.ref }}
|
||||
TIMING_T0: ${{ steps.t0.outputs.epoch || '0' }}
|
||||
TIMING_T1: ${{ steps.t1.outputs.epoch || '0' }}
|
||||
TIMING_T2: ${{ steps.t2.outputs.epoch || '0' }}
|
||||
GHOSTTYKIT_PREBUILT: ${{ steps.ghosttykit_prebuilt.outputs.available || 'false' }}
|
||||
GHOSTTYKIT_DOWNLOAD_SECONDS: ${{ steps.ghosttykit_prebuilt.outputs.seconds || '0' }}
|
||||
ZIG_INSTALL_SECONDS: ${{ steps.zig_fallback.outputs.seconds || '0' }}
|
||||
GHOSTTYKIT_FALLBACK_SECONDS: ${{ steps.ghosttykit_fallback.outputs.seconds || '0' }}
|
||||
CACHE_XCODE_KEY: ${{ steps.cache_meta.outputs.xcode_key }}
|
||||
CACHE_BRANCH_KEY: ${{ steps.cache_meta.outputs.branch_key }}
|
||||
CACHE_WORKSPACE_KEY: ${{ steps.cache_meta.outputs.workspace_key }}
|
||||
CACHE_RUNNER_KEY: ${{ steps.cache_meta.outputs.runner_key }}
|
||||
SPM_CACHE_PRIMARY_KEY: ${{ steps.cache_meta.outputs.spm_key }}
|
||||
SPM_CACHE_STATUS: ${{ steps.spm_sanitize.outputs.status || 'not_applicable' }}
|
||||
SPM_CACHE_EXACT_HIT: ${{ steps.spm_sanitize.outputs.status == 'exact_hit' }}
|
||||
SPM_CACHE_MATCHED_KEY: ${{ steps.spm_restore.outputs.matched_key }}
|
||||
SPM_CACHE_RESTORE_OUTCOME: ${{ steps.restore_spm.outcome || 'not_applicable' }}
|
||||
SPM_CACHE_RESTORE_SECONDS: ${{ steps.spm_restore.outputs.seconds || '0' }}
|
||||
SPM_CACHE_SAVE_STATUS: ${{ steps.spm_save.outputs.status || 'not_applicable' }}
|
||||
SPM_CACHE_SAVE_SECONDS: ${{ steps.spm_save.outputs.seconds || '0' }}
|
||||
DERIVED_DATA_CACHE_PRIMARY_KEY: ${{ steps.cache_meta.outputs.derived_data_key }}
|
||||
DERIVED_DATA_CACHE_STATUS: ${{ steps.derived_data_restore.outputs.status || 'not_applicable' }}
|
||||
DERIVED_DATA_CACHE_EXACT_HIT: ${{ steps.derived_data_restore.outputs.exact_hit || 'false' }}
|
||||
DERIVED_DATA_CACHE_MATCHED_KEY: ${{ steps.derived_data_restore.outputs.matched_key }}
|
||||
DERIVED_DATA_CACHE_RESTORE_OUTCOME: ${{ steps.restore_derived_data.outcome || 'not_applicable' }}
|
||||
DERIVED_DATA_CACHE_RESTORE_SECONDS: ${{ steps.derived_data_restore.outputs.seconds || '0' }}
|
||||
DERIVED_DATA_CACHE_SAVE_STATUS: ${{ steps.derived_data_save.outputs.status || 'not_applicable' }}
|
||||
DERIVED_DATA_CACHE_SAVE_SECONDS: ${{ steps.derived_data_save.outputs.seconds || '0' }}
|
||||
CACHE_COLD_RETRY: ${{ steps.build_macos.outputs.cache_retry || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifact
|
||||
now=$(date +%s)
|
||||
t0=${{ steps.t0.outputs.epoch }}
|
||||
t1=${{ steps.t1.outputs.epoch || 0 }}
|
||||
cat > artifact/timings.json <<JSON
|
||||
{
|
||||
"tag": "${{ inputs.tag }}",
|
||||
"platform": "${{ inputs.platform }}",
|
||||
"runner": "${{ inputs.runner }}",
|
||||
"ref": "${{ inputs.ref }}",
|
||||
"deps_seconds": $(( t1 > t0 ? t1 - t0 : 0 )),
|
||||
"build_seconds": $(( t1 > 0 ? now - t1 : 0 )),
|
||||
"post_checkout_total_seconds": $(( now - t0 ))
|
||||
export TIMING_NOW=$now
|
||||
python3 - <<'PY' > artifact/timings.json
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
def integer(name: str) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, "0"))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def boolean(name: str) -> bool:
|
||||
return os.environ.get(name, "false").lower() == "true"
|
||||
|
||||
t0 = integer("TIMING_T0")
|
||||
t1 = integer("TIMING_T1")
|
||||
t2 = integer("TIMING_T2")
|
||||
now = integer("TIMING_NOW")
|
||||
timing_clock_valid = t0 > 0
|
||||
spm_status = os.environ["SPM_CACHE_STATUS"]
|
||||
derived_status = os.environ["DERIVED_DATA_CACHE_STATUS"]
|
||||
payload = {
|
||||
"tag": os.environ["TIMING_TAG"],
|
||||
"platform": os.environ["TIMING_PLATFORM"],
|
||||
"runner": os.environ["TIMING_RUNNER"],
|
||||
"ref": os.environ["TIMING_REF"],
|
||||
"deps_seconds": t1 - t0 if timing_clock_valid and t1 > t0 else 0,
|
||||
"build_seconds": t2 - t1 if timing_clock_valid and t1 > t0 and t2 > t1 else 0,
|
||||
"post_checkout_total_seconds": now - t0 if timing_clock_valid and now > t0 else 0,
|
||||
"ghosttykit_prebuilt": boolean("GHOSTTYKIT_PREBUILT"),
|
||||
"ghosttykit_download_seconds": integer("GHOSTTYKIT_DOWNLOAD_SECONDS"),
|
||||
"zig_install_seconds": integer("ZIG_INSTALL_SECONDS"),
|
||||
"ghosttykit_fallback_seconds": integer("GHOSTTYKIT_FALLBACK_SECONDS"),
|
||||
"cache_xcode_key": os.environ.get("CACHE_XCODE_KEY", ""),
|
||||
"cache_branch_key": os.environ.get("CACHE_BRANCH_KEY", ""),
|
||||
"cache_workspace_key": os.environ.get("CACHE_WORKSPACE_KEY", ""),
|
||||
"cache_runner_key": os.environ.get("CACHE_RUNNER_KEY", ""),
|
||||
"spm_cache_primary_key": os.environ.get("SPM_CACHE_PRIMARY_KEY", ""),
|
||||
"spm_cache_status": spm_status,
|
||||
"spm_cache_hit": spm_status in {"exact_hit", "fallback_hit"},
|
||||
"spm_cache_exact_hit": boolean("SPM_CACHE_EXACT_HIT"),
|
||||
"spm_cache_matched_key": os.environ.get("SPM_CACHE_MATCHED_KEY", ""),
|
||||
"spm_cache_restore_outcome": os.environ["SPM_CACHE_RESTORE_OUTCOME"],
|
||||
"spm_cache_restore_seconds": integer("SPM_CACHE_RESTORE_SECONDS"),
|
||||
"spm_cache_save_status": os.environ["SPM_CACHE_SAVE_STATUS"],
|
||||
"spm_cache_save_seconds": integer("SPM_CACHE_SAVE_SECONDS"),
|
||||
"derived_data_cache_primary_key": os.environ.get("DERIVED_DATA_CACHE_PRIMARY_KEY", ""),
|
||||
"derived_data_cache_status": derived_status,
|
||||
"derived_data_cache_hit": derived_status in {"exact_hit", "fallback_hit"},
|
||||
"derived_data_cache_exact_hit": boolean("DERIVED_DATA_CACHE_EXACT_HIT"),
|
||||
"derived_data_cache_matched_key": os.environ.get("DERIVED_DATA_CACHE_MATCHED_KEY", ""),
|
||||
"derived_data_cache_restore_outcome": os.environ["DERIVED_DATA_CACHE_RESTORE_OUTCOME"],
|
||||
"derived_data_cache_restore_seconds": integer("DERIVED_DATA_CACHE_RESTORE_SECONDS"),
|
||||
"derived_data_cache_save_status": os.environ["DERIVED_DATA_CACHE_SAVE_STATUS"],
|
||||
"derived_data_cache_save_seconds": integer("DERIVED_DATA_CACHE_SAVE_SECONDS"),
|
||||
"cache_cold_retry": boolean("CACHE_COLD_RETRY"),
|
||||
}
|
||||
JSON
|
||||
json.dump(payload, fp=sys.stdout, indent=2, sort_keys=True)
|
||||
print()
|
||||
PY
|
||||
t0=${TIMING_T0:-0}
|
||||
t1=${TIMING_T1:-0}
|
||||
t2=${TIMING_T2:-0}
|
||||
if (( t0 > 0 && t1 > t0 )); then
|
||||
deps_seconds=$(( t1 - t0 ))
|
||||
else
|
||||
deps_seconds=0
|
||||
fi
|
||||
if (( t0 > 0 && t1 > t0 && t2 > t1 )); then
|
||||
build_seconds=$(( t2 - t1 ))
|
||||
else
|
||||
build_seconds=0
|
||||
fi
|
||||
if (( t0 > 0 && now > t0 )); then
|
||||
total_seconds=$(( now - t0 ))
|
||||
else
|
||||
total_seconds=0
|
||||
fi
|
||||
{
|
||||
echo "### reload-build timings"
|
||||
echo ""
|
||||
echo "- runner: \`${{ inputs.runner }}\`"
|
||||
echo "- platform: \`${{ inputs.platform }}\`"
|
||||
echo "- deps: $(( t1 > t0 ? t1 - t0 : 0 ))s"
|
||||
echo "- build: $(( t1 > 0 ? now - t1 : 0 ))s"
|
||||
echo "- post-checkout total: $(( now - t0 ))s"
|
||||
echo "- runner: \`$TIMING_RUNNER\`"
|
||||
echo "- platform: \`$TIMING_PLATFORM\`"
|
||||
echo "- deps: ${deps_seconds}s"
|
||||
echo "- build: ${build_seconds}s"
|
||||
echo "- SPM cache: ${SPM_CACHE_STATUS} (restore ${SPM_CACHE_RESTORE_SECONDS}s, save ${SPM_CACHE_SAVE_SECONDS}s / ${SPM_CACHE_SAVE_STATUS})"
|
||||
echo "- DerivedData cache: ${DERIVED_DATA_CACHE_STATUS} (restore ${DERIVED_DATA_CACHE_RESTORE_SECONDS}s, save ${DERIVED_DATA_CACHE_SAVE_SECONDS}s / ${DERIVED_DATA_CACHE_SAVE_STATUS})"
|
||||
echo "- cache-triggered cold retry: ${CACHE_COLD_RETRY}"
|
||||
echo "- GhosttyKit prebuilt: ${GHOSTTYKIT_PREBUILT} (download ${GHOSTTYKIT_DOWNLOAD_SECONDS}s, Zig fallback ${ZIG_INSTALL_SECONDS}s)"
|
||||
echo "- post-checkout total: ${total_seconds}s"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
|
||||
|
||||
@@ -379,6 +379,9 @@ jobs:
|
||||
-derivedDataPath "$IOS_DERIVED_DATA"
|
||||
)
|
||||
if [ -n "${TEST_FILTER:-}" ]; then
|
||||
if [[ "$TEST_FILTER" == cmuxUITests/* ]]; then
|
||||
XCODEBUILD_ARGS+=(-testPlan cmux-ui)
|
||||
fi
|
||||
XCODEBUILD_ARGS+=(-only-testing:"$TEST_FILTER")
|
||||
else
|
||||
# Full UI tests currently exceed the pull-request simulator budget
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Testbox broker guard
|
||||
|
||||
# The main CI suite is dispatch-only right now, so the trust-boundary guard for
|
||||
# the Blacksmith Testbox lane gets its own always-on workflow. It must run on
|
||||
# every pull request, with no path filter: a path filter is exactly the thing a
|
||||
# change that moves the guard could slip past.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: testbox-broker-guard-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Testbox broker trust boundary
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install guard dependencies
|
||||
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3
|
||||
|
||||
- name: Validate Blacksmith Testbox broker trust boundary
|
||||
run: python3 tests/test_ci_testbox_broker_guard.py
|
||||
|
||||
- name: Validate receipt-bound cleanup ownership check
|
||||
run: ./tests/test_testbox_cleanup_receipt_ref.sh
|
||||
|
||||
- name: Lint the Testbox lane helpers
|
||||
run: shellcheck scripts/blacksmith-bounded-command.sh scripts/blacksmith-cmux-tui-testbox-stage.sh scripts/blacksmith-testbox-cleanup.sh scripts/blacksmith-testbox-keepalive.sh
|
||||
|
||||
- name: Lint the Testbox warmup workflow
|
||||
env:
|
||||
ACTIONLINT_VERSION: "1.7.7"
|
||||
ACTIONLINT_SHA256: "023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/actionlint.tar.gz"
|
||||
curl -fsSL -o "$archive" \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict
|
||||
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
|
||||
"$RUNNER_TEMP/actionlint" \
|
||||
.github/workflows/cmux-tui-testbox-warmup.yml \
|
||||
.github/workflows/testbox-broker-guard.yml
|
||||
@@ -18,6 +18,7 @@ ios/Config/AppStoreConnect.local.plist
|
||||
# Swift Package Manager
|
||||
.swiftpm/
|
||||
.ci-source-packages/
|
||||
.ci-reload-derived-data/
|
||||
|
||||
# GhosttyKit binary (built from ghostty submodule via scripts/setup.sh)
|
||||
GhosttyKit.xcframework
|
||||
@@ -73,3 +74,7 @@ artifacts/
|
||||
|
||||
# tmux verbose debug logs (tmux -v) that land in the cwd
|
||||
tmux-*.log
|
||||
|
||||
# Remote-only Blacksmith cmux-tui benchmark output
|
||||
/testbox-benchmark/
|
||||
/.cmux-scratch/
|
||||
|
||||
@@ -17,6 +17,10 @@ A tag gives the app its own name, bundle ID, socket, and derived data path, so i
|
||||
|
||||
Other variants: `reloadp.sh` (Release), `reloads.sh` (Release as isolated "cmux STAGING"), `reload2.sh --tag <tag>` (both).
|
||||
|
||||
## Shared Mac fleet capacity
|
||||
|
||||
Every healthy slot in the canonical Mac fleet is general-purpose. Builds, iOS archives, tests, profiling, simulator and UI verification, and any other resource-intensive workload may use any available slot. Do not wait for an AWS-only builder or infer capacity from a workload label. Use the shared lease state and slot-isolated paths supplied by the fleet tooling.
|
||||
|
||||
Compile-only check, no launch:
|
||||
|
||||
```bash
|
||||
@@ -127,3 +131,7 @@ Detailed contributor rules live in `skills/`. Use the task-specific skill before
|
||||
- `cmux-shared-behavior`: shared action paths for multi-entrypoint behavior and optimistic updates.
|
||||
- `cmux-ghostty`: Ghostty submodule and GhosttyKit workflow.
|
||||
- `cmux-release`: release, version bump, changelog, pretag guard, release assets.
|
||||
- Blacksmith Testbox (remote Linux builds for cmux-tui): warm your own box before any cmux-tui Rust or Zig
|
||||
build, and never compile cmux-tui on the Mac. The skill lives in cmuxterm-hq at
|
||||
`skills/infra/blacksmith-testbox/SKILL.md`; the workflows, `scripts/blacksmith-*.sh`, and the
|
||||
`tests/test_testbox_*` guards stay here. Quickest path: `./scripts/blacksmith-testbox-demo.sh`.
|
||||
|
||||
@@ -77,26 +77,31 @@ extension CMUXCLI {
|
||||
/// the pane exits before the real command runs; that is why Claude Code
|
||||
/// 2.1.183 teammates never opened a split pane (issue #6447).
|
||||
///
|
||||
/// Every command is run through `/bin/sh -c '<command>'`, so Ghostty execs a
|
||||
/// shell rather than a builtin/expression/assignment-prefix. The whole command
|
||||
/// is single-quoted, so it round-trips verbatim regardless of operators or
|
||||
/// quoting — there is no attempt to classify which commands "need" a shell,
|
||||
/// which was unreliable (tmux shell-commands can hide operators with no
|
||||
/// surrounding whitespace). Commands that are already a shell invocation (e.g.
|
||||
/// OMO's `/bin/sh -c "…"`) are simply run through one more shell, which execs
|
||||
/// straight into them.
|
||||
/// Every command is run through `/bin/sh -lc '<command>'`, so Ghostty execs a
|
||||
/// login shell rather than a builtin/expression/assignment-prefix. The `-l`
|
||||
/// is important: Ghostty's `exec -l` only changes argv[0], and does not make
|
||||
/// macOS `/bin/sh` read `/etc/profile` when it is given a non-interactive `-c`
|
||||
/// command. The login shell therefore runs `path_helper` and restores the
|
||||
/// user's full login PATH before the command starts (issue #10189). The whole
|
||||
/// command is single-quoted, so it round-trips verbatim regardless of
|
||||
/// operators or quoting — there is no attempt to classify which commands
|
||||
/// "need" a shell, which was unreliable (tmux shell-commands can hide
|
||||
/// operators with no surrounding whitespace). Commands that are already a
|
||||
/// shell invocation (e.g. OMO's `/bin/sh -c "…"`) are simply run through one
|
||||
/// more shell, which execs straight into them.
|
||||
///
|
||||
/// A POSIX shell (`/bin/sh`) is used deliberately rather than the user's
|
||||
/// `$SHELL`: the commands being wrapped are POSIX `sh` syntax (Claude Code's
|
||||
/// `cd … && env …`, and the no-command fallback `exec ${SHELL:-/bin/sh} -l`),
|
||||
/// and `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter
|
||||
/// expansion or `NAME=value` command prefixes. `/bin/sh` is always present and
|
||||
/// runs the bodies correctly for every user. `-l` is not passed (`/bin/sh`
|
||||
/// does not take it); on macOS Ghostty already supplies a login-style argv0.
|
||||
/// and `csh`/`tcsh` cannot parse `${VAR:-default}` parameter expansion or
|
||||
/// `NAME=value` command prefixes. `/bin/sh` is always present and runs the
|
||||
/// bodies correctly for every user; its login mode is a shell-independent way to
|
||||
/// invoke macOS `path_helper` without asking the user's shell to parse a
|
||||
/// POSIX command body.
|
||||
func tmuxShellInvokedStartCommand(_ command: String) -> String {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return command }
|
||||
return "/bin/sh -c \(tmuxShellQuote(trimmed))"
|
||||
return "/bin/sh -lc \(tmuxShellQuote(trimmed))"
|
||||
}
|
||||
|
||||
/// Like `tmuxShellInvokedStartCommand`, but first exports `prependEnv` inside
|
||||
|
||||
+10
-3
@@ -12,9 +12,16 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
|
||||
|
||||
/// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the
|
||||
/// ticket has no Tailscale route to disclose.
|
||||
public func encode(_ ticket: CmxAttachTicket) throws -> URL? {
|
||||
public func encode(
|
||||
_ ticket: CmxAttachTicket,
|
||||
pairingURLScheme: CmxPairingURLScheme? =
|
||||
CmxPairingURLSchemeResolver().resolved
|
||||
) throws -> URL? {
|
||||
let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale }
|
||||
guard !tailscaleRoutes.isEmpty else { return nil }
|
||||
guard !tailscaleRoutes.isEmpty,
|
||||
let scheme = pairingURLScheme?.rawValue else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let legacyTicket = try CmxAttachTicket(
|
||||
version: ticket.version,
|
||||
@@ -35,7 +42,7 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let payload = base64URLEncode(try encoder.encode(legacyTicket))
|
||||
return URL(
|
||||
string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)"
|
||||
string: "\(scheme)://attach?v=\(legacyTicket.version)&payload=\(payload)"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,8 +77,13 @@ public struct CmxPairingQRCode: Sendable {
|
||||
/// route is dropped, never written into a scannable code.
|
||||
public func encode(
|
||||
_ ticket: CmxAttachTicket,
|
||||
routeDisclosureMode: CmxPairingRouteDisclosureMode
|
||||
routeDisclosureMode: CmxPairingRouteDisclosureMode,
|
||||
pairingURLScheme: CmxPairingURLScheme? =
|
||||
CmxPairingURLSchemeResolver().resolved
|
||||
) -> String? {
|
||||
guard let scheme = pairingURLScheme?.rawValue else {
|
||||
return nil
|
||||
}
|
||||
let items: [String]
|
||||
switch routeDisclosureMode {
|
||||
case .irohIdentityOnly:
|
||||
@@ -119,7 +124,7 @@ public struct CmxPairingQRCode: Sendable {
|
||||
// Mac's QR opens the dev iOS build, a release Mac's QR opens the
|
||||
// release build, and the system camera can no longer hand a beta/prod
|
||||
// code to a dev build that also claimed the scheme.
|
||||
return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&")
|
||||
return "\(scheme)://attach?" + items.joined(separator: "&")
|
||||
}
|
||||
|
||||
/// Whether `ticket` is expressible in the selected minimal grammar.
|
||||
@@ -224,7 +229,7 @@ public struct CmxPairingQRCode: Sendable {
|
||||
/// the minimal grammar).
|
||||
public func isPairingCodeURLString(_ rawValue: String) -> Bool {
|
||||
guard let url = URL(string: rawValue),
|
||||
CmxPairingURLScheme.isPairingScheme(url.scheme),
|
||||
CmxPairingURLScheme(rawValue: url.scheme) != nil,
|
||||
url.host == "attach",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return false
|
||||
|
||||
@@ -1,75 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
/// The channel-specific URL scheme carried by cmux pairing/attach deep links.
|
||||
/// One validated URL scheme carried by a cmux pairing or attach deep link.
|
||||
///
|
||||
/// All builds used to register and emit one scheme (`cmux-ios`), so scanning a
|
||||
/// beta/prod pairing QR with the iOS Camera app could open a *dev* build that
|
||||
/// happened to be installed (the OS picks an arbitrary app when several claim
|
||||
/// a scheme). The scheme is therefore channel-specific, mirroring how
|
||||
/// `MobileBuildType` splits channels:
|
||||
///
|
||||
/// - **Development (DEBUG)** builds — local Xcode and `reload.sh` tagged
|
||||
/// builds on both Mac and iPhone — register and emit ``development``.
|
||||
/// - **Release** builds (TestFlight beta and App Store prod) register and
|
||||
/// emit ``release``. Beta and prod share a scheme because they are the same
|
||||
/// compile configuration and a phone realistically has only one of them.
|
||||
///
|
||||
/// Emitters (the Mac building a pairing QR or attach URL) use ``current`` so a
|
||||
/// dev Mac pairs a dev phone and a release Mac pairs a release phone via the
|
||||
/// system camera. Parsers (the in-app scanner, manual paste, the root scene's
|
||||
/// deep-link gate) accept *any* pairing scheme via ``isPairingScheme(_:)`` /
|
||||
/// ``hasPairingScheme(_:)``, so cross-channel pairing still works when the
|
||||
/// user scans from inside the app.
|
||||
///
|
||||
/// The iOS app's registered scheme comes from `CMUX_IOS_URL_SCHEME` in
|
||||
/// `ios/Config/Shared.xcconfig` (dev) and `ios/Config/Release.xcconfig`
|
||||
/// (release); keep those values in sync with these constants.
|
||||
///
|
||||
/// lint:allow namespace-type — the build channel's URL scheme is a pure
|
||||
/// compile-time constant set with no per-instance state to inject; these
|
||||
/// scheme strings and the stateless pairing-scheme predicates are a genuine
|
||||
/// namespace, like the sanctioned FFI/seam holders.
|
||||
/// Every installed iOS bundle registers exactly one scheme derived from its
|
||||
/// complete bundle identifier. Parsers also accept the two historical shared
|
||||
/// schemes so an old QR remains scannable inside an already-open app, but new
|
||||
/// apps never register those shared schemes with iOS.
|
||||
public struct CmxPairingURLScheme {
|
||||
private init() {}
|
||||
/// The validated, lowercase URL scheme.
|
||||
public let rawValue: String
|
||||
|
||||
/// The scheme Release (TestFlight beta + App Store) builds register and emit.
|
||||
/// Creates the exact scheme registered by one installed iOS bundle.
|
||||
public init?(iOSBundleIdentifier: String?) {
|
||||
guard let namespace = MobileIOSAppNamespace(
|
||||
bundleIdentifier: iOSBundleIdentifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let scheme = namespace.pairingURLScheme.lowercased()
|
||||
guard Self.releaseSchemes.contains(scheme)
|
||||
|| scheme == Self.untaggedDevelopmentScheme
|
||||
|| scheme.hasPrefix(Self.developmentPrefix) else {
|
||||
return nil
|
||||
}
|
||||
rawValue = scheme
|
||||
}
|
||||
|
||||
/// Parses a classifiable bundle-specific or historical shared pairing
|
||||
/// scheme. Unknown release-like namespaces fail closed so account preflight
|
||||
/// cannot be bypassed by a syntactically valid but unclassified scheme.
|
||||
public init?(rawValue: String?) {
|
||||
guard let rawValue else { return nil }
|
||||
let normalized = rawValue.lowercased()
|
||||
if Self.all.contains(normalized) {
|
||||
self.rawValue = normalized
|
||||
return
|
||||
}
|
||||
let prefix = "cmux-ios-"
|
||||
guard normalized.hasPrefix(prefix),
|
||||
MobileIOSAppNamespace(
|
||||
bundleIdentifier: String(normalized.dropFirst(prefix.count))
|
||||
) != nil,
|
||||
Self.releaseSchemes.contains(normalized)
|
||||
|| normalized == Self.untaggedDevelopmentScheme
|
||||
|| normalized.hasPrefix(Self.developmentPrefix) else {
|
||||
return nil
|
||||
}
|
||||
self.rawValue = normalized
|
||||
}
|
||||
|
||||
/// Parses the scheme from a complete pairing URL.
|
||||
public init?(urlString: String) {
|
||||
guard urlString.contains("://"),
|
||||
let components = URLComponents(string: urlString),
|
||||
let scheme = CmxPairingURLScheme(rawValue: components.scheme) else {
|
||||
return nil
|
||||
}
|
||||
self = scheme
|
||||
}
|
||||
|
||||
/// Whether this scheme identifies a tagged iOS development build.
|
||||
public var isDevelopment: Bool {
|
||||
rawValue == Self.development
|
||||
|| rawValue == Self.untaggedDevelopmentScheme
|
||||
|| rawValue.hasPrefix(Self.developmentPrefix)
|
||||
}
|
||||
|
||||
/// Whether this scheme identifies an App Store or TestFlight build.
|
||||
public var isRelease: Bool {
|
||||
Self.releaseSchemes.contains(rawValue)
|
||||
}
|
||||
|
||||
/// Historical shared Release scheme. Parse-only in new iOS builds.
|
||||
public static let release = "cmux-ios"
|
||||
|
||||
/// The scheme development (DEBUG/tagged) builds register and emit.
|
||||
/// Historical shared development scheme. Parse-only in new iOS builds.
|
||||
public static let development = "cmux-ios-dev"
|
||||
|
||||
/// Every scheme any cmux build may emit; parsers accept all of them.
|
||||
/// Historical schemes retained for source compatibility and old QR tests.
|
||||
public static let all: [String] = [release, development]
|
||||
|
||||
/// The scheme this build emits in pairing QRs and attach URLs.
|
||||
public static var current: String {
|
||||
scheme(isDevelopmentBuild: isDevelopmentBuild)
|
||||
}
|
||||
private static let untaggedDevelopmentScheme = "cmux-ios-dev.cmux.ios"
|
||||
private static let developmentPrefix = "cmux-ios-dev.cmux.ios."
|
||||
|
||||
/// Pure channel-to-scheme mapping, injected with the compile flag so the
|
||||
/// derivation is testable from a single build configuration.
|
||||
public static func scheme(isDevelopmentBuild: Bool) -> String {
|
||||
isDevelopmentBuild ? development : release
|
||||
}
|
||||
|
||||
/// Whether `scheme` is a pairing scheme from any cmux channel.
|
||||
public static func isPairingScheme(_ scheme: String?) -> Bool {
|
||||
guard let scheme else { return false }
|
||||
return all.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame }
|
||||
}
|
||||
|
||||
/// Whether `rawValue` starts with any channel's pairing scheme (the
|
||||
/// scanner/paste-side prefix check, before URL parsing).
|
||||
public static func hasPairingScheme(_ rawValue: String) -> Bool {
|
||||
let lowercased = rawValue.lowercased()
|
||||
return all.contains { lowercased.hasPrefix($0 + "://") }
|
||||
}
|
||||
|
||||
private static var isDevelopmentBuild: Bool {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
private static let releaseSchemes: Set<String> = [
|
||||
release,
|
||||
"cmux-ios-com.cmux.app",
|
||||
"cmux-ios-dev.cmux.app.beta",
|
||||
"cmux-ios-dev.cmux.app.internal",
|
||||
"cmux-ios-dev.cmux.app.demo",
|
||||
]
|
||||
}
|
||||
|
||||
extension CmxPairingURLScheme: Equatable, Sendable {}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
/// Resolves the pairing target for the current process without global state.
|
||||
public struct CmxPairingURLSchemeResolver: Sendable {
|
||||
private let currentIOSBundleIdentifier: String?
|
||||
private let targetIOSBundleIdentifier: String?
|
||||
private let macInstanceTag: String?
|
||||
private let isDevelopmentBuild: Bool
|
||||
|
||||
/// Captures the current app identity and any explicit Mac pairing target.
|
||||
///
|
||||
/// A Mac may set `CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER` to any authoritative
|
||||
/// release-lane bundle id. Tagged Mac builds otherwise target their exact
|
||||
/// same-tag iOS bundle.
|
||||
public init(
|
||||
bundle: Bundle = .main,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) {
|
||||
currentIOSBundleIdentifier = bundle.bundleIdentifier
|
||||
targetIOSBundleIdentifier =
|
||||
environment["CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER"]
|
||||
macInstanceTag = environment["CMUX_TAG"]
|
||||
#if DEBUG
|
||||
isDevelopmentBuild = true
|
||||
#else
|
||||
isDevelopmentBuild = false
|
||||
#endif
|
||||
}
|
||||
|
||||
init(
|
||||
currentIOSBundleIdentifier: String?,
|
||||
targetIOSBundleIdentifier: String?,
|
||||
macInstanceTag: String?,
|
||||
isDevelopmentBuild: Bool
|
||||
) {
|
||||
self.currentIOSBundleIdentifier = currentIOSBundleIdentifier
|
||||
self.targetIOSBundleIdentifier = targetIOSBundleIdentifier
|
||||
self.macInstanceTag = macInstanceTag
|
||||
self.isDevelopmentBuild = isDevelopmentBuild
|
||||
}
|
||||
|
||||
/// The exact scheme this process should emit, or `nil` on invalid identity.
|
||||
public var resolved: CmxPairingURLScheme? {
|
||||
#if os(iOS)
|
||||
return CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: currentIOSBundleIdentifier
|
||||
)
|
||||
#else
|
||||
if let targetIOSBundleIdentifier {
|
||||
return CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: targetIOSBundleIdentifier
|
||||
)
|
||||
}
|
||||
if macInstanceTag == nil || macInstanceTag?.isEmpty == true {
|
||||
return CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: isDevelopmentBuild
|
||||
? "dev.cmux.ios"
|
||||
: "com.cmux.app"
|
||||
)
|
||||
}
|
||||
guard let namespace = MobileIOSAppNamespace(
|
||||
pairedMacInstanceTag: macInstanceTag
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: namespace.bundleIdentifier
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -213,6 +213,14 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name of a configured connection method.
|
||||
public func displayName(_ method: DiagnosticConnectionMethod) -> String {
|
||||
switch method {
|
||||
case .automatic: localized("diagnostics.connectionMethod.automatic", defaultValue: "Auto-Connect (Iroh)")
|
||||
case .tailscale: localized("diagnostics.connectionMethod.tailscale", defaultValue: "Tailscale Only")
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name of a selected network path.
|
||||
public func displayName(_ kind: DiagnosticPathKind) -> String {
|
||||
switch kind {
|
||||
@@ -681,6 +689,10 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
return Field(key: "style", value: toastStyleName(raw))
|
||||
case .toastDismissed:
|
||||
return Field(key: "reason", value: toastDismissReasonName(raw))
|
||||
case .connectionMethodPreferenceChanged, .connectionMethodConfigured:
|
||||
return Field(key: "method", value: connectionMethodName(raw))
|
||||
case .foregroundTransportSelected:
|
||||
return Field(key: "transport", value: transportName(raw))
|
||||
default:
|
||||
if Self.appEventKindsWithValuePayload.contains(kind) {
|
||||
return Field(key: "value", value: String(raw))
|
||||
@@ -743,6 +755,11 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
?? unknownPayloadName(raw)
|
||||
}
|
||||
|
||||
private func connectionMethodName(_ raw: Int) -> String {
|
||||
DiagnosticConnectionMethod(rawValue: raw).map(displayName)
|
||||
?? unknownPayloadName(raw)
|
||||
}
|
||||
|
||||
private func unknownPayloadName(_ raw: Int) -> String {
|
||||
localized(
|
||||
"diagnostics.unknown.payload",
|
||||
@@ -762,7 +779,6 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
.displayWorkspacePreviewLinesChanged,
|
||||
.terminalScrollbackRowsChanged,
|
||||
.telemetrySharingChanged,
|
||||
.connectionMethodPreferenceChanged,
|
||||
.notificationPreferenceChanged,
|
||||
.terminalDraftStateChanged,
|
||||
]
|
||||
@@ -1388,6 +1404,7 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
case "active_sessions": localized("diagnostics.field.activeSessions", defaultValue: "Active sessions")
|
||||
case "count": localized("diagnostics.field.count", defaultValue: "Count")
|
||||
case "value": localized("diagnostics.field.value", defaultValue: "Value")
|
||||
case "method": localized("diagnostics.field.method", defaultValue: "Method")
|
||||
case "action": localized("diagnostics.field.action", defaultValue: "Action")
|
||||
case "tab": localized("diagnostics.field.tab", defaultValue: "Tab")
|
||||
case "scope": localized("diagnostics.field.scope", defaultValue: "Scope")
|
||||
|
||||
@@ -767,6 +767,7 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
|
||||
case displayWorkspacePreviewLinesChanged = 528
|
||||
case terminalScrollbackRowsChanged = 529
|
||||
case telemetrySharingChanged = 530
|
||||
/// `c`: ``DiagnosticConnectionMethod`` the user switched to.
|
||||
case connectionMethodPreferenceChanged = 531
|
||||
/// Detail: ``DiagnosticAppEventDetail/toolbarConfigurationAction(_:)``.
|
||||
case customToolbarChanged = 532
|
||||
@@ -836,6 +837,22 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
|
||||
|
||||
// MARK: Appended persistence events
|
||||
case pairedMacStoreWriteStarted = 660
|
||||
|
||||
// MARK: Appended connection reporting events
|
||||
/// The configured connection method, recorded at composition and on every
|
||||
/// foreground so any shared report window states it even after the ring
|
||||
/// rolls past app launch. `c`: ``DiagnosticConnectionMethod``.
|
||||
case connectionMethodConfigured = 661
|
||||
/// The transport that actually carries the foreground connection, recorded
|
||||
/// on connect and on every active-route change. `c`: ``DiagnosticTransportKind``.
|
||||
case foregroundTransportSelected = 662
|
||||
}
|
||||
|
||||
/// The user's configured connection method, mirrored from the settings picker
|
||||
/// without account, address, or grant details.
|
||||
public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable {
|
||||
case automatic = 0
|
||||
case tailscale = 1
|
||||
}
|
||||
|
||||
/// High-level lifecycle state for one phone-controlled Simulator stream.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
/// Immutable isolation boundary for one installed cmux iOS application.
|
||||
///
|
||||
/// The complete bundle identifier is the namespace. Distribution labels and
|
||||
/// short development tags are deliberately not accepted here because either
|
||||
/// can alias another installed app.
|
||||
public struct MobileIOSAppNamespace: Equatable, Hashable, Sendable {
|
||||
/// Exact bundle identifier that owns this namespace.
|
||||
public let bundleIdentifier: String
|
||||
|
||||
/// Creates a namespace from one complete, validated iOS bundle identifier.
|
||||
public init?(bundleIdentifier: String?) {
|
||||
guard let bundleIdentifier else { return nil }
|
||||
let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
trimmed == bundleIdentifier,
|
||||
trimmed == trimmed.lowercased(),
|
||||
trimmed.count <= 255,
|
||||
trimmed.contains("."),
|
||||
trimmed.range(
|
||||
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.bundleIdentifier = trimmed
|
||||
}
|
||||
|
||||
/// Resolves the exact iOS bundle paired with one Mac app instance.
|
||||
///
|
||||
/// Tagged Mac builds pair with the same tagged iOS development bundle.
|
||||
/// The stable Mac instance pairs with the public App Store bundle. Invalid
|
||||
/// tags fail closed instead of aliasing another installed iOS app.
|
||||
public init?(pairedMacInstanceTag instanceTag: String?) {
|
||||
let tag = instanceTag?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if let instanceTag, instanceTag != tag {
|
||||
return nil
|
||||
}
|
||||
let bundleIdentifier = if tag.isEmpty || tag == "default" {
|
||||
"com.cmux.app"
|
||||
} else {
|
||||
"dev.cmux.ios.\(tag)"
|
||||
}
|
||||
self.init(bundleIdentifier: bundleIdentifier)
|
||||
}
|
||||
|
||||
/// The exact Keychain access group this app must claim after signing.
|
||||
public func keychainAccessGroup(teamIdentifier: String) -> String {
|
||||
"\(teamIdentifier).\(bundleIdentifier)"
|
||||
}
|
||||
|
||||
/// A Keychain service that cannot collide with another installed bundle.
|
||||
public func keychainService(base: String) -> String {
|
||||
"\(base).\(bundleIdentifier)"
|
||||
}
|
||||
|
||||
/// The only pairing URL scheme this bundle registers with iOS.
|
||||
public var pairingURLScheme: String {
|
||||
"cmux-ios-\(bundleIdentifier)"
|
||||
}
|
||||
|
||||
/// Opaque server partition for data restored to this exact app bundle.
|
||||
public var serverScope: String {
|
||||
let encoded = Data(bundleIdentifier.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
return "ios:v3:\(encoded)"
|
||||
}
|
||||
|
||||
/// The only legacy backup collection that can be attributed to this bundle.
|
||||
///
|
||||
/// The App Store app owns the former unscoped release collection. Tagged
|
||||
/// development bundles own their same-tag v2 collection. Beta, Internal,
|
||||
/// and Demo intentionally adopt nothing because their old unscoped records
|
||||
/// cannot be attributed without risking cross-build restore.
|
||||
public var legacyBackupScope: MobileIOSLegacyBackupScope? {
|
||||
if bundleIdentifier == "com.cmux.app" {
|
||||
return .unscoped
|
||||
}
|
||||
let prefix = "dev.cmux.ios."
|
||||
guard bundleIdentifier.hasPrefix(prefix),
|
||||
let buildScope = MobileIOSBuildScope(
|
||||
String(bundleIdentifier.dropFirst(prefix.count))
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return .scoped(buildScope.serializedScope)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/// One unambiguous pre-v3 backup collection eligible for one-time adoption.
|
||||
public enum MobileIOSLegacyBackupScope: Equatable, Sendable {
|
||||
/// The former App Store collection that did not carry a scope header.
|
||||
case unscoped
|
||||
|
||||
/// A former development collection identified by its exact v2 scope.
|
||||
case scoped(String)
|
||||
|
||||
/// The legacy request header value, or `nil` for the unscoped collection.
|
||||
public var headerValue: String? {
|
||||
switch self {
|
||||
case .unscoped: nil
|
||||
case .scoped(let value): value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,14 +116,15 @@ public struct MobileSyncPairingPayload: Equatable, Sendable, Codable {
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(self)
|
||||
let payload = Self.base64URLEncode(data)
|
||||
guard let url = URL(string: "\(CmxPairingURLScheme.current)://pair?v=\(version)&payload=\(payload)") else {
|
||||
guard let scheme = CmxPairingURLSchemeResolver().resolved?.rawValue,
|
||||
let url = URL(string: "\(scheme)://pair?v=\(version)&payload=\(payload)") else {
|
||||
throw MobileSyncPairingPayloadError.invalidURL
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
public static func decodeURL(_ url: URL, now: Date = Date()) throws -> MobileSyncPairingPayload {
|
||||
guard CmxPairingURLScheme.isPairingScheme(url.scheme),
|
||||
guard CmxPairingURLScheme(rawValue: url.scheme) != nil,
|
||||
url.host == "pair",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let encodedPayload = components.queryItems?.first(where: { $0.name == "payload" })?.value,
|
||||
|
||||
@@ -103,6 +103,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.connectionMethod.automatic": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auto-Connect (Iroh)"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "自動接続 (Iroh)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.connectionMethod.tailscale": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tailscale Only"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tailscaleのみ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.count.bytes": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -5845,6 +5879,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.field.method": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Method"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "接続方法"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.field.publicPaths": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
||||
+7
-3
@@ -4,6 +4,9 @@ import Testing
|
||||
|
||||
private let compactIrohQRCoder = CmxAttachTicketCompactCoder()
|
||||
private let compactIrohQREndpointID = String(repeating: "c", count: 64)
|
||||
private let compactIrohQRTarget = CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: "dev.cmux.app.beta"
|
||||
)!
|
||||
|
||||
private func compactIrohQRExpiry() -> Date {
|
||||
Date(timeIntervalSince1970: 4_000_000_000)
|
||||
@@ -88,11 +91,12 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
|
||||
#expect(hints.isEmpty)
|
||||
let pairingURL = try #require(CmxPairingQRCode().encode(
|
||||
ticket,
|
||||
routeDisclosureMode: .irohIdentityOnly
|
||||
routeDisclosureMode: .irohIdentityOnly,
|
||||
pairingURLScheme: compactIrohQRTarget
|
||||
))
|
||||
#expect(
|
||||
pairingURL
|
||||
== "\(CmxPairingURLScheme.current)://attach?v=3&i=\(compactIrohQREndpointID)"
|
||||
== "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)"
|
||||
)
|
||||
#expect(!pairingURL.contains("payload="))
|
||||
#expect(!pairingURL.contains("mac-1"))
|
||||
@@ -130,7 +134,7 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
let beforeURL =
|
||||
"\(CmxPairingURLScheme.current)://attach?v=1&payload=\(compactBase64)"
|
||||
"\(compactIrohQRTarget.rawValue)://attach?v=1&payload=\(compactBase64)"
|
||||
let beforeImage = try #require(CmxPairingQRBitmap().makeImage(payload: beforeURL))
|
||||
let afterImage = try #require(CmxPairingQRBitmap().makeImage(payload: pairingURL))
|
||||
let quietZone = CmxPairingQRBitmap.quietZoneModules * 2
|
||||
|
||||
+10
-5
@@ -56,10 +56,12 @@ import Testing
|
||||
try tailscaleRoute(index: 0, host: "100.64.0.5"),
|
||||
])
|
||||
let url = try #require(encodeLegacy(ticket))
|
||||
// The scheme is channel-specific: a release Mac emits cmux-ios, a dev
|
||||
// Mac emits cmux-ios-dev, so the system camera routes each channel's QR
|
||||
// to its build. The rest of the URL is identical across channels.
|
||||
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
|
||||
// The scheme is bundle-specific, so the system camera routes the QR to
|
||||
// the matching installed iOS build. The rest of the URL is unchanged.
|
||||
let scheme = try #require(
|
||||
CmxPairingURLSchemeResolver().resolved?.rawValue
|
||||
)
|
||||
#expect(url == "\(scheme)://attach?v=2&r=100.64.0.5:58465")
|
||||
|
||||
let decoded = try CmxPairingQRCode().decode(try components(url))
|
||||
#expect(decoded.routes == ticket.routes)
|
||||
@@ -148,7 +150,10 @@ import Testing
|
||||
let ticket = try pairingTicket(routes: [loopback, tailscale])
|
||||
|
||||
let url = try #require(encodeLegacy(ticket))
|
||||
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
|
||||
let scheme = try #require(
|
||||
CmxPairingURLSchemeResolver().resolved?.rawValue
|
||||
)
|
||||
#expect(url == "\(scheme)://attach?v=2&r=100.64.0.5:58465")
|
||||
let decoded = try CmxPairingQRCode().decode(try components(url))
|
||||
#expect(decoded.routes == [tailscale])
|
||||
}
|
||||
|
||||
+137
-36
@@ -2,52 +2,153 @@ import Foundation
|
||||
import Testing
|
||||
@testable import CMUXMobileCore
|
||||
|
||||
/// The pairing/attach URL scheme is channel-specific so the system Camera app
|
||||
/// can never hand a beta/prod QR to a dev build that also claimed the scheme:
|
||||
/// dev (Debug/tagged) builds register + emit `cmux-ios-dev`, Release (beta +
|
||||
/// prod) registers + emits `cmux-ios`. Parsers accept every channel's scheme so
|
||||
/// cross-channel pairing still works from inside the app.
|
||||
/// Every installed iOS bundle owns one pairing URL scheme. Parsers accept only
|
||||
/// schemes whose release or development lane can be classified for account
|
||||
/// preflight, while installed builds still register their exact bundle scheme.
|
||||
@Suite struct CmxPairingURLSchemeTests {
|
||||
@Test func developmentBuildsEmitDevScheme() {
|
||||
#expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: true) == "cmux-ios-dev")
|
||||
@Test func everyInstalledBundleEmitsItsOwnScheme() {
|
||||
#expect(
|
||||
CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: "dev.cmux.app.internal"
|
||||
)?.rawValue == "cmux-ios-dev.cmux.app.internal"
|
||||
)
|
||||
#expect(
|
||||
CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: "dev.cmux.app.demo"
|
||||
)?.rawValue == "cmux-ios-dev.cmux.app.demo"
|
||||
)
|
||||
#expect(
|
||||
CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: "dev.cmux.ios.feature-a"
|
||||
)?.rawValue == "cmux-ios-dev.cmux.ios.feature-a"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func releaseBuildsEmitReleaseScheme() {
|
||||
#expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: false) == "cmux-ios")
|
||||
}
|
||||
|
||||
@Test func currentMatchesThisBuildsCompileChannel() {
|
||||
// `current` derives from the DEBUG compile flag, so a Debug test run
|
||||
// emits the dev scheme and a Release test run emits the release scheme.
|
||||
#if DEBUG
|
||||
#expect(CmxPairingURLScheme.current == "cmux-ios-dev")
|
||||
#else
|
||||
#expect(CmxPairingURLScheme.current == "cmux-ios")
|
||||
@Test func invalidIdentityDoesNotFallBackToAnotherApp() {
|
||||
#expect(CmxPairingURLScheme(iOSBundleIdentifier: "") == nil)
|
||||
#expect(CmxPairingURLScheme(iOSBundleIdentifier: "invalid bundle") == nil)
|
||||
#expect(
|
||||
CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: "dev.cmux.app.unrecognized"
|
||||
) == nil
|
||||
)
|
||||
#if !os(iOS)
|
||||
#expect(
|
||||
CmxPairingURLSchemeResolver(
|
||||
currentIOSBundleIdentifier: nil,
|
||||
targetIOSBundleIdentifier: nil,
|
||||
macInstanceTag: "invalid tag",
|
||||
isDevelopmentBuild: true
|
||||
).resolved == nil
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test func parserAcceptsEverySchemeRegardlessOfChannel() {
|
||||
// Both channels' schemes parse, case-insensitively, so a phone on
|
||||
// either channel can pair from a QR minted by either channel's Mac.
|
||||
#expect(CmxPairingURLScheme.isPairingScheme("cmux-ios"))
|
||||
#expect(CmxPairingURLScheme.isPairingScheme("cmux-ios-dev"))
|
||||
#expect(CmxPairingURLScheme.isPairingScheme("CMUX-IOS-DEV"))
|
||||
#if !os(iOS)
|
||||
@Test func untaggedDebugMacTargetsDefaultDebugIOSBundle() {
|
||||
#if DEBUG
|
||||
#expect(
|
||||
CmxPairingURLSchemeResolver(
|
||||
currentIOSBundleIdentifier: nil,
|
||||
targetIOSBundleIdentifier: nil,
|
||||
macInstanceTag: nil,
|
||||
isDevelopmentBuild: true
|
||||
).resolved?.rawValue == "cmux-ios-dev.cmux.ios"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test func untaggedMacBuildChannelsResolveDistinctExactBundles() {
|
||||
#expect(
|
||||
CmxPairingURLSchemeResolver(
|
||||
currentIOSBundleIdentifier: nil,
|
||||
targetIOSBundleIdentifier: nil,
|
||||
macInstanceTag: nil,
|
||||
isDevelopmentBuild: true
|
||||
).resolved?.rawValue == "cmux-ios-dev.cmux.ios"
|
||||
)
|
||||
#expect(
|
||||
CmxPairingURLSchemeResolver(
|
||||
currentIOSBundleIdentifier: nil,
|
||||
targetIOSBundleIdentifier: nil,
|
||||
macInstanceTag: nil,
|
||||
isDevelopmentBuild: false
|
||||
).resolved?.rawValue == "cmux-ios-com.cmux.app"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func macCanExplicitlyTargetEveryReleaseLane() {
|
||||
for bundleIdentifier in [
|
||||
"com.cmux.app",
|
||||
"dev.cmux.app.beta",
|
||||
"dev.cmux.app.internal",
|
||||
"dev.cmux.app.demo",
|
||||
] {
|
||||
#expect(
|
||||
CmxPairingURLSchemeResolver(
|
||||
currentIOSBundleIdentifier: nil,
|
||||
targetIOSBundleIdentifier: bundleIdentifier,
|
||||
macInstanceTag: nil,
|
||||
isDevelopmentBuild: false
|
||||
).resolved?.rawValue
|
||||
== "cmux-ios-\(bundleIdentifier)"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test func parserAcceptsNamespacedSchemes() {
|
||||
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.internal") != nil)
|
||||
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.demo") != nil)
|
||||
#expect(CmxPairingURLScheme(rawValue: "CMUX-IOS-DEV.CMUX.IOS.FEATURE-A") != nil)
|
||||
// Old QR codes remain scannable inside an already-open app. New builds
|
||||
// do not register these shared schemes with iOS.
|
||||
#expect(CmxPairingURLScheme(rawValue: "cmux-ios") != nil)
|
||||
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev") != nil)
|
||||
}
|
||||
|
||||
@Test func parserRejectsForeignSchemes() {
|
||||
#expect(!CmxPairingURLScheme.isPairingScheme(nil))
|
||||
#expect(!CmxPairingURLScheme.isPairingScheme(""))
|
||||
#expect(!CmxPairingURLScheme.isPairingScheme("https"))
|
||||
// A different cmux scheme that is not a pairing scheme must not match.
|
||||
#expect(!CmxPairingURLScheme.isPairingScheme("cmux-ios-staging"))
|
||||
#expect(CmxPairingURLScheme(rawValue: nil) == nil)
|
||||
#expect(CmxPairingURLScheme(rawValue: "") == nil)
|
||||
#expect(CmxPairingURLScheme(rawValue: "https") == nil)
|
||||
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-*") == nil)
|
||||
}
|
||||
|
||||
@Test func prefixCheckAcceptsBothChannelsAndRejectsOthers() {
|
||||
#expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios://attach?v=2&r=100.64.0.5:58465"))
|
||||
#expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios-dev://attach?v=2&r=100.64.0.5:58465"))
|
||||
#expect(CmxPairingURLScheme.hasPairingScheme("CMUX-IOS://attach?v=2"))
|
||||
#expect(!CmxPairingURLScheme.hasPairingScheme("https://example.com"))
|
||||
// A bare scheme name without "://" is not a deep link.
|
||||
#expect(!CmxPairingURLScheme.hasPairingScheme("cmux-ios"))
|
||||
@Test func channelClassificationRecognizesOnlyAuthoritativeLanes() throws {
|
||||
for bundleIdentifier in [
|
||||
"com.cmux.app",
|
||||
"dev.cmux.app.beta",
|
||||
"dev.cmux.app.internal",
|
||||
"dev.cmux.app.demo",
|
||||
] {
|
||||
let scheme = try #require(
|
||||
CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: bundleIdentifier
|
||||
)
|
||||
)
|
||||
#expect(scheme.isRelease)
|
||||
#expect(!scheme.isDevelopment)
|
||||
}
|
||||
let development = try #require(
|
||||
CmxPairingURLScheme(
|
||||
iOSBundleIdentifier: "dev.cmux.ios.feature-a"
|
||||
)
|
||||
)
|
||||
#expect(development.isDevelopment)
|
||||
#expect(!development.isRelease)
|
||||
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.unrecognized") == nil)
|
||||
}
|
||||
|
||||
@Test func prefixCheckAcceptsNamespacedSchemesAndRejectsOthers() {
|
||||
#expect(CmxPairingURLScheme(urlString:
|
||||
"cmux-ios-dev.cmux.app.internal://attach?v=2&r=100.64.0.5:58465"
|
||||
) != nil)
|
||||
#expect(CmxPairingURLScheme(urlString:
|
||||
"CMUX-IOS-DEV.CMUX.IOS.FEATURE-A://attach?v=2"
|
||||
) != nil)
|
||||
#expect(CmxPairingURLScheme(urlString: "cmux-ios://attach?v=2") != nil)
|
||||
#expect(CmxPairingURLScheme(urlString: "cmux-ios-dev://attach?v=2") != nil)
|
||||
#expect(CmxPairingURLScheme(urlString: "https://example.com") == nil)
|
||||
#expect(CmxPairingURLScheme(urlString: "cmux-ios-dev.cmux.app.internal") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
+42
@@ -595,6 +595,48 @@ import Testing
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared report must state the configured connection method and the
|
||||
/// transport actually carrying the foreground connection in words, so a
|
||||
/// support thread never needs a Settings screenshot to interpret dials.
|
||||
@Test func describesConnectionMethodAndForegroundTransport() {
|
||||
let configured = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .appFeatureAction,
|
||||
tNanos: 1,
|
||||
a: DiagnosticAppEventKind.connectionMethodConfigured.rawValue,
|
||||
c: DiagnosticConnectionMethod.tailscale.rawValue
|
||||
))
|
||||
#expect(configured.fields == [
|
||||
.init(key: "operation", value: "connectionMethodConfigured"),
|
||||
.init(key: "method", value: "Tailscale Only"),
|
||||
])
|
||||
#expect(englishPresentation.summary(configured)
|
||||
.contains("Method: Tailscale Only"))
|
||||
|
||||
let changed = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .appFeatureAction,
|
||||
tNanos: 1,
|
||||
a: DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue,
|
||||
c: DiagnosticConnectionMethod.automatic.rawValue
|
||||
))
|
||||
#expect(changed.fields == [
|
||||
.init(key: "operation", value: "connectionMethodPreferenceChanged"),
|
||||
.init(key: "method", value: "Auto-Connect (Iroh)"),
|
||||
])
|
||||
|
||||
let transport = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .appFeatureAction,
|
||||
tNanos: 1,
|
||||
a: DiagnosticAppEventKind.foregroundTransportSelected.rawValue,
|
||||
c: DiagnosticTransportKind.tailscale.rawValue
|
||||
))
|
||||
#expect(transport.fields == [
|
||||
.init(key: "operation", value: "foregroundTransportSelected"),
|
||||
.init(key: "transport", value: "Tailscale"),
|
||||
])
|
||||
#expect(englishPresentation.summary(transport)
|
||||
.contains("Transport: Tailscale"))
|
||||
}
|
||||
|
||||
@Test func extractsFailureAndTransportKinds() {
|
||||
let event = DiagnosticEvent(
|
||||
code: .endpointFailed,
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CMUXMobileCore
|
||||
|
||||
@Suite struct MobileIOSAppNamespaceTests {
|
||||
@Test(
|
||||
arguments: [
|
||||
"com.cmux.app",
|
||||
"dev.cmux.app.beta",
|
||||
"dev.cmux.app.internal",
|
||||
"dev.cmux.app.demo",
|
||||
"dev.cmux.ios.feature-a",
|
||||
"dev.cmux.ios.feature-b",
|
||||
]
|
||||
)
|
||||
func fullBundleIdentifierOwnsEveryNamespace(bundleIdentifier: String) throws {
|
||||
let namespace = try #require(
|
||||
MobileIOSAppNamespace(bundleIdentifier: bundleIdentifier)
|
||||
)
|
||||
|
||||
#expect(namespace.bundleIdentifier == bundleIdentifier)
|
||||
#expect(
|
||||
namespace.keychainService(base: "com.cmuxterm.iroh.identity")
|
||||
== "com.cmuxterm.iroh.identity.\(bundleIdentifier)"
|
||||
)
|
||||
#expect(
|
||||
namespace.keychainAccessGroup(teamIdentifier: "7WLXT3NR37")
|
||||
== "7WLXT3NR37.\(bundleIdentifier)"
|
||||
)
|
||||
#expect(
|
||||
namespace.pairingURLScheme
|
||||
== "cmux-ios-\(bundleIdentifier)"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func appTypesAndDevTagsNeverSharePersistentOrPairingScopes() throws {
|
||||
let bundleIdentifiers = [
|
||||
"com.cmux.app",
|
||||
"dev.cmux.app.beta",
|
||||
"dev.cmux.app.internal",
|
||||
"dev.cmux.app.demo",
|
||||
"dev.cmux.ios.feature-a",
|
||||
"dev.cmux.ios.feature-b",
|
||||
]
|
||||
let namespaces = try bundleIdentifiers.map {
|
||||
try #require(MobileIOSAppNamespace(bundleIdentifier: $0))
|
||||
}
|
||||
|
||||
#expect(Set(namespaces.map(\.serverScope)).count == namespaces.count)
|
||||
#expect(Set(namespaces.map(\.pairingURLScheme)).count == namespaces.count)
|
||||
#expect(
|
||||
Set(
|
||||
namespaces.map {
|
||||
$0.keychainService(base: "com.cmuxterm.iroh.identity")
|
||||
}
|
||||
).count == namespaces.count
|
||||
)
|
||||
}
|
||||
|
||||
@Test func rejectsMissingOrUnsafeBundleIdentifiers() {
|
||||
#expect(MobileIOSAppNamespace(bundleIdentifier: nil) == nil)
|
||||
#expect(MobileIOSAppNamespace(bundleIdentifier: "") == nil)
|
||||
#expect(MobileIOSAppNamespace(bundleIdentifier: "Dev.cmux.ios.feature-a") == nil)
|
||||
#expect(MobileIOSAppNamespace(bundleIdentifier: "dev.cmux.ios.*") == nil)
|
||||
#expect(MobileIOSAppNamespace(bundleIdentifier: "dev cmux ios") == nil)
|
||||
}
|
||||
|
||||
@Test func macInstanceTagResolvesOneExactIOSBundle() {
|
||||
#expect(
|
||||
MobileIOSAppNamespace(pairedMacInstanceTag: "feature-a")?.bundleIdentifier
|
||||
== "dev.cmux.ios.feature-a"
|
||||
)
|
||||
#expect(
|
||||
MobileIOSAppNamespace(pairedMacInstanceTag: "default")?.bundleIdentifier
|
||||
== "com.cmux.app"
|
||||
)
|
||||
#expect(MobileIOSAppNamespace(pairedMacInstanceTag: "invalid tag") == nil)
|
||||
#expect(MobileIOSAppNamespace(pairedMacInstanceTag: " feature-a ") == nil)
|
||||
}
|
||||
|
||||
@Test func legacyBackupAdoptionIsLimitedToUnambiguousOwners() throws {
|
||||
let appStore = try #require(
|
||||
MobileIOSAppNamespace(bundleIdentifier: "com.cmux.app")
|
||||
)
|
||||
let tagged = try #require(
|
||||
MobileIOSAppNamespace(bundleIdentifier: "dev.cmux.ios.feature-a")
|
||||
)
|
||||
#expect(appStore.legacyBackupScope == .unscoped)
|
||||
#expect(
|
||||
tagged.legacyBackupScope
|
||||
== .scoped("ios:v2:ZmVhdHVyZS1h")
|
||||
)
|
||||
for bundleIdentifier in [
|
||||
"dev.cmux.app.beta",
|
||||
"dev.cmux.app.internal",
|
||||
"dev.cmux.app.demo",
|
||||
] {
|
||||
#expect(
|
||||
MobileIOSAppNamespace(
|
||||
bundleIdentifier: bundleIdentifier
|
||||
)?.legacyBackupScope == nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -23,12 +23,14 @@ public struct StackAuthClient: AuthClient {
|
||||
/// - config: The resolved auth configuration (project id + publishable key).
|
||||
/// - tokenStore: Where Stack persists tokens. Pass `.memory` for the
|
||||
/// simulator DEBUG flow and `.keychain` for real devices/release.
|
||||
/// - oauthBrowserSessionPrivacy: Whether OAuth may reuse Safari cookies.
|
||||
/// - baseURL: Stack API origin. Defaults to Stack's production API.
|
||||
/// - noAutomaticPrefetch: Disables Stack project prefetch when the host
|
||||
/// owns startup sequencing.
|
||||
public init(
|
||||
config: AuthConfig,
|
||||
tokenStore: TokenStoreInit,
|
||||
oauthBrowserSessionPrivacy: OAuthBrowserSessionPrivacy = .shared,
|
||||
baseURL: String = "https://api.stack-auth.com",
|
||||
noAutomaticPrefetch: Bool = false
|
||||
) {
|
||||
@@ -38,7 +40,8 @@ public struct StackAuthClient: AuthClient {
|
||||
publishableClientKey: config.stack.publishableClientKey,
|
||||
baseUrl: baseURL,
|
||||
tokenStore: tokenStore,
|
||||
noAutomaticPrefetch: noAutomaticPrefetch
|
||||
noAutomaticPrefetch: noAutomaticPrefetch,
|
||||
oauthBrowserSessionPrivacy: oauthBrowserSessionPrivacy
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@ enum AuthPhase: String, Sendable, Hashable {
|
||||
case listTeams = "list_teams"
|
||||
case postSignIn = "post_sign_in"
|
||||
case accountDeletion = "account_deletion"
|
||||
case pushRegistrationSession = "push_registration_session"
|
||||
case pushUnregistrationSession = "push_unregistration_session"
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
struct PendingUnregister: Codable, Hashable, Sendable {
|
||||
let tokenHex: String
|
||||
let accountID: String
|
||||
}
|
||||
|
||||
/// Indexed durable storage for privacy-sensitive push cleanup obligations.
|
||||
///
|
||||
/// UserDefaults retains its domain in memory and is a poor fit for a queue that
|
||||
/// can outlive several accounts. SQLite keeps the working set bounded: retries
|
||||
/// read at most their requested batch, token reassignment uses an indexed
|
||||
/// delete, and the uniqueness constraint compacts duplicate obligations.
|
||||
final class PendingUnregisterStore {
|
||||
private var database: OpaquePointer?
|
||||
|
||||
init(databaseURL: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: databaseURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
var opened: OpaquePointer?
|
||||
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
|
||||
guard sqlite3_open_v2(databaseURL.path, &opened, flags, nil) == SQLITE_OK,
|
||||
let opened else {
|
||||
if let opened { sqlite3_close_v2(opened) }
|
||||
throw PendingUnregisterStoreError.openFailed
|
||||
}
|
||||
database = opened
|
||||
do {
|
||||
try execute("PRAGMA journal_mode=WAL;")
|
||||
try execute("PRAGMA synchronous=FULL;")
|
||||
try execute("PRAGMA auto_vacuum=INCREMENTAL;")
|
||||
try execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pending_unregister (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hex TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
UNIQUE(token_hex, account_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
try execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS pending_unregister_account_sequence
|
||||
ON pending_unregister(account_id, sequence);
|
||||
"""
|
||||
)
|
||||
try execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS pending_unregister_token
|
||||
ON pending_unregister(token_hex);
|
||||
"""
|
||||
)
|
||||
} catch {
|
||||
sqlite3_close_v2(opened)
|
||||
database = nil
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let database {
|
||||
sqlite3_close_v2(database)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func insert(_ entry: PendingUnregister) -> Bool {
|
||||
guard let statement = prepare(
|
||||
"""
|
||||
INSERT OR IGNORE INTO pending_unregister(token_hex, account_id)
|
||||
VALUES (?, ?);
|
||||
"""
|
||||
) else { return false }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard bind(entry.tokenHex, to: statement, at: 1),
|
||||
bind(entry.accountID, to: statement, at: 2),
|
||||
sqlite3_step(statement) == SQLITE_DONE else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Inserts a legacy queue in one durable transaction. This keeps launch
|
||||
/// migration linear and pays at most one FULL-synchronous commit.
|
||||
@discardableResult
|
||||
func insertAll(_ entries: [PendingUnregister]) -> Bool {
|
||||
guard !entries.isEmpty else { return true }
|
||||
guard sqlite3_exec(
|
||||
database,
|
||||
"BEGIN IMMEDIATE;",
|
||||
nil,
|
||||
nil,
|
||||
nil
|
||||
) == SQLITE_OK else { return false }
|
||||
var committed = false
|
||||
defer {
|
||||
if !committed {
|
||||
_ = sqlite3_exec(database, "ROLLBACK;", nil, nil, nil)
|
||||
}
|
||||
}
|
||||
guard let statement = prepare(
|
||||
"""
|
||||
INSERT OR IGNORE INTO pending_unregister(token_hex, account_id)
|
||||
VALUES (?, ?);
|
||||
"""
|
||||
) else { return false }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
for entry in entries {
|
||||
sqlite3_reset(statement)
|
||||
sqlite3_clear_bindings(statement)
|
||||
guard bind(entry.tokenHex, to: statement, at: 1),
|
||||
bind(entry.accountID, to: statement, at: 2),
|
||||
sqlite3_step(statement) == SQLITE_DONE else { return false }
|
||||
}
|
||||
guard sqlite3_exec(database, "COMMIT;", nil, nil, nil) == SQLITE_OK else {
|
||||
return false
|
||||
}
|
||||
committed = true
|
||||
return true
|
||||
}
|
||||
|
||||
func batch(accountID: String, limit: Int) -> [PendingUnregister] {
|
||||
guard limit > 0, let statement = prepare(
|
||||
"""
|
||||
SELECT token_hex, account_id
|
||||
FROM pending_unregister
|
||||
WHERE account_id = ?
|
||||
ORDER BY sequence
|
||||
LIMIT ?;
|
||||
"""
|
||||
) else { return [] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard bind(accountID, to: statement, at: 1),
|
||||
sqlite3_bind_int64(statement, 2, Int64(limit)) == SQLITE_OK else {
|
||||
return []
|
||||
}
|
||||
var result: [PendingUnregister] = []
|
||||
result.reserveCapacity(limit)
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let token = sqlite3_column_text(statement, 0),
|
||||
let account = sqlite3_column_text(statement, 1) else {
|
||||
continue
|
||||
}
|
||||
result.append(PendingUnregister(
|
||||
tokenHex: String(cString: token),
|
||||
accountID: String(cString: account)
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(tokenHex: String, accountID: String) -> Bool {
|
||||
guard let statement = prepare(
|
||||
"""
|
||||
DELETE FROM pending_unregister
|
||||
WHERE token_hex = ? AND account_id = ?;
|
||||
"""
|
||||
) else { return false }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard bind(tokenHex, to: statement, at: 1),
|
||||
bind(accountID, to: statement, at: 2),
|
||||
sqlite3_step(statement) == SQLITE_DONE else { return false }
|
||||
compactFreedPages()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeAll(tokenHex: String) -> Bool {
|
||||
guard let statement = prepare(
|
||||
"DELETE FROM pending_unregister WHERE token_hex = ?;"
|
||||
) else { return false }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard bind(tokenHex, to: statement, at: 1),
|
||||
sqlite3_step(statement) == SQLITE_DONE else { return false }
|
||||
compactFreedPages()
|
||||
return true
|
||||
}
|
||||
|
||||
var hasEntries: Bool {
|
||||
guard let statement = prepare(
|
||||
"SELECT 1 FROM pending_unregister LIMIT 1;"
|
||||
) else { return false }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
return sqlite3_step(statement) == SQLITE_ROW
|
||||
}
|
||||
|
||||
private func compactFreedPages() {
|
||||
_ = sqlite3_exec(database, "PRAGMA incremental_vacuum(4);", nil, nil, nil)
|
||||
}
|
||||
|
||||
private func execute(_ sql: String) throws {
|
||||
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
||||
throw PendingUnregisterStoreError.schemaFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func prepare(_ sql: String) -> OpaquePointer? {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
return nil
|
||||
}
|
||||
return statement
|
||||
}
|
||||
|
||||
private func bind(
|
||||
_ value: String,
|
||||
to statement: OpaquePointer,
|
||||
at index: Int32
|
||||
) -> Bool {
|
||||
value.withCString { pointer in
|
||||
sqlite3_bind_text(
|
||||
statement,
|
||||
index,
|
||||
pointer,
|
||||
-1,
|
||||
unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
) == SQLITE_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum PendingUnregisterStoreError: Error {
|
||||
case openFailed
|
||||
case schemaFailed
|
||||
}
|
||||
@@ -21,6 +21,17 @@ public protocol PushRegistering: Sendable {
|
||||
/// removing it server-side on disable.
|
||||
func setEnabled(_ enabled: Bool) async
|
||||
|
||||
/// Commits a coordinator-owned preference in generation order. Opt-out
|
||||
/// cleanup runs in an app-owned worker and this call awaits its bounded
|
||||
/// attempt without transferring cancellation ownership. Enabling is
|
||||
/// persisted here but must wait for ``reconcileEnabledIntent(generation:)``
|
||||
/// after iOS notification authorization succeeds.
|
||||
func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async
|
||||
|
||||
/// Starts backend registration for the current enabled intent after the
|
||||
/// coordinator has confirmed that iOS permits notification delivery.
|
||||
func reconcileEnabledIntent(generation: UInt64) async
|
||||
|
||||
/// Cache and (when opted in) upload a freshly registered APNs device token.
|
||||
func register(deviceToken: Data) async
|
||||
|
||||
|
||||
+576
-100
@@ -21,12 +21,32 @@ public actor PushRegistrationService: PushRegistering {
|
||||
private let bundleID: String
|
||||
private let apnsEnvironment: String
|
||||
private let defaults: UserDefaults
|
||||
private let pendingUnregisterStoreURL: URL
|
||||
private var pendingUnregisterStore: PendingUnregisterStore?
|
||||
private let session: URLSession
|
||||
private let retryDelays: [Duration]
|
||||
private let retryJitter: @Sendable (ClosedRange<Double>) -> Double
|
||||
private let retrySleep: @Sendable (Duration) async throws -> Void
|
||||
private let sessionSnapshotTimeout: Duration
|
||||
private let sessionSnapshotClock: any Clock<Duration>
|
||||
private let sessionSnapshotTimeoutRegistry = AuthPhaseTimeoutRegistry()
|
||||
private let authLog = AuthDebugLog()
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var unregisterDrainTask: Task<Void, Never>?
|
||||
/// App-lifetime, direction-owned workers let a privacy-sensitive opt-out
|
||||
/// proceed while an older registration request is still in flight. One
|
||||
/// stored task per direction bounds concurrency during rapid toggling.
|
||||
private var enableIntentReconciliationTask: Task<Void, Never>?
|
||||
private var disableIntentReconciliationTask: Task<Void, Never>?
|
||||
private var enableIntentReconciliationRequested = false
|
||||
private var disableIntentReconciliationRequested = false
|
||||
private var coordinatorIntentGeneration: UInt64 = 0
|
||||
private var coordinatorIntentEnabled: Bool?
|
||||
private var coordinatorIntentReconciledGeneration: UInt64?
|
||||
private var pendingUnregisterRecoveryTask: Task<Void, Never>?
|
||||
private var pendingUnregisterRecoveryGeneration: UUID?
|
||||
private var unregisterDrainPreferenceGeneration: UUID?
|
||||
|
||||
// Actor reentrancy lets a second lifecycle callback enter while the first
|
||||
// POST is suspended in URLSession. Keep one in-flight upload per token so
|
||||
// foreground refresh, auth revalidation, and APNs callbacks cannot create
|
||||
@@ -35,7 +55,6 @@ public actor PushRegistrationService: PushRegistering {
|
||||
private var uploadTask: Task<Void, Never>?
|
||||
private var uploadTaskTokenHex: String?
|
||||
private var uploadTaskGeneration: UUID?
|
||||
private var uploadTaskAccountID: String?
|
||||
private var operationGeneration = UUID()
|
||||
private var snapshotValue: PushRegistrationSnapshot
|
||||
private var snapshotContinuations:
|
||||
@@ -50,6 +69,26 @@ public actor PushRegistrationService: PushRegistering {
|
||||
"cmux.notifications.pendingUnregisters.v2"
|
||||
private static let pendingUnregisterAttemptBudget = 4
|
||||
|
||||
private static func defaultPendingUnregisterStoreURL(
|
||||
suiteName: String?,
|
||||
bundleID: String
|
||||
) -> URL {
|
||||
let namespace = (suiteName ?? bundleID).map { character in
|
||||
character.isLetter || character.isNumber || character == "-"
|
||||
? character
|
||||
: "_"
|
||||
}
|
||||
let root = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first ?? FileManager.default.temporaryDirectory
|
||||
return root
|
||||
.appendingPathComponent("cmux", isDirectory: true)
|
||||
.appendingPathComponent(
|
||||
"push-cleanup-\(String(namespace)).sqlite3"
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a push registration service.
|
||||
///
|
||||
/// - Parameters:
|
||||
@@ -69,6 +108,7 @@ public actor PushRegistrationService: PushRegistering {
|
||||
bundleID: String,
|
||||
apnsEnvironment: String,
|
||||
suiteName: String? = nil,
|
||||
pendingUnregisterStoreURL: URL? = nil,
|
||||
session: sending URLSession = .shared,
|
||||
retryDelays: [Duration] = [
|
||||
.seconds(1),
|
||||
@@ -81,7 +121,9 @@ public actor PushRegistrationService: PushRegistering {
|
||||
},
|
||||
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
|
||||
try await ContinuousClock().sleep(for: $0)
|
||||
}
|
||||
},
|
||||
sessionSnapshotTimeout: Duration = .seconds(15),
|
||||
sessionSnapshotClock: any Clock<Duration> = ContinuousClock()
|
||||
) {
|
||||
self.tokenProvider = tokenProvider
|
||||
self.apiBaseURL = apiBaseURL
|
||||
@@ -92,11 +134,30 @@ public actor PushRegistrationService: PushRegistering {
|
||||
} else {
|
||||
self.defaults = .standard
|
||||
}
|
||||
Self.migrateLegacyPendingUnregisters(in: self.defaults)
|
||||
let storeURL = pendingUnregisterStoreURL
|
||||
?? Self.defaultPendingUnregisterStoreURL(
|
||||
suiteName: suiteName,
|
||||
bundleID: bundleID
|
||||
)
|
||||
self.pendingUnregisterStoreURL = storeURL
|
||||
do {
|
||||
self.pendingUnregisterStore = try PendingUnregisterStore(
|
||||
databaseURL: storeURL
|
||||
)
|
||||
} catch {
|
||||
self.pendingUnregisterStore = nil
|
||||
pushLog.error("Unable to open durable push-token cleanup store")
|
||||
}
|
||||
Self.migrateLegacyPendingUnregisters(
|
||||
in: self.defaults,
|
||||
overflowStore: self.pendingUnregisterStore
|
||||
)
|
||||
self.session = session
|
||||
self.retryDelays = retryDelays
|
||||
self.retryJitter = retryJitter
|
||||
self.retrySleep = retrySleep
|
||||
self.sessionSnapshotTimeout = sessionSnapshotTimeout
|
||||
self.sessionSnapshotClock = sessionSnapshotClock
|
||||
let enabled = self.defaults.bool(forKey: Self.enabledKey)
|
||||
let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false
|
||||
self.snapshotValue = PushRegistrationSnapshot(
|
||||
@@ -113,6 +174,16 @@ public actor PushRegistrationService: PushRegistering {
|
||||
|
||||
public func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
|
||||
let id = UUID()
|
||||
let hasKnownRegistration = cachedTokenHex != nil
|
||||
&& defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
)?.isEmpty == false
|
||||
if !isEnabled,
|
||||
hasPendingUnregisters || hasKnownRegistration {
|
||||
coordinatorIntentEnabled = false
|
||||
disableIntentReconciliationRequested = true
|
||||
scheduleDisableIntentReconciliation()
|
||||
}
|
||||
return AsyncStream { continuation in
|
||||
snapshotContinuations[id] = continuation
|
||||
continuation.yield(snapshotValue)
|
||||
@@ -123,21 +194,142 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
|
||||
public func setEnabled(_ enabled: Bool) async {
|
||||
let wasEnabled = isEnabled
|
||||
// The UI commits the shared preference before crossing into this actor.
|
||||
// Snapshot state therefore carries the prior service intent needed to
|
||||
// decide whether an opt-out still owes backend cleanup.
|
||||
let owesBackendCleanup = snapshotValue.isEnabled
|
||||
|| defaults.string(forKey: Self.registeredAccountIDKey) != nil
|
||||
cancelRetry()
|
||||
operationGeneration = UUID()
|
||||
let generation = operationGeneration
|
||||
defaults.set(enabled, forKey: Self.enabledKey)
|
||||
if enabled {
|
||||
await syncTokenIfPossible()
|
||||
} else {
|
||||
publish(.disabled)
|
||||
if wasEnabled {
|
||||
await unregisterFromServer()
|
||||
if owesBackendCleanup {
|
||||
await unregisterFromServer(
|
||||
preferenceGeneration: generation
|
||||
)
|
||||
} else {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
await retryPendingUnregisterIfPossible(
|
||||
preferenceGeneration: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the coordinator's latest preference immediately. Disable starts
|
||||
/// app-owned backend cleanup and awaits its bounded attempt; enable waits
|
||||
/// for the coordinator's separate post-authorization reconciliation call.
|
||||
public func applyEnabledIntent(
|
||||
_ enabled: Bool,
|
||||
generation: UInt64
|
||||
) async {
|
||||
guard generation >= coordinatorIntentGeneration else { return }
|
||||
if generation == coordinatorIntentGeneration,
|
||||
coordinatorIntentEnabled == enabled {
|
||||
return
|
||||
}
|
||||
coordinatorIntentGeneration = generation
|
||||
coordinatorIntentEnabled = enabled
|
||||
coordinatorIntentReconciledGeneration = nil
|
||||
operationGeneration = UUID()
|
||||
cancelRetry()
|
||||
defaults.set(enabled, forKey: Self.enabledKey)
|
||||
if enabled {
|
||||
let hasToken = cachedTokenHex != nil
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: hasToken,
|
||||
backendState: hasToken
|
||||
? .registrationRequired
|
||||
: .awaitingDeviceToken
|
||||
))
|
||||
} else {
|
||||
if let tokenHex = cachedTokenHex,
|
||||
let accountID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
),
|
||||
!accountID.isEmpty {
|
||||
// Persist the cleanup before the worker can suspend on auth.
|
||||
persistPendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: accountID
|
||||
)
|
||||
}
|
||||
publish(.disabled)
|
||||
}
|
||||
if !enabled {
|
||||
disableIntentReconciliationRequested = true
|
||||
scheduleDisableIntentReconciliation()
|
||||
// The worker is app-owned, so cancellation of a stale Settings
|
||||
// task cannot cancel privacy cleanup. Awaiting it preserves the
|
||||
// public `disable()` completion guarantee for callers that clear
|
||||
// authentication immediately afterwards.
|
||||
await disableIntentReconciliationTask?.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles an enabled intent only after iOS authorization has succeeded.
|
||||
/// Stale generations cannot upload a cached APNs token.
|
||||
public func reconcileEnabledIntent(generation: UInt64) async {
|
||||
guard generation == coordinatorIntentGeneration,
|
||||
coordinatorIntentEnabled == true,
|
||||
isEnabled else { return }
|
||||
coordinatorIntentReconciledGeneration = generation
|
||||
enableIntentReconciliationRequested = true
|
||||
scheduleEnableIntentReconciliation()
|
||||
}
|
||||
|
||||
private func scheduleEnableIntentReconciliation() {
|
||||
guard enableIntentReconciliationTask == nil else { return }
|
||||
enableIntentReconciliationTask = Task { [weak self] in
|
||||
await self?.drainEnableIntentReconciliation()
|
||||
}
|
||||
}
|
||||
|
||||
private func drainEnableIntentReconciliation() async {
|
||||
while enableIntentReconciliationRequested {
|
||||
enableIntentReconciliationRequested = false
|
||||
guard coordinatorIntentEnabled == true else { continue }
|
||||
await syncTokenIfPossible()
|
||||
}
|
||||
enableIntentReconciliationTask = nil
|
||||
if enableIntentReconciliationRequested {
|
||||
scheduleEnableIntentReconciliation()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleDisableIntentReconciliation() {
|
||||
guard disableIntentReconciliationTask == nil else { return }
|
||||
disableIntentReconciliationTask = Task { [weak self] in
|
||||
await self?.drainDisableIntentReconciliation()
|
||||
}
|
||||
}
|
||||
|
||||
private func drainDisableIntentReconciliation() async {
|
||||
while disableIntentReconciliationRequested {
|
||||
disableIntentReconciliationRequested = false
|
||||
guard coordinatorIntentEnabled == false else { continue }
|
||||
let generation = coordinatorIntentGeneration
|
||||
let preferenceGeneration = operationGeneration
|
||||
await unregisterFromServer(
|
||||
preferenceGeneration: preferenceGeneration
|
||||
)
|
||||
await retryPendingUnregisterIfPossible(
|
||||
preferenceGeneration: preferenceGeneration
|
||||
)
|
||||
guard generation == coordinatorIntentGeneration,
|
||||
coordinatorIntentEnabled == false else { continue }
|
||||
publish(.disabled)
|
||||
}
|
||||
disableIntentReconciliationTask = nil
|
||||
if disableIntentReconciliationRequested {
|
||||
scheduleDisableIntentReconciliation()
|
||||
}
|
||||
}
|
||||
|
||||
public func register(deviceToken: Data) async {
|
||||
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
let previousToken = cachedTokenHex
|
||||
@@ -157,8 +349,16 @@ public actor PushRegistrationService: PushRegistering {
|
||||
defaults.removeObject(forKey: Self.registeredAccountIDKey)
|
||||
}
|
||||
defaults.set(hex, forKey: Self.cachedTokenKey)
|
||||
guard isEnabled else {
|
||||
publish(.disabled)
|
||||
guard canUploadForCurrentIntent else {
|
||||
publish(
|
||||
isEnabled
|
||||
? PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registrationRequired
|
||||
)
|
||||
: .disabled
|
||||
)
|
||||
return
|
||||
}
|
||||
// A repeated callback for the same cached token should cancel only a
|
||||
@@ -178,6 +378,16 @@ public actor PushRegistrationService: PushRegistering {
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
guard canUploadForCurrentIntent else {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: cachedTokenHex != nil,
|
||||
backendState: cachedTokenHex == nil
|
||||
? .awaitingDeviceToken
|
||||
: .registrationRequired
|
||||
))
|
||||
return
|
||||
}
|
||||
guard let hex = cachedTokenHex else {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
@@ -202,12 +412,45 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
|
||||
public func unregisterFromServer() async {
|
||||
// Treat direct cleanup retries as the current opt-out operation too,
|
||||
// so a newer enable can supersede an in-flight DELETE and trigger the
|
||||
// same final re-upload repair as coordinator-owned cleanup.
|
||||
cancelRetry()
|
||||
await unregisterFromServer(
|
||||
preferenceGeneration: operationGeneration,
|
||||
requiresDisabledPreference: false
|
||||
)
|
||||
}
|
||||
|
||||
private func unregisterFromServer(
|
||||
preferenceGeneration: UUID?,
|
||||
requiresDisabledPreference: Bool = true
|
||||
) async {
|
||||
if preferenceGeneration == nil {
|
||||
cancelRetry()
|
||||
}
|
||||
guard let hex = cachedTokenHex else { return }
|
||||
let session = try? await tokenProvider.authenticatedSessionSnapshot()
|
||||
let ownerID = defaults.string(
|
||||
let registeredOwnerID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
) ?? session?.accountID
|
||||
)
|
||||
if let registeredOwnerID, !registeredOwnerID.isEmpty {
|
||||
// Record the privacy cleanup before any authentication await. A
|
||||
// stalled session restore must not lose an already-known owner.
|
||||
persistPendingUnregister(
|
||||
tokenHex: hex,
|
||||
accountID: registeredOwnerID
|
||||
)
|
||||
}
|
||||
let session = await boundedSessionSnapshot(
|
||||
phase: .pushUnregistrationSession,
|
||||
recoveryGeneration: preferenceGeneration
|
||||
)
|
||||
if let preferenceGeneration,
|
||||
preferenceGeneration != operationGeneration
|
||||
|| (requiresDisabledPreference && isEnabled) {
|
||||
return
|
||||
}
|
||||
let ownerID = registeredOwnerID ?? session?.accountID
|
||||
guard let ownerID, !ownerID.isEmpty else { return }
|
||||
// Persist before requiring live auth. This is the privacy guarantee for
|
||||
// an offline or signed-out opt-out.
|
||||
@@ -218,6 +461,19 @@ public actor PushRegistrationService: PushRegistering {
|
||||
if await sendDelete(tokenHex: hex, sessionSnapshot: session) {
|
||||
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
|
||||
let preferenceWasSuperseded = preferenceGeneration.map {
|
||||
$0 != operationGeneration
|
||||
|| (requiresDisabledPreference && isEnabled)
|
||||
} ?? false
|
||||
if preferenceWasSuperseded,
|
||||
enableIntentIsReconciled,
|
||||
let currentToken = cachedTokenHex,
|
||||
currentToken == hex {
|
||||
// A newer enable may have posted while this older DELETE was
|
||||
// already in flight. Re-upsert after the DELETE acknowledgement
|
||||
// so the latest preference is also the final backend state.
|
||||
await upload(tokenHex: hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,38 +554,51 @@ public actor PushRegistrationService: PushRegistering {
|
||||
tokenHex: String,
|
||||
replacingGeneration: UUID? = nil
|
||||
) async {
|
||||
let requestedAccountID = (try? await tokenProvider
|
||||
.authenticatedSessionSnapshot())?.accountID
|
||||
if let uploadTask,
|
||||
uploadTaskTokenHex == tokenHex,
|
||||
uploadTaskGeneration == operationGeneration,
|
||||
uploadTaskGeneration != replacingGeneration,
|
||||
uploadTaskAccountID == requestedAccountID {
|
||||
await uploadTask.value
|
||||
while canUploadForCurrentIntent, cachedTokenHex == tokenHex {
|
||||
if let inFlightTask = uploadTask,
|
||||
uploadTaskGeneration != replacingGeneration {
|
||||
let inFlightGeneration = uploadTaskGeneration
|
||||
await inFlightTask.value
|
||||
if uploadTaskGeneration == inFlightGeneration {
|
||||
uploadTask = nil
|
||||
uploadTaskTokenHex = nil
|
||||
uploadTaskGeneration = nil
|
||||
}
|
||||
guard canUploadForCurrentIntent,
|
||||
cachedTokenHex == tokenHex else { return }
|
||||
if snapshotValue.backendState == .registered {
|
||||
return
|
||||
}
|
||||
// The mutation already represented the current operation. A
|
||||
// newer generation loops and starts only after it completes.
|
||||
if inFlightGeneration == operationGeneration {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
operationGeneration = UUID()
|
||||
let generation = operationGeneration
|
||||
let retryDelays = self.retryDelays
|
||||
let task = Task { [weak self, retryDelays] in
|
||||
guard let self else { return }
|
||||
await self.attemptUpload(
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: retryDelays
|
||||
)
|
||||
}
|
||||
uploadTask = task
|
||||
uploadTaskTokenHex = tokenHex
|
||||
uploadTaskGeneration = generation
|
||||
await task.value
|
||||
if uploadTaskGeneration == generation {
|
||||
uploadTask = nil
|
||||
uploadTaskTokenHex = nil
|
||||
uploadTaskGeneration = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
operationGeneration = UUID()
|
||||
let generation = operationGeneration
|
||||
let retryDelays = self.retryDelays
|
||||
let task = Task { [weak self, retryDelays] in
|
||||
guard let self else { return }
|
||||
await self.attemptUpload(
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: retryDelays
|
||||
)
|
||||
}
|
||||
uploadTask = task
|
||||
uploadTaskTokenHex = tokenHex
|
||||
uploadTaskGeneration = generation
|
||||
uploadTaskAccountID = requestedAccountID
|
||||
await task.value
|
||||
if uploadTaskGeneration == generation {
|
||||
uploadTask = nil
|
||||
uploadTaskTokenHex = nil
|
||||
uploadTaskGeneration = nil
|
||||
uploadTaskAccountID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptUpload(
|
||||
@@ -337,7 +606,8 @@ public actor PushRegistrationService: PushRegistering {
|
||||
generation: UUID,
|
||||
remainingDelays: [Duration]
|
||||
) async {
|
||||
guard isEnabled, generation == operationGeneration,
|
||||
guard canUploadForCurrentIntent,
|
||||
generation == operationGeneration,
|
||||
cachedTokenHex == tokenHex else { return }
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
@@ -352,13 +622,23 @@ public actor PushRegistrationService: PushRegistering {
|
||||
"bundleId": bundleID,
|
||||
"environment": apnsEnvironment,
|
||||
"platform": "ios",
|
||||
]
|
||||
],
|
||||
authPhase: .pushRegistrationSession
|
||||
)
|
||||
let result: RegistrationResult
|
||||
let requestSession: AuthenticatedSessionSnapshot?
|
||||
switch request {
|
||||
case let .success(context):
|
||||
requestSession = context.session
|
||||
// A POST can commit before its response reaches the app. Record
|
||||
// the owner first so a crash followed by an opt-out relaunch still
|
||||
// has enough identity to delete that ambiguous registration.
|
||||
if let requestSession = context.session {
|
||||
persistPendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: requestSession.accountID
|
||||
)
|
||||
}
|
||||
result = await performRegistration(context.request)
|
||||
case let .failure(failure):
|
||||
requestSession = nil
|
||||
@@ -395,6 +675,9 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
switch result {
|
||||
case let .success(pushServiceConfigured):
|
||||
let previousOwnerID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
)
|
||||
if let requestSession {
|
||||
defaults.set(
|
||||
requestSession.accountID,
|
||||
@@ -405,11 +688,8 @@ public actor PushRegistrationService: PushRegistering {
|
||||
// current account also removes any old-account association, so a
|
||||
// pending tombstone for this token is fulfilled without applying
|
||||
// old credentials.
|
||||
for pending in pendingUnregisters where pending.tokenHex == tokenHex {
|
||||
clearPendingUnregister(
|
||||
tokenHex: pending.tokenHex,
|
||||
accountID: pending.accountID
|
||||
)
|
||||
if previousOwnerID != nil || hasPendingUnregisters {
|
||||
clearPendingUnregisterToken(tokenHex: tokenHex)
|
||||
}
|
||||
if pushServiceConfigured {
|
||||
publish(PushRegistrationSnapshot(
|
||||
@@ -492,8 +772,9 @@ public actor PushRegistrationService: PushRegistering {
|
||||
staleSession: AuthenticatedSessionSnapshot,
|
||||
staleGeneration: UUID
|
||||
) async {
|
||||
let currentSession = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot()
|
||||
let currentSession = await boundedSessionSnapshot(
|
||||
phase: .pushRegistrationSession
|
||||
)
|
||||
if isEnabled,
|
||||
cachedTokenHex == tokenHex,
|
||||
currentSession?.accountID == staleSession.accountID {
|
||||
@@ -522,8 +803,9 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
|
||||
guard isEnabled, let currentToken = cachedTokenHex,
|
||||
let currentSession = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot(),
|
||||
let currentSession = await boundedSessionSnapshot(
|
||||
phase: .pushRegistrationSession
|
||||
),
|
||||
await tokenProvider.isAuthenticatedSessionCurrent(currentSession)
|
||||
else { return }
|
||||
await upload(tokenHex: currentToken, replacingGeneration: staleGeneration)
|
||||
@@ -538,10 +820,14 @@ public actor PushRegistrationService: PushRegistering {
|
||||
guard case let .success(context) = await makeRequest(
|
||||
method: "DELETE",
|
||||
path: "/api/device-tokens",
|
||||
body: ["deviceToken": tokenHex],
|
||||
body: [
|
||||
"deviceToken": tokenHex,
|
||||
"bundleId": bundleID,
|
||||
],
|
||||
capturedAccessToken: capturedAccessToken,
|
||||
capturedRefreshToken: capturedRefreshToken,
|
||||
sessionSnapshot: sessionSnapshot
|
||||
sessionSnapshot: sessionSnapshot,
|
||||
authPhase: .pushUnregistrationSession
|
||||
) else { return false }
|
||||
guard await performDelete(context.request) else { return false }
|
||||
if let session = context.session {
|
||||
@@ -556,7 +842,8 @@ public actor PushRegistrationService: PushRegistering {
|
||||
body: [String: String],
|
||||
capturedAccessToken: String? = nil,
|
||||
capturedRefreshToken: String? = nil,
|
||||
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
|
||||
sessionSnapshot: AuthenticatedSessionSnapshot? = nil,
|
||||
authPhase: AuthPhase
|
||||
) async -> Result<PushRequest, PushRegistrationFailure> {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
@@ -572,15 +859,14 @@ public actor PushRegistrationService: PushRegistering {
|
||||
refreshToken = capturedRefreshToken
|
||||
authenticatedSession = nil
|
||||
} else {
|
||||
do {
|
||||
let session = try await tokenProvider
|
||||
.authenticatedSessionSnapshot()
|
||||
accessToken = session.accessToken
|
||||
refreshToken = session.refreshToken
|
||||
authenticatedSession = session
|
||||
} catch {
|
||||
guard let session = await boundedSessionSnapshot(
|
||||
phase: authPhase
|
||||
) else {
|
||||
return .failure(.authenticationRequired)
|
||||
}
|
||||
accessToken = session.accessToken
|
||||
refreshToken = session.refreshToken
|
||||
authenticatedSession = session
|
||||
}
|
||||
guard let url = URL(string: apiBaseURL + path) else {
|
||||
return .failure(.invalidConfiguration)
|
||||
@@ -589,6 +875,7 @@ public actor PushRegistrationService: PushRegistering {
|
||||
request.httpMethod = method
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
|
||||
request.setValue(bundleID, forHTTPHeaderField: "X-Cmux-App-Namespace")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
request.timeoutInterval = 15
|
||||
@@ -661,12 +948,31 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
}
|
||||
|
||||
private func retryPendingUnregisterIfPossible() async {
|
||||
guard let session = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot() else { return }
|
||||
private func retryPendingUnregisterIfPossible(
|
||||
preferenceGeneration: UUID? = nil
|
||||
) async {
|
||||
guard let session = await boundedSessionSnapshot(
|
||||
phase: .pushUnregistrationSession,
|
||||
recoveryGeneration: preferenceGeneration
|
||||
) else { return }
|
||||
if let preferenceGeneration,
|
||||
preferenceGeneration != operationGeneration || isEnabled {
|
||||
return
|
||||
}
|
||||
let currentAccountID = session.accountID
|
||||
let matching = pendingUnregisters.filter {
|
||||
$0.accountID == currentAccountID
|
||||
var seen = Set<PendingUnregister>()
|
||||
let matching = (
|
||||
pendingUnregisterOverflowBatch(
|
||||
accountID: currentAccountID,
|
||||
// Keep one lookahead entry so a bounded batch can tell
|
||||
// whether another continuation is required.
|
||||
limit: Self.pendingUnregisterAttemptBudget + 1
|
||||
) + pendingUnregisterFallbackBatch(
|
||||
accountID: currentAccountID,
|
||||
limit: Self.pendingUnregisterAttemptBudget + 1
|
||||
)
|
||||
).filter {
|
||||
seen.insert($0).inserted
|
||||
}
|
||||
let batch = Array(
|
||||
matching.prefix(Self.pendingUnregisterAttemptBudget)
|
||||
@@ -702,25 +1008,92 @@ public actor PushRegistrationService: PushRegistering {
|
||||
tokenHex: pending.tokenHex
|
||||
)
|
||||
}
|
||||
let preferenceWasSuperseded = preferenceGeneration.map {
|
||||
$0 != operationGeneration || isEnabled
|
||||
} ?? false
|
||||
if preferenceWasSuperseded,
|
||||
enableIntentIsReconciled,
|
||||
let currentToken = cachedTokenHex,
|
||||
results.contains(where: {
|
||||
$0.0.tokenHex == currentToken && $0.1
|
||||
}) {
|
||||
// A newer enable raced cleanup that was already sent. Restore the
|
||||
// current token only after every acknowledged DELETE has finished.
|
||||
await upload(tokenHex: currentToken)
|
||||
return
|
||||
}
|
||||
guard !preferenceWasSuperseded else { return }
|
||||
if matching.count > batch.count,
|
||||
results.contains(where: { $0.1 }) {
|
||||
schedulePendingUnregisterContinuation()
|
||||
schedulePendingUnregisterContinuation(
|
||||
preferenceGeneration: preferenceGeneration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func boundedSessionSnapshot(
|
||||
phase: AuthPhase,
|
||||
recoveryGeneration: UUID? = nil
|
||||
) async -> AuthenticatedSessionSnapshot? {
|
||||
let tokenProvider = tokenProvider
|
||||
do {
|
||||
return try await withAuthPhaseTimeout(
|
||||
phase,
|
||||
duration: sessionSnapshotTimeout,
|
||||
clock: sessionSnapshotClock,
|
||||
log: authLog,
|
||||
registry: sessionSnapshotTimeoutRegistry,
|
||||
blocksRetriesWhileTimedOutOperationActive: true
|
||||
) {
|
||||
// This provider API only reads a coherent stored token pair or
|
||||
// awaits bounded launch bootstrap. Cancelling it cannot leave
|
||||
// an ambiguous server mutation behind.
|
||||
try await tokenProvider.authenticatedSessionSnapshot()
|
||||
}
|
||||
} catch let error as AuthError where error == .timedOut {
|
||||
schedulePendingUnregisterRecovery(
|
||||
preferenceGeneration: recoveryGeneration
|
||||
)
|
||||
return nil
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var enableIntentIsReconciled: Bool {
|
||||
guard isEnabled else { return false }
|
||||
if coordinatorIntentEnabled == true {
|
||||
return coordinatorIntentReconciledGeneration
|
||||
== coordinatorIntentGeneration
|
||||
}
|
||||
return coordinatorIntentEnabled == nil
|
||||
}
|
||||
|
||||
private var canUploadForCurrentIntent: Bool {
|
||||
enableIntentIsReconciled
|
||||
}
|
||||
|
||||
private func persistPendingUnregister(tokenHex: String, accountID: String) {
|
||||
let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID)
|
||||
var queue = pendingUnregisters
|
||||
if !queue.contains(entry) {
|
||||
queue.append(entry)
|
||||
if durablePendingUnregisterStore()?.insert(entry) == true {
|
||||
// SQLite is durable before the legacy fallback is removed.
|
||||
storePendingUnregisters(
|
||||
pendingUnregisters.filter { $0 != entry }
|
||||
)
|
||||
return
|
||||
}
|
||||
// Never evict a privacy cleanup obligation merely to enforce a local
|
||||
// storage cap. The set is deduplicated by (account, token), and drains
|
||||
// in bounded network batches so size cannot stall current readiness.
|
||||
var queue = pendingUnregisters
|
||||
queue.removeAll { $0 == entry }
|
||||
queue.append(entry)
|
||||
storePendingUnregisters(queue)
|
||||
}
|
||||
|
||||
private func schedulePendingUnregisterContinuation() {
|
||||
private func schedulePendingUnregisterContinuation(
|
||||
preferenceGeneration: UUID? = nil
|
||||
) {
|
||||
if let preferenceGeneration {
|
||||
unregisterDrainPreferenceGeneration = preferenceGeneration
|
||||
}
|
||||
guard unregisterDrainTask == nil else { return }
|
||||
unregisterDrainTask = Task { [weak self] in
|
||||
await Task.yield()
|
||||
@@ -729,19 +1102,57 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
}
|
||||
|
||||
private func schedulePendingUnregisterRecovery(
|
||||
preferenceGeneration: UUID?
|
||||
) {
|
||||
if let preferenceGeneration {
|
||||
pendingUnregisterRecoveryGeneration = preferenceGeneration
|
||||
}
|
||||
guard pendingUnregisterRecoveryTask == nil else { return }
|
||||
let clock = sessionSnapshotClock
|
||||
pendingUnregisterRecoveryTask = Task { [weak self, clock] in
|
||||
do {
|
||||
// AuthPhaseTimeoutRegistry holds a timed-out phase for 30s.
|
||||
// Wait past that lease before asking the worker to retry.
|
||||
try await clock.sleep(for: .seconds(31))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
await self.finishPendingUnregisterRecovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishPendingUnregisterRecovery() {
|
||||
pendingUnregisterRecoveryTask = nil
|
||||
let generation = pendingUnregisterRecoveryGeneration
|
||||
pendingUnregisterRecoveryGeneration = nil
|
||||
guard hasPendingUnregisters else { return }
|
||||
schedulePendingUnregisterContinuation(
|
||||
preferenceGeneration: generation
|
||||
)
|
||||
}
|
||||
|
||||
private func runPendingUnregisterContinuation() async {
|
||||
unregisterDrainTask = nil
|
||||
await retryPendingUnregisterIfPossible()
|
||||
let generation = unregisterDrainPreferenceGeneration
|
||||
unregisterDrainPreferenceGeneration = nil
|
||||
await retryPendingUnregisterIfPossible(
|
||||
preferenceGeneration: generation
|
||||
)
|
||||
}
|
||||
|
||||
private func clearPendingUnregister(
|
||||
tokenHex: String,
|
||||
accountID: String
|
||||
) {
|
||||
let filtered = pendingUnregisters.filter { entry in
|
||||
_ = durablePendingUnregisterStore()?.remove(
|
||||
tokenHex: tokenHex,
|
||||
accountID: accountID
|
||||
)
|
||||
storePendingUnregisters(pendingUnregisters.filter { entry in
|
||||
entry.tokenHex != tokenHex || entry.accountID != accountID
|
||||
}
|
||||
storePendingUnregisters(filtered)
|
||||
})
|
||||
}
|
||||
|
||||
private var pendingUnregisters: [PendingUnregister] {
|
||||
@@ -760,44 +1171,114 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
|
||||
private static func migrateLegacyPendingUnregisters(
|
||||
in defaults: UserDefaults
|
||||
in defaults: UserDefaults,
|
||||
overflowStore: PendingUnregisterStore?
|
||||
) {
|
||||
guard let tokenHex = defaults.string(
|
||||
forKey: pendingUnregisterTokenKey
|
||||
), let accountID = defaults.string(
|
||||
forKey: pendingUnregisterAccountIDKey
|
||||
), !tokenHex.isEmpty, !accountID.isEmpty else { return }
|
||||
var entries = (defaults.data(forKey: pendingUnregisterQueueKey)
|
||||
.flatMap { try? JSONDecoder().decode(
|
||||
[PendingUnregister].self,
|
||||
from: $0
|
||||
) }) ?? []
|
||||
let legacy = PendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: accountID
|
||||
)
|
||||
if !entries.contains(legacy) { entries.append(legacy) }
|
||||
if let data = try? JSONEncoder().encode(entries) {
|
||||
defaults.set(data, forKey: pendingUnregisterQueueKey)
|
||||
if let tokenHex = defaults.string(
|
||||
forKey: pendingUnregisterTokenKey
|
||||
), let accountID = defaults.string(
|
||||
forKey: pendingUnregisterAccountIDKey
|
||||
), !tokenHex.isEmpty, !accountID.isEmpty {
|
||||
let legacy = PendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: accountID
|
||||
)
|
||||
entries.removeAll { $0 == legacy }
|
||||
entries.append(legacy)
|
||||
}
|
||||
var seen = Set<PendingUnregister>()
|
||||
var newestFirst: [PendingUnregister] = []
|
||||
for entry in entries.reversed() where seen.insert(entry).inserted {
|
||||
newestFirst.append(entry)
|
||||
}
|
||||
let normalized = Array(newestFirst.reversed())
|
||||
guard normalized.isEmpty
|
||||
|| overflowStore?.insertAll(normalized) == true else {
|
||||
// Keep every legacy key intact when durable migration fails.
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: pendingUnregisterQueueKey)
|
||||
defaults.removeObject(forKey: pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: pendingUnregisterAccountIDKey)
|
||||
}
|
||||
|
||||
private func storePendingUnregisters(_ entries: [PendingUnregister]) {
|
||||
if entries.isEmpty {
|
||||
var seen = Set<PendingUnregister>()
|
||||
var newestFirst: [PendingUnregister] = []
|
||||
for entry in entries.reversed() where seen.insert(entry).inserted {
|
||||
newestFirst.append(entry)
|
||||
}
|
||||
let normalized = Array(newestFirst.reversed())
|
||||
if normalized.isEmpty {
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterQueueKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
|
||||
return
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(entries) {
|
||||
if let data = try? JSONEncoder().encode(normalized) {
|
||||
defaults.set(data, forKey: Self.pendingUnregisterQueueKey)
|
||||
}
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
|
||||
}
|
||||
|
||||
private var hasPendingUnregisters: Bool {
|
||||
durablePendingUnregisterStore()?.hasEntries == true
|
||||
|| !pendingUnregisters.isEmpty
|
||||
}
|
||||
|
||||
private func pendingUnregisterOverflowBatch(
|
||||
accountID: String,
|
||||
limit: Int
|
||||
) -> [PendingUnregister] {
|
||||
durablePendingUnregisterStore()?.batch(
|
||||
accountID: accountID,
|
||||
limit: limit
|
||||
) ?? []
|
||||
}
|
||||
|
||||
private func pendingUnregisterFallbackBatch(
|
||||
accountID: String,
|
||||
limit: Int
|
||||
) -> [PendingUnregister] {
|
||||
Array(pendingUnregisters.lazy.filter {
|
||||
$0.accountID == accountID
|
||||
}.prefix(limit))
|
||||
}
|
||||
|
||||
private func clearPendingUnregisterToken(tokenHex: String) {
|
||||
_ = durablePendingUnregisterStore()?.removeAll(tokenHex: tokenHex)
|
||||
storePendingUnregisters(
|
||||
pendingUnregisters.filter { $0.tokenHex != tokenHex }
|
||||
)
|
||||
}
|
||||
|
||||
private func durablePendingUnregisterStore() -> PendingUnregisterStore? {
|
||||
if let pendingUnregisterStore {
|
||||
return pendingUnregisterStore
|
||||
}
|
||||
do {
|
||||
let store = try PendingUnregisterStore(
|
||||
databaseURL: pendingUnregisterStoreURL
|
||||
)
|
||||
pendingUnregisterStore = store
|
||||
Self.migrateLegacyPendingUnregisters(
|
||||
in: defaults,
|
||||
overflowStore: store
|
||||
)
|
||||
pushLog.info("Recovered durable push-token cleanup store")
|
||||
return store
|
||||
} catch {
|
||||
pushLog.error("Unable to recover durable push-token cleanup store")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func clearRegisteredOwner(
|
||||
accountID: String,
|
||||
tokenHex: String
|
||||
@@ -956,8 +1437,3 @@ private struct RegistrationErrorResponse: Decodable {
|
||||
let retryAfterSeconds: Int?
|
||||
let limit: Int?
|
||||
}
|
||||
|
||||
private struct PendingUnregister: Codable, Hashable {
|
||||
let tokenHex: String
|
||||
let accountID: String
|
||||
}
|
||||
|
||||
+104
-9
@@ -19,15 +19,28 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
|
||||
private static let accessTokenAccount = "cmux-auth-access-token"
|
||||
private static let refreshTokenAccount = "cmux-auth-refresh-token"
|
||||
private let service: String
|
||||
private let accessGroup: String?
|
||||
private let legacyProjectID: String?
|
||||
private let log = AuthDebugLog()
|
||||
|
||||
private var cachedAccessToken: String?
|
||||
private var cachedRefreshToken: String?
|
||||
|
||||
/// Creates a keychain store writing under `service`.
|
||||
/// - Parameter service: The keychain service name; see ``serviceName(bundleIdentifier:)``.
|
||||
public init(service: String) {
|
||||
/// Creates a Keychain store writing under one exact signed access group.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - service: The bundle-scoped Keychain service.
|
||||
/// - accessGroup: The app's exact signed Keychain access group.
|
||||
/// - legacyProjectID: The Stack project whose older account-only items
|
||||
/// may be adopted from this same access group.
|
||||
public init(
|
||||
service: String,
|
||||
accessGroup: String? = nil,
|
||||
legacyProjectID: String? = nil
|
||||
) {
|
||||
self.service = service
|
||||
self.accessGroup = accessGroup
|
||||
self.legacyProjectID = legacyProjectID
|
||||
}
|
||||
|
||||
/// The keychain service name auth tokens are stored under, namespaced by
|
||||
@@ -43,12 +56,18 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
|
||||
|
||||
public func getStoredAccessToken() async -> String? {
|
||||
if let cachedAccessToken { return cachedAccessToken }
|
||||
return keychainRead(account: Self.accessTokenAccount)
|
||||
return readOrAdoptLegacyToken(
|
||||
account: Self.accessTokenAccount,
|
||||
legacyAccount: legacyProjectID.map { "stack-auth-access-\($0)" }
|
||||
)
|
||||
}
|
||||
|
||||
public func getStoredRefreshToken() async -> String? {
|
||||
if let cachedRefreshToken { return cachedRefreshToken }
|
||||
return keychainRead(account: Self.refreshTokenAccount)
|
||||
return readOrAdoptLegacyToken(
|
||||
account: Self.refreshTokenAccount,
|
||||
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
|
||||
)
|
||||
}
|
||||
|
||||
public func setTokens(accessToken: String?, refreshToken: String?) async {
|
||||
@@ -83,13 +102,20 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
|
||||
cachedRefreshToken = nil
|
||||
keychainDelete(account: Self.accessTokenAccount)
|
||||
keychainDelete(account: Self.refreshTokenAccount)
|
||||
deleteLegacyTokens()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool {
|
||||
let snapshot = AuthTokenSnapshot(
|
||||
accessToken: keychainRead(account: Self.accessTokenAccount),
|
||||
refreshToken: keychainRead(account: Self.refreshTokenAccount)
|
||||
accessToken: readOrAdoptLegacyToken(
|
||||
account: Self.accessTokenAccount,
|
||||
legacyAccount: legacyProjectID.map { "stack-auth-access-\($0)" }
|
||||
),
|
||||
refreshToken: readOrAdoptLegacyToken(
|
||||
account: Self.refreshTokenAccount,
|
||||
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
|
||||
)
|
||||
)
|
||||
guard snapshot.matches(expectedAccessToken: accessToken, expectedRefreshToken: refreshToken) else {
|
||||
log.log("keychain.clearTokensIfCurrent: skipped stale clear")
|
||||
@@ -110,7 +136,10 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
|
||||
newRefreshToken: String?,
|
||||
newAccessToken: String?
|
||||
) async {
|
||||
let current = keychainRead(account: Self.refreshTokenAccount)
|
||||
let current = readOrAdoptLegacyToken(
|
||||
account: Self.refreshTokenAccount,
|
||||
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
|
||||
)
|
||||
let matches = current == compareRefreshToken
|
||||
log.log("keychain.compareAndSet: matches=\(matches) hasNewRefresh=\(newRefreshToken?.isEmpty == false) hasNewAccess=\(newAccessToken?.isEmpty == false)")
|
||||
guard matches else { return }
|
||||
@@ -121,13 +150,58 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
|
||||
}
|
||||
|
||||
#if canImport(Security)
|
||||
private func readOrAdoptLegacyToken(
|
||||
account: String,
|
||||
legacyAccount: String?
|
||||
) -> String? {
|
||||
if let current = keychainRead(account: account) {
|
||||
return current
|
||||
}
|
||||
// Legacy account-only items are ambiguous without the exact signed
|
||||
// access group. Never let a caller using the current-token-only API
|
||||
// adopt another installed cmux bundle's Stack session.
|
||||
guard accessGroup != nil,
|
||||
let legacyAccount,
|
||||
let legacy = keychainReadLegacy(account: legacyAccount),
|
||||
keychainWrite(legacy, account: account) else {
|
||||
return nil
|
||||
}
|
||||
keychainDeleteLegacy(account: legacyAccount)
|
||||
return legacy
|
||||
}
|
||||
|
||||
private func deleteLegacyTokens() {
|
||||
guard accessGroup != nil, let legacyProjectID else { return }
|
||||
keychainDeleteLegacy(account: "stack-auth-access-\(legacyProjectID)")
|
||||
keychainDeleteLegacy(account: "stack-auth-refresh-\(legacyProjectID)")
|
||||
}
|
||||
|
||||
private func baseQuery(account: String) -> [String: Any] {
|
||||
[
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
private func legacyBaseQuery(account: String) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
// The legacy Stack SDK omitted this attribute when adding items,
|
||||
// which Keychain persists as the empty service. An omitted query
|
||||
// attribute is a wildcard and could match another credential.
|
||||
kSecAttrService as String: "",
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
private func keychainRead(account: String) -> String? {
|
||||
@@ -170,7 +244,28 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
|
||||
private func keychainDelete(account: String) {
|
||||
_ = SecItemDelete(baseQuery(account: account) as CFDictionary)
|
||||
}
|
||||
|
||||
private func keychainReadLegacy(account: String) -> String? {
|
||||
var query = legacyBaseQuery(account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
if status != errSecItemNotFound {
|
||||
log.log("keychain legacy READ status=\(status) account=\(account)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func keychainDeleteLegacy(account: String) {
|
||||
_ = SecItemDelete(legacyBaseQuery(account: account) as CFDictionary)
|
||||
}
|
||||
#else
|
||||
private func readOrAdoptLegacyToken(account: String, legacyAccount: String?) -> String? { nil }
|
||||
private func deleteLegacyTokens() {}
|
||||
private func keychainRead(account: String) -> String? { nil }
|
||||
private func keychainWrite(_ value: String, account: String) -> Bool { false }
|
||||
private func keychainDelete(account: String) {}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
@testable import CmuxAuthRuntime
|
||||
|
||||
actor CancellationIgnoringPushTokenProvider: TokenProviding {
|
||||
private let snapshotValue = AuthenticatedSessionSnapshot(
|
||||
generation: 1,
|
||||
accountID: "push-user-1",
|
||||
accessToken: "access",
|
||||
refreshToken: "refresh"
|
||||
)
|
||||
private let started: TestPhaseSignal
|
||||
private let blocker: TestContinuationBlocker
|
||||
private let cancellationObserved = TestPhaseSignal()
|
||||
private let completed = TestPhaseSignal()
|
||||
private(set) var snapshotRequestCount = 0
|
||||
|
||||
init(started: TestPhaseSignal, blocker: TestContinuationBlocker) {
|
||||
self.started = started
|
||||
self.blocker = blocker
|
||||
}
|
||||
|
||||
func authenticatedSessionSnapshot() async throws
|
||||
-> AuthenticatedSessionSnapshot {
|
||||
snapshotRequestCount += 1
|
||||
await started.markStarted()
|
||||
let cancellationObserved = cancellationObserved
|
||||
return await withTaskCancellationHandler {
|
||||
await blocker.wait()
|
||||
await completed.markStarted()
|
||||
return snapshotValue
|
||||
} onCancel: {
|
||||
Task { await cancellationObserved.markStarted() }
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilCancellationObserved() async {
|
||||
await cancellationObserved.waitUntilStarted()
|
||||
}
|
||||
|
||||
func waitUntilCompleted() async {
|
||||
await completed.waitUntilStarted()
|
||||
}
|
||||
|
||||
func isAuthenticatedSessionCurrent(
|
||||
_ snapshot: AuthenticatedSessionSnapshot
|
||||
) async -> Bool {
|
||||
snapshot == snapshotValue
|
||||
}
|
||||
|
||||
func accessToken() async throws -> String { snapshotValue.accessToken }
|
||||
func storedAccessToken() async -> String? { snapshotValue.accessToken }
|
||||
func refreshToken() async -> String? { snapshotValue.refreshToken }
|
||||
func forceRefreshAccessToken() async throws -> String {
|
||||
snapshotValue.accessToken
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
#endif
|
||||
import Testing
|
||||
@testable import CmuxAuthRuntime
|
||||
|
||||
@Suite(.serialized)
|
||||
struct KeychainStackTokenStoreTests {
|
||||
#if canImport(Security)
|
||||
@Test func clearingLegacyTokensPreservesSameAccountInAnotherService() async throws {
|
||||
let projectID = UUID().uuidString
|
||||
let account = "stack-auth-access-\(projectID)"
|
||||
let unrelatedService = "cmux-test-unrelated-\(UUID().uuidString)"
|
||||
let unrelatedToken = Data("unrelated-token".utf8)
|
||||
let unrelatedQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: unrelatedService,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
_ = SecItemDelete(unrelatedQuery as CFDictionary)
|
||||
defer { _ = SecItemDelete(unrelatedQuery as CFDictionary) }
|
||||
|
||||
var insertion = unrelatedQuery
|
||||
insertion[kSecValueData as String] = unrelatedToken
|
||||
insertion[kSecAttrAccessible as String] =
|
||||
kSecAttrAccessibleAfterFirstUnlock
|
||||
try #require(SecItemAdd(insertion as CFDictionary, nil) == errSecSuccess)
|
||||
|
||||
let store = KeychainStackTokenStore(
|
||||
service: "cmux-test-current-\(UUID().uuidString)",
|
||||
legacyProjectID: projectID
|
||||
)
|
||||
await store.clearTokens()
|
||||
|
||||
var lookup = unrelatedQuery
|
||||
lookup[kSecReturnData as String] = true
|
||||
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
#expect(
|
||||
SecItemCopyMatching(lookup as CFDictionary, &result)
|
||||
== errSecSuccess
|
||||
)
|
||||
#expect(result as? Data == unrelatedToken)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+374
-44
@@ -159,6 +159,20 @@ actor RetryDelayRecorder {
|
||||
// append to the same singleton between this test's reset and its assertion,
|
||||
// failing nondeterministically. `.serialized` removes that interleaving.
|
||||
@Suite(.serialized) struct PushRegistrationServiceTests {
|
||||
private func testPendingUnregisterStoreURL(for suite: String) -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("push-cleanup-\(suite).sqlite3")
|
||||
}
|
||||
|
||||
private func pendingUnregisters(
|
||||
suite: String,
|
||||
accountID: String
|
||||
) -> [PendingUnregister] {
|
||||
(try? PendingUnregisterStore(
|
||||
databaseURL: testPendingUnregisterStoreURL(for: suite)
|
||||
).batch(accountID: accountID, limit: 100)) ?? []
|
||||
}
|
||||
|
||||
private func makeService(
|
||||
tokenProvider: any TokenProviding = FakeTokenProvider()
|
||||
) -> (PushRegistrationService, UserDefaults) {
|
||||
@@ -172,6 +186,9 @@ actor RetryDelayRecorder {
|
||||
bundleID: "dev.cmux.ios",
|
||||
apnsEnvironment: "sandbox",
|
||||
suiteName: suite,
|
||||
pendingUnregisterStoreURL: testPendingUnregisterStoreURL(
|
||||
for: suite
|
||||
),
|
||||
session: URLSession(configuration: configuration)
|
||||
)
|
||||
return (service, defaults)
|
||||
@@ -185,7 +202,10 @@ actor RetryDelayRecorder {
|
||||
seedDefaults: (UserDefaults) -> Void = { _ in },
|
||||
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
|
||||
try await ContinuousClock().sleep(for: $0)
|
||||
}
|
||||
},
|
||||
sessionSnapshotTimeout: Duration = .seconds(15),
|
||||
sessionSnapshotClock: any Clock<Duration> = ContinuousClock(),
|
||||
pendingUnregisterStoreURL: URL? = nil
|
||||
) -> (PushRegistrationService, UserDefaults) {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
seedDefaults(defaults)
|
||||
@@ -209,10 +229,14 @@ actor RetryDelayRecorder {
|
||||
bundleID: "dev.cmux.ios.push1",
|
||||
apnsEnvironment: "sandbox",
|
||||
suiteName: suite,
|
||||
pendingUnregisterStoreURL: pendingUnregisterStoreURL
|
||||
?? testPendingUnregisterStoreURL(for: suite),
|
||||
session: URLSession(configuration: configuration),
|
||||
retryDelays: retryDelays,
|
||||
retryJitter: { _ in 1 },
|
||||
retrySleep: retrySleep
|
||||
retrySleep: retrySleep,
|
||||
sessionSnapshotTimeout: sessionSnapshotTimeout,
|
||||
sessionSnapshotClock: sessionSnapshotClock
|
||||
)
|
||||
return (service, defaults)
|
||||
}
|
||||
@@ -288,6 +312,10 @@ actor RetryDelayRecorder {
|
||||
}
|
||||
#expect(request?.httpMethod == "DELETE")
|
||||
#expect(request?.value(forHTTPHeaderField: "X-Stack-Refresh-Token") == "captured-refresh")
|
||||
#expect(
|
||||
request?.value(forHTTPHeaderField: "X-Cmux-App-Namespace")
|
||||
== "dev.cmux.ios"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func signOutUnregisterNeverFallsBackToLiveProvider() async {
|
||||
@@ -328,14 +356,10 @@ actor RetryDelayRecorder {
|
||||
refreshToken: "captured-refresh"
|
||||
)
|
||||
|
||||
let queueData = defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
)
|
||||
let queue = queueData.flatMap {
|
||||
try? JSONSerialization.jsonObject(with: $0)
|
||||
as? [[String: String]]
|
||||
}
|
||||
#expect(queue == [["tokenHex": "ab", "accountID": "old-user"]])
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "old-user"
|
||||
) == [PendingUnregister(tokenHex: "ab", accountID: "old-user")])
|
||||
#expect(
|
||||
defaults.string(forKey: "cmux.notifications.pendingUnregisterToken")
|
||||
== nil
|
||||
@@ -392,11 +416,14 @@ actor RetryDelayRecorder {
|
||||
forKey: "cmux.notifications.registeredAccountID"
|
||||
) == "account-a"
|
||||
)
|
||||
let queueText = defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
).flatMap { String(data: $0, encoding: .utf8) }
|
||||
#expect(queueText?.contains("account-a") == true)
|
||||
#expect(queueText?.contains("account-b") == false)
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-a"
|
||||
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-b"
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test func legacySignOutCannotProveRegisteredOwnerMatchesCredentials() async {
|
||||
@@ -418,10 +445,10 @@ actor RetryDelayRecorder {
|
||||
)
|
||||
|
||||
#expect(await PushRegistrationURLProtocol.script.requests.isEmpty)
|
||||
let queueText = defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
).flatMap { String(data: $0, encoding: .utf8) }
|
||||
#expect(queueText?.contains("account-a") == true)
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-a"
|
||||
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
|
||||
}
|
||||
|
||||
@Test func enabledWithoutAPNsTokenReportsAwaitingTokenInsteadOfReady() async {
|
||||
@@ -737,13 +764,10 @@ actor RetryDelayRecorder {
|
||||
|
||||
await service.setEnabled(false)
|
||||
|
||||
let persisted = try? JSONDecoder().decode(
|
||||
[[String: String]].self,
|
||||
from: defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
) ?? Data()
|
||||
)
|
||||
#expect(persisted == [["tokenHex": "ab", "accountID": "account-a"]])
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-a"
|
||||
) == [PendingUnregister(tokenHex: "ab", accountID: "account-a")])
|
||||
#expect(await PushRegistrationURLProtocol.script.requests.isEmpty)
|
||||
|
||||
let (returned, _) = makeScriptedService(
|
||||
@@ -767,6 +791,28 @@ actor RetryDelayRecorder {
|
||||
)
|
||||
}
|
||||
|
||||
@Test func relaunchRecoversOptOutCommittedBeforeServiceHandoff() async {
|
||||
await PushRegistrationURLProtocol.script.reset([.response(200)])
|
||||
let (service, _) = makeScriptedService(
|
||||
seedDefaults: { defaults in
|
||||
defaults.set(false, forKey: "cmux.notifications.pushEnabled")
|
||||
defaults.set("ab", forKey: "cmux.notifications.deviceTokenHex")
|
||||
defaults.set(
|
||||
"push-user-1",
|
||||
forKey: "cmux.notifications.registeredAccountID"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
_ = await service.snapshots()
|
||||
await PushRegistrationURLProtocol.script.waitForRequestCount(1)
|
||||
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
|
||||
== ["DELETE"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test func accountBOwnedOptOutNeverDeletesAccountATokenWithBCredentials() async {
|
||||
await PushRegistrationURLProtocol.script.reset([.response(200)])
|
||||
let suite = "push-optout-owner-mismatch-\(UUID().uuidString)"
|
||||
@@ -785,11 +831,14 @@ actor RetryDelayRecorder {
|
||||
await service.setEnabled(false)
|
||||
|
||||
#expect(await PushRegistrationURLProtocol.script.requests.isEmpty)
|
||||
let queueText = defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
).flatMap { String(data: $0, encoding: .utf8) }
|
||||
#expect(queueText?.contains("account-a") == true)
|
||||
#expect(queueText?.contains("account-b") == false)
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-a"
|
||||
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-b"
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test func malformedDeleteAcknowledgementKeepsDurableTombstone() async {
|
||||
@@ -807,10 +856,10 @@ actor RetryDelayRecorder {
|
||||
|
||||
await service.setEnabled(false)
|
||||
|
||||
#expect(
|
||||
defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2")
|
||||
!= nil
|
||||
)
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-a"
|
||||
) == [PendingUnregister(tokenHex: "ab", accountID: "account-a")])
|
||||
#expect(
|
||||
defaults.string(forKey: "cmux.notifications.registeredAccountID")
|
||||
== "account-a"
|
||||
@@ -873,6 +922,177 @@ actor RetryDelayRecorder {
|
||||
)
|
||||
}
|
||||
|
||||
@Test func enablingDuringInFlightDisableRepostsAfterLateDelete() async {
|
||||
let started = TestPhaseSignal()
|
||||
let blocker = TestContinuationBlocker()
|
||||
await PushRegistrationURLProtocol.script.reset([
|
||||
.response(200),
|
||||
.gatedResponse(200, started: started, blocker: blocker),
|
||||
.response(200),
|
||||
.response(200),
|
||||
])
|
||||
let (service, _) = makeScriptedService()
|
||||
await service.register(deviceToken: Data([0xAA]))
|
||||
await service.setEnabled(true)
|
||||
|
||||
let disabling = Task {
|
||||
await service.setEnabled(false)
|
||||
}
|
||||
await started.waitUntilStarted()
|
||||
await service.setEnabled(true)
|
||||
await blocker.release()
|
||||
await disabling.value
|
||||
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
|
||||
== ["POST", "DELETE", "POST", "POST"]
|
||||
)
|
||||
#expect(await service.snapshot.backendState == .registered)
|
||||
}
|
||||
|
||||
@Test func coordinatorIntentWorkerDrainsLatestOptOutAfterLatePost() async {
|
||||
let started = TestPhaseSignal()
|
||||
let blocker = TestContinuationBlocker()
|
||||
await PushRegistrationURLProtocol.script.reset([
|
||||
.gatedResponse(200, started: started, blocker: blocker),
|
||||
.response(200),
|
||||
.response(200),
|
||||
])
|
||||
let suite = "push-ambiguous-post-\(UUID().uuidString)"
|
||||
let (service, _) = makeScriptedService(suite: suite)
|
||||
await service.register(deviceToken: Data([0xAA]))
|
||||
|
||||
await service.applyEnabledIntent(true, generation: 1)
|
||||
await service.reconcileEnabledIntent(generation: 1)
|
||||
await started.waitUntilStarted()
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "push-user-1"
|
||||
) == [PendingUnregister(
|
||||
tokenHex: "aa",
|
||||
accountID: "push-user-1"
|
||||
)])
|
||||
await service.applyEnabledIntent(false, generation: 2)
|
||||
|
||||
// Opt-out cleanup must start while the superseded POST is still
|
||||
// parked. Waiting for that POST could leave the backend token active
|
||||
// indefinitely even though the UI already reports notifications off.
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.waitForRequestCount(2)
|
||||
)
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
|
||||
== ["POST", "DELETE"]
|
||||
)
|
||||
#expect(await service.snapshot == .disabled)
|
||||
|
||||
await blocker.release()
|
||||
await PushRegistrationURLProtocol.script.waitForRequestCount(3)
|
||||
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
|
||||
== ["POST", "DELETE", "DELETE"]
|
||||
)
|
||||
#expect(await service.snapshot == .disabled)
|
||||
}
|
||||
|
||||
@Test func coordinatorIntentAuthenticationHasBoundedSingleAttempt() async {
|
||||
let started = TestPhaseSignal()
|
||||
let blocker = TestContinuationBlocker()
|
||||
let provider = CancellationIgnoringPushTokenProvider(
|
||||
started: started,
|
||||
blocker: blocker
|
||||
)
|
||||
let clock = ManualTestClock()
|
||||
let timeout = Duration.seconds(2)
|
||||
let (service, _) = makeScriptedService(
|
||||
tokenProvider: provider,
|
||||
accountID: nil,
|
||||
sessionSnapshotTimeout: timeout,
|
||||
sessionSnapshotClock: clock
|
||||
)
|
||||
await service.register(deviceToken: Data([0xAA]))
|
||||
|
||||
await service.applyEnabledIntent(true, generation: 1)
|
||||
await service.reconcileEnabledIntent(generation: 1)
|
||||
await started.waitUntilStarted()
|
||||
await clock.waitUntilSleepers()
|
||||
clock.advance(by: timeout)
|
||||
|
||||
#expect(
|
||||
await wait(
|
||||
for: .failed(.authenticationRequired),
|
||||
from: service
|
||||
)
|
||||
)
|
||||
|
||||
// The timed-out provider deliberately ignores cancellation. A newer
|
||||
// enable intent fails against the active phase instead of accumulating
|
||||
// another unowned task behind it.
|
||||
await service.applyEnabledIntent(true, generation: 2)
|
||||
await service.reconcileEnabledIntent(generation: 2)
|
||||
#expect(
|
||||
await wait(
|
||||
for: .failed(.authenticationRequired),
|
||||
from: service
|
||||
)
|
||||
)
|
||||
await provider.waitUntilCancellationObserved()
|
||||
#expect(await provider.snapshotRequestCount == 1)
|
||||
|
||||
await blocker.release()
|
||||
await provider.waitUntilCompleted()
|
||||
}
|
||||
|
||||
@Test func coordinatorOptOutAuthenticationHasBoundedSingleAttempt() async {
|
||||
let started = TestPhaseSignal()
|
||||
let blocker = TestContinuationBlocker()
|
||||
let provider = CancellationIgnoringPushTokenProvider(
|
||||
started: started,
|
||||
blocker: blocker
|
||||
)
|
||||
let clock = ManualTestClock()
|
||||
let timeout = Duration.seconds(2)
|
||||
let (service, _) = makeScriptedService(
|
||||
tokenProvider: provider,
|
||||
accountID: nil,
|
||||
seedDefaults: { defaults in
|
||||
defaults.set(
|
||||
true,
|
||||
forKey: "cmux.notifications.pushEnabled"
|
||||
)
|
||||
defaults.set(
|
||||
"aa",
|
||||
forKey: "cmux.notifications.deviceTokenHex"
|
||||
)
|
||||
defaults.set(
|
||||
"push-user-1",
|
||||
forKey: "cmux.notifications.registeredAccountID"
|
||||
)
|
||||
},
|
||||
sessionSnapshotTimeout: timeout,
|
||||
sessionSnapshotClock: clock
|
||||
)
|
||||
|
||||
let disabling = Task {
|
||||
await service.applyEnabledIntent(false, generation: 1)
|
||||
}
|
||||
await started.waitUntilStarted()
|
||||
await clock.waitUntilSleepers()
|
||||
clock.advance(by: timeout)
|
||||
await provider.waitUntilCancellationObserved()
|
||||
await disabling.value
|
||||
|
||||
// A direct cleanup retry must fail against the still-active timed-out
|
||||
// phase instead of starting a second authentication operation.
|
||||
await service.unregisterFromServer()
|
||||
#expect(await provider.snapshotRequestCount == 1)
|
||||
#expect(await service.snapshot == .disabled)
|
||||
|
||||
await blocker.release()
|
||||
await provider.waitUntilCompleted()
|
||||
}
|
||||
|
||||
@Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async {
|
||||
let started = TestPhaseSignal()
|
||||
let blocker = TestContinuationBlocker()
|
||||
@@ -956,21 +1176,23 @@ actor RetryDelayRecorder {
|
||||
accessToken: "b-access",
|
||||
refreshToken: "b-refresh"
|
||||
)
|
||||
await service.syncTokenIfPossible()
|
||||
let currentSync = Task {
|
||||
await service.syncTokenIfPossible()
|
||||
}
|
||||
await blocker.release()
|
||||
await oldUpload.value
|
||||
await currentSync.value
|
||||
|
||||
let requests = await PushRegistrationURLProtocol.script.requests
|
||||
#expect(
|
||||
requests.map(\.httpMethod)
|
||||
== ["POST", "POST", "DELETE", "POST"]
|
||||
== ["POST", "DELETE", "POST"]
|
||||
)
|
||||
#expect(
|
||||
requests.map {
|
||||
$0.value(forHTTPHeaderField: "Authorization")
|
||||
} == [
|
||||
"Bearer a-access",
|
||||
"Bearer b-access",
|
||||
"Bearer a-access",
|
||||
"Bearer b-access",
|
||||
]
|
||||
@@ -1151,10 +1373,10 @@ actor RetryDelayRecorder {
|
||||
let firstRequests = await PushRegistrationURLProtocol.script.requests
|
||||
#expect(firstRequests.map(\.httpMethod) == ["POST", "DELETE"])
|
||||
#expect(await service.snapshot.backendState == .registered)
|
||||
let pendingText = defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
).flatMap { String(data: $0, encoding: .utf8) }
|
||||
#expect(pendingText?.contains("aa") == true)
|
||||
#expect(pendingUnregisters(
|
||||
suite: suite,
|
||||
accountID: "account-a"
|
||||
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
|
||||
|
||||
await PushRegistrationURLProtocol.script.reset([
|
||||
.response(200),
|
||||
@@ -1305,9 +1527,115 @@ actor RetryDelayRecorder {
|
||||
#expect(deletedTokens == ["aa", "bb", "aa", "bb"])
|
||||
}
|
||||
|
||||
@Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async {
|
||||
@Test func pendingCleanupMigrationMovesEveryEntryToIndexedStore() async throws {
|
||||
let storeURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("push-overflow-\(UUID().uuidString).sqlite3")
|
||||
defer { try? FileManager.default.removeItem(at: storeURL) }
|
||||
let existing = (0..<200).map { index in
|
||||
[
|
||||
"tokenHex": String(format: "%064x", index),
|
||||
"accountID": "historical-account-\(index)",
|
||||
]
|
||||
}
|
||||
let (service, defaults) = makeScriptedService(
|
||||
accountID: nil,
|
||||
seedDefaults: { defaults in
|
||||
defaults.set(
|
||||
try? JSONSerialization.data(withJSONObject: existing),
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
)
|
||||
defaults.set(
|
||||
true,
|
||||
forKey: "cmux.notifications.pushEnabled"
|
||||
)
|
||||
defaults.set(
|
||||
String(repeating: "f", count: 64),
|
||||
forKey: "cmux.notifications.deviceTokenHex"
|
||||
)
|
||||
defaults.set(
|
||||
"current-account",
|
||||
forKey: "cmux.notifications.registeredAccountID"
|
||||
)
|
||||
},
|
||||
pendingUnregisterStoreURL: storeURL
|
||||
)
|
||||
|
||||
await service.applyEnabledIntent(false, generation: 1)
|
||||
|
||||
#expect(defaults.data(
|
||||
forKey: "cmux.notifications.pendingUnregisters.v2"
|
||||
) == nil)
|
||||
let store = try PendingUnregisterStore(databaseURL: storeURL)
|
||||
let oldest = store.batch(
|
||||
accountID: "historical-account-0",
|
||||
limit: 2
|
||||
)
|
||||
let newest = store.batch(accountID: "current-account", limit: 2)
|
||||
#expect(oldest.map(\.accountID) == ["historical-account-0"])
|
||||
#expect(newest.map(\.accountID) == ["current-account"])
|
||||
}
|
||||
|
||||
@Test func durableCleanupStoreReopensAfterLaunchFailure() async throws {
|
||||
await PushRegistrationURLProtocol.script.reset([.response(200)])
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
"push-store-reopen-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
let parkedRoot = root.appendingPathExtension("parked")
|
||||
let storeURL = root.appendingPathComponent("cleanup.sqlite3")
|
||||
defer {
|
||||
try? fileManager.removeItem(at: root)
|
||||
try? fileManager.removeItem(at: parkedRoot)
|
||||
}
|
||||
do {
|
||||
let store = try PendingUnregisterStore(databaseURL: storeURL)
|
||||
#expect(store.insert(PendingUnregister(
|
||||
tokenHex: "aa",
|
||||
accountID: "account-a"
|
||||
)))
|
||||
}
|
||||
try fileManager.moveItem(at: root, to: parkedRoot)
|
||||
#expect(fileManager.createFile(atPath: root.path, contents: Data()))
|
||||
|
||||
let (service, _) = makeScriptedService(
|
||||
accountID: "account-a",
|
||||
pendingUnregisterStoreURL: storeURL
|
||||
)
|
||||
|
||||
try fileManager.removeItem(at: root)
|
||||
try fileManager.moveItem(at: parkedRoot, to: root)
|
||||
_ = await service.snapshots()
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.waitForRequestCount(1)
|
||||
)
|
||||
#expect(
|
||||
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
|
||||
== ["DELETE"]
|
||||
)
|
||||
let reopenedStore = try PendingUnregisterStore(databaseURL: storeURL)
|
||||
var cleanupFinished = false
|
||||
for _ in 0..<1_000 {
|
||||
if reopenedStore.batch(accountID: "account-a", limit: 2).isEmpty {
|
||||
cleanupFinished = true
|
||||
break
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(cleanupFinished)
|
||||
}
|
||||
|
||||
@Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async throws {
|
||||
await PushRegistrationURLProtocol.script.reset([.response(200)])
|
||||
let suite = "push-owner-reassignment-\(UUID().uuidString)"
|
||||
let storeURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("push-owner-\(UUID().uuidString).sqlite3")
|
||||
defer { try? FileManager.default.removeItem(at: storeURL) }
|
||||
let overflowStore = try PendingUnregisterStore(databaseURL: storeURL)
|
||||
#expect(overflowStore.insert(PendingUnregister(
|
||||
tokenHex: "ab",
|
||||
accountID: "old-user"
|
||||
)))
|
||||
let (service, defaults) = makeScriptedService(
|
||||
tokenProvider: FakeTokenProvider(
|
||||
access: "new-access",
|
||||
@@ -1329,7 +1657,8 @@ actor RetryDelayRecorder {
|
||||
"old-user",
|
||||
forKey: "cmux.notifications.pendingUnregisterAccountID"
|
||||
)
|
||||
}
|
||||
},
|
||||
pendingUnregisterStoreURL: storeURL
|
||||
)
|
||||
|
||||
await service.setEnabled(true)
|
||||
@@ -1346,6 +1675,7 @@ actor RetryDelayRecorder {
|
||||
defaults.string(forKey: "cmux.notifications.pendingUnregisterAccountID")
|
||||
== nil
|
||||
)
|
||||
#expect(!overflowStore.hasEntries)
|
||||
}
|
||||
|
||||
@Test func legacySingleTombstoneMigratesOnceAndIsRemovedAfterSuccess() async {
|
||||
|
||||
+31
@@ -36,6 +36,16 @@ public struct CmxIrohBackpressuredClientBroker:
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether the wrapped client retains request authorization.
|
||||
public func hasBindingAuthorization() async -> Bool {
|
||||
await broker.hasBindingAuthorization()
|
||||
}
|
||||
|
||||
/// Returns the binding ID represented by the wrapped client's proof.
|
||||
public func bindingAuthorizationID() async -> String? {
|
||||
await broker.bindingAuthorizationID()
|
||||
}
|
||||
|
||||
public func discover() async throws -> CmxIrohDiscoveryResponse {
|
||||
try await gate.perform(accountID: accountID, operation: .discovery) {
|
||||
try await broker.discover()
|
||||
@@ -81,6 +91,20 @@ public struct CmxIrohBackpressuredClientBroker:
|
||||
try await broker.revoke(bindingID: bindingID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes an older same-device binding through the wrapped stale route.
|
||||
public func revokeStale(bindingID: String) async throws {
|
||||
try await gate.perform(accountID: accountID, operation: .revocation) {
|
||||
try await broker.revokeStale(bindingID: bindingID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes one same-build Mac through the wrapped account-management path.
|
||||
public func forgetMac(bindingID: String) async throws {
|
||||
try await gate.perform(accountID: accountID, operation: .revocation) {
|
||||
try await broker.forgetMac(bindingID: bindingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation-gated host broker used by an account-owned Mac runtime.
|
||||
@@ -160,6 +184,13 @@ public struct CmxIrohBackpressuredHostBroker:
|
||||
try await broker.revoke(bindingID: bindingID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes an older same-device binding through the wrapped stale route.
|
||||
public func revokeStale(bindingID: String) async throws {
|
||||
try await gate.perform(accountID: accountID, operation: .revocation) {
|
||||
try await broker.revokeStale(bindingID: bindingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation-gated relay-policy broker sharing a runtime's account gate.
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
public import CMUXMobileCore
|
||||
|
||||
/// Proof material that lets a fresh broker client act as one registered binding.
|
||||
public struct CmxIrohBindingRequestAuthorization: Sendable {
|
||||
/// The exact broker binding whose endpoint key signs each request.
|
||||
public let bindingID: String
|
||||
|
||||
/// The exact app namespace recorded on the authorized binding.
|
||||
public let clientNamespace: String
|
||||
|
||||
let signer: CmxIrohRegistrationSigner
|
||||
|
||||
/// Reconstructs request authorization from retained binding and identity state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bindingID: The exact registered broker binding identifier.
|
||||
/// - clientNamespace: The exact namespace recorded during registration.
|
||||
/// - identity: The endpoint identity material that owns the binding.
|
||||
/// - endpointID: The endpoint identifier recorded on the binding.
|
||||
/// - Throws: ``CmxIrohRegistrationError/endpointIdentityMismatch`` when the
|
||||
/// supplied identity does not derive the recorded endpoint.
|
||||
public init(
|
||||
bindingID: String,
|
||||
clientNamespace: String,
|
||||
identity: CmxIrohIdentityMaterial,
|
||||
endpointID: CmxIrohPeerIdentity
|
||||
) throws {
|
||||
self.bindingID = bindingID
|
||||
self.clientNamespace = clientNamespace
|
||||
signer = try CmxIrohRegistrationSigner(
|
||||
identity: identity,
|
||||
endpointID: endpointID.endpointID
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
bindingID: String,
|
||||
clientNamespace: String,
|
||||
signer: CmxIrohRegistrationSigner
|
||||
) {
|
||||
self.bindingID = bindingID
|
||||
self.clientNamespace = clientNamespace
|
||||
self.signer = signer
|
||||
}
|
||||
}
|
||||
+4
@@ -6,4 +6,8 @@ public protocol CmxIrohBindingRevoking: Sendable {
|
||||
///
|
||||
/// - Parameter bindingID: The broker-owned lowercase binding UUID.
|
||||
func revoke(bindingID: String) async throws
|
||||
|
||||
/// Revokes an older same-device binding through the account-scoped stale
|
||||
/// cleanup route, rather than pretending the caller owns that ID.
|
||||
func revokeStale(bindingID: String) async throws
|
||||
}
|
||||
|
||||
+14
-10
@@ -7,6 +7,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
case bindingID
|
||||
case deviceID
|
||||
case appInstanceID
|
||||
case clientNamespace
|
||||
case tag
|
||||
case platform
|
||||
case endpointID
|
||||
@@ -23,6 +24,9 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
/// The installation's broker-facing app-instance UUID.
|
||||
public let appInstanceID: String
|
||||
|
||||
/// The exact app namespace that owns the binding.
|
||||
public let clientNamespace: String
|
||||
|
||||
/// The build tag registered with the broker.
|
||||
public let tag: String
|
||||
|
||||
@@ -44,6 +48,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
/// - bindingID: The broker-owned lowercase binding UUID.
|
||||
/// - deviceID: The account device's lowercase UUID.
|
||||
/// - appInstanceID: The installation's lowercase app-instance UUID.
|
||||
/// - clientNamespace: The exact bundle-derived app namespace.
|
||||
/// - tag: The safe build tag sent during registration.
|
||||
/// - platform: The endpoint's platform role.
|
||||
/// - endpointID: The registered Iroh endpoint identity.
|
||||
@@ -54,6 +59,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
bindingID: String,
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
clientNamespace: String = "legacy",
|
||||
tag: String,
|
||||
platform: CmxIrohPlatform,
|
||||
endpointID: CmxIrohPeerIdentity,
|
||||
@@ -63,13 +69,15 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
guard Self.isCanonicalUUID(bindingID),
|
||||
Self.isCanonicalUUID(deviceID),
|
||||
Self.isCanonicalUUID(appInstanceID),
|
||||
Self.isSafeTag(tag),
|
||||
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
|
||||
cmxIrohIsSafeToken(tag),
|
||||
(1 ... Int(Int32.max)).contains(identityGeneration) else {
|
||||
throw CmxIrohBrokerCredentialRepositoryError.invalidBinding
|
||||
}
|
||||
self.bindingID = bindingID
|
||||
self.deviceID = deviceID
|
||||
self.appInstanceID = appInstanceID
|
||||
self.clientNamespace = clientNamespace
|
||||
self.tag = tag
|
||||
self.platform = platform
|
||||
self.endpointID = endpointID
|
||||
@@ -84,6 +92,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
bindingID = binding.bindingID
|
||||
deviceID = binding.deviceID
|
||||
appInstanceID = binding.appInstanceID
|
||||
clientNamespace = binding.clientNamespace
|
||||
tag = binding.tag
|
||||
platform = binding.platform
|
||||
endpointID = binding.endpointID
|
||||
@@ -101,6 +110,10 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
bindingID: container.decode(String.self, forKey: .bindingID),
|
||||
deviceID: container.decode(String.self, forKey: .deviceID),
|
||||
appInstanceID: container.decode(String.self, forKey: .appInstanceID),
|
||||
clientNamespace: container.decodeIfPresent(
|
||||
String.self,
|
||||
forKey: .clientNamespace
|
||||
) ?? "legacy",
|
||||
tag: container.decode(String.self, forKey: .tag),
|
||||
platform: container.decode(CmxIrohPlatform.self, forKey: .platform),
|
||||
endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID),
|
||||
@@ -116,13 +129,4 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
UUID(uuidString: value)?.uuidString.lowercased() == value
|
||||
}
|
||||
|
||||
private static func isSafeTag(_ value: String) -> Bool {
|
||||
guard (1 ... 64).contains(value.utf8.count) else { return false }
|
||||
return value.utf8.allSatisfy { byte in
|
||||
(48 ... 57).contains(byte)
|
||||
|| (65 ... 90).contains(byte)
|
||||
|| (97 ... 122).contains(byte)
|
||||
|| [45, 46, 58, 95].contains(byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -7,6 +7,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
case bindingID = "binding_id"
|
||||
case deviceID = "device_id"
|
||||
case appInstanceID = "app_instance_id"
|
||||
case clientNamespace = "client_namespace"
|
||||
case tag
|
||||
case platform
|
||||
case displayName = "display_name"
|
||||
@@ -22,6 +23,9 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
public let bindingID: String
|
||||
public let deviceID: String
|
||||
public let appInstanceID: String
|
||||
|
||||
/// The exact bundle-derived app namespace that owns this binding.
|
||||
public let clientNamespace: String
|
||||
public let tag: String
|
||||
public let platform: CmxIrohPlatform
|
||||
public let displayName: String?
|
||||
@@ -38,6 +42,10 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
let bindingID = try container.decode(String.self, forKey: .bindingID)
|
||||
let deviceID = try container.decode(String.self, forKey: .deviceID)
|
||||
let appInstanceID = try container.decode(String.self, forKey: .appInstanceID)
|
||||
let clientNamespace = try container.decodeIfPresent(
|
||||
String.self,
|
||||
forKey: .clientNamespace
|
||||
) ?? "legacy"
|
||||
let tag = try container.decode(String.self, forKey: .tag)
|
||||
let endpointID = try container.decode(String.self, forKey: .endpointID)
|
||||
let identityGeneration = try container.decode(Int.self, forKey: .identityGeneration)
|
||||
@@ -52,11 +60,12 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
guard Self.isCanonicalUUID(bindingID),
|
||||
Self.isCanonicalUUID(deviceID),
|
||||
Self.isCanonicalUUID(appInstanceID),
|
||||
Self.isSafeToken(tag),
|
||||
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
|
||||
cmxIrohIsSafeToken(tag),
|
||||
(1 ... Int(Int32.max)).contains(identityGeneration),
|
||||
capabilities.count <= 32,
|
||||
Set(capabilities).count == capabilities.count,
|
||||
capabilities.allSatisfy(Self.isSafeToken),
|
||||
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }),
|
||||
displayName.map(Self.isSafeDisplayName) ?? true,
|
||||
pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount,
|
||||
pathHints.filter({ $0.kind == .relayURL }).count <= 2,
|
||||
@@ -72,6 +81,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
self.bindingID = bindingID
|
||||
self.deviceID = deviceID
|
||||
self.appInstanceID = appInstanceID
|
||||
self.clientNamespace = clientNamespace
|
||||
self.tag = tag
|
||||
platform = try container.decode(CmxIrohPlatform.self, forKey: .platform)
|
||||
self.displayName = displayName
|
||||
@@ -89,6 +99,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
try container.encode(bindingID, forKey: .bindingID)
|
||||
try container.encode(deviceID, forKey: .deviceID)
|
||||
try container.encode(appInstanceID, forKey: .appInstanceID)
|
||||
try container.encode(clientNamespace, forKey: .clientNamespace)
|
||||
try container.encode(tag, forKey: .tag)
|
||||
try container.encode(platform, forKey: .platform)
|
||||
try container.encodeIfPresent(displayName, forKey: .displayName)
|
||||
@@ -105,16 +116,6 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
|
||||
UUID(uuidString: value)?.uuidString.lowercased() == value
|
||||
}
|
||||
|
||||
private static func isSafeToken(_ value: String) -> Bool {
|
||||
guard (1 ... 64).contains(value.utf8.count) else { return false }
|
||||
return value.utf8.allSatisfy { byte in
|
||||
(48 ... 57).contains(byte)
|
||||
|| (65 ... 90).contains(byte)
|
||||
|| (97 ... 122).contains(byte)
|
||||
|| [45, 46, 58, 95].contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isSafeDisplayName(_ value: String) -> Bool {
|
||||
!value.isEmpty
|
||||
&& value.utf16.count <= 128
|
||||
|
||||
+3
@@ -4,6 +4,8 @@ public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable {
|
||||
public let deviceId: String
|
||||
/// Stable app-instance UUID.
|
||||
public let appInstanceId: String
|
||||
/// Exact app namespace that owns the prospective binding.
|
||||
public let clientNamespace: String
|
||||
/// Safe build or app-instance tag.
|
||||
public let tag: String
|
||||
/// Exact Iroh EndpointID that will sign the challenge.
|
||||
@@ -16,6 +18,7 @@ public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable {
|
||||
init(payload: CmxIrohRegistrationPayload, payloadSHA256: String) {
|
||||
deviceId = payload.deviceID
|
||||
appInstanceId = payload.appInstanceID
|
||||
clientNamespace = payload.clientNamespace
|
||||
tag = payload.tag
|
||||
endpointId = payload.endpointID
|
||||
identityGeneration = payload.identityGeneration
|
||||
|
||||
+17
@@ -10,10 +10,27 @@ public protocol CmxIrohClientBrokerServing: CmxIrohRegistryServing,
|
||||
prepared: CmxIrohPreparedRegistration,
|
||||
signer: CmxIrohRegistrationSigner
|
||||
) async throws -> CmxIrohRegistrationResponse
|
||||
|
||||
/// Reports whether signed post-registration broker requests can be made.
|
||||
/// A rate-limited registration cannot establish this proof on a cold start.
|
||||
func hasBindingAuthorization() async -> Bool
|
||||
|
||||
/// Returns the binding ID represented by the retained request proof.
|
||||
func bindingAuthorizationID() async -> String?
|
||||
|
||||
/// Revokes one same-build Mac through the explicit account-management path.
|
||||
func forgetMac(bindingID: String) async throws
|
||||
}
|
||||
|
||||
public extension CmxIrohClientBrokerServing {
|
||||
/// Accepts the operation when a conformer does not impose a local broker floor.
|
||||
func preflight(operation _: CmxIrohBrokerOperation) async throws {}
|
||||
|
||||
/// Reports no retained request proof for conformers that do not persist one.
|
||||
func hasBindingAuthorization() async -> Bool { false }
|
||||
|
||||
/// Reports no retained binding ID for conformers that do not persist proof.
|
||||
func bindingAuthorizationID() async -> String? { nil }
|
||||
}
|
||||
|
||||
extension CmxIrohTrustBrokerClient: CmxIrohClientBrokerServing {}
|
||||
|
||||
+2
-1
@@ -456,6 +456,7 @@ public actor CmxIrohClientOfflinePolicyCache {
|
||||
left.bindingID == right.bindingID
|
||||
&& left.deviceID == right.deviceID
|
||||
&& left.appInstanceID == right.appInstanceID
|
||||
&& left.clientNamespace == right.clientNamespace
|
||||
&& left.tag == right.tag
|
||||
&& left.platform == right.platform
|
||||
&& left.endpointID == right.endpointID
|
||||
@@ -469,7 +470,7 @@ public actor CmxIrohClientOfflinePolicyCache {
|
||||
for expectation: CmxIrohClientOfflinePolicyExpectation
|
||||
) -> String {
|
||||
let transcript = Data(
|
||||
"cmux/iroh/offline-client-policy-scope/v1\0\(expectation.accountID)\0\(expectation.localBindingExpectation.appInstanceID)".utf8
|
||||
"cmux/iroh/offline-client-policy-scope/v2\0\(expectation.accountID)\0\(expectation.localBindingExpectation.clientNamespace)\0\(expectation.localBindingExpectation.appInstanceID)".utf8
|
||||
)
|
||||
return SHA256.hash(data: transcript)
|
||||
.map { String(format: "%02x", $0) }
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ public import Foundation
|
||||
extension CmxIrohClientRuntime {
|
||||
func performSignOut(
|
||||
pendingRevocation: CmxIrohPendingRevocation?,
|
||||
bindingAuthorization: CmxIrohBindingRequestAuthorization?,
|
||||
revision: UInt64
|
||||
) async -> CmxIrohClientSignOutPreparation {
|
||||
async let wasPersisted = Self.persist(pendingRevocation, to: pendingRevocations)
|
||||
@@ -10,7 +11,8 @@ extension CmxIrohClientRuntime {
|
||||
let (persisted, _) = await (wasPersisted, networkTeardown)
|
||||
let preparation = CmxIrohClientSignOutPreparation(
|
||||
pendingRevocation: pendingRevocation,
|
||||
wasPersisted: persisted
|
||||
wasPersisted: persisted,
|
||||
bindingAuthorization: bindingAuthorization
|
||||
)
|
||||
|
||||
guard lifecyclePhase == .signingOut,
|
||||
|
||||
+31
-7
@@ -15,6 +15,7 @@ extension CmxIrohClientRuntime {
|
||||
let expectation = try CmxIrohLocalBindingExpectation(
|
||||
deviceID: configuration.deviceID,
|
||||
appInstanceID: configuration.appInstanceID,
|
||||
clientNamespace: configuration.clientNamespace,
|
||||
tag: configuration.tag,
|
||||
platform: .ios,
|
||||
endpointID: expectedEndpointID,
|
||||
@@ -101,6 +102,7 @@ extension CmxIrohClientRuntime {
|
||||
)
|
||||
let prepared = try signer.prepare(payload: payload)
|
||||
let registration: CmxIrohRegistrationResponse?
|
||||
var registrationFailure: (any Error)?
|
||||
do {
|
||||
registration = try await broker.register(prepared: prepared, signer: signer)
|
||||
} catch {
|
||||
@@ -108,6 +110,7 @@ extension CmxIrohClientRuntime {
|
||||
// Registration backpressure blocks mutation, while a fresh
|
||||
// authenticated discovery can still confirm an existing tuple.
|
||||
registration = nil
|
||||
registrationFailure = error
|
||||
} else {
|
||||
guard !prefetchedDiscoveryRejectedCachedBinding,
|
||||
Self.recoversWithCachedPolicy(error),
|
||||
@@ -130,12 +133,38 @@ extension CmxIrohClientRuntime {
|
||||
if let registration, !expectation.matches(registration.binding) {
|
||||
throw CmxIrohClientRuntimeError.invalidLocalBinding
|
||||
}
|
||||
if registration == nil,
|
||||
!(await broker.hasBindingAuthorization()) {
|
||||
// No registration response means this broker instance did not get
|
||||
// a chance to install fresh proof. Do not drain revocations or
|
||||
// issue namespaced discovery requests without persisted proof.
|
||||
throw registrationFailure
|
||||
?? CmxIrohTrustBrokerClientError.invalidAuthentication
|
||||
}
|
||||
if registration != nil {
|
||||
lastRegistrationRefreshState = refreshState
|
||||
}
|
||||
let revokedPendingBinding: Bool
|
||||
let activeBindingID: String?
|
||||
if let registration {
|
||||
activeBindingID = registration.binding.bindingID
|
||||
} else {
|
||||
activeBindingID = await broker.bindingAuthorizationID()
|
||||
}
|
||||
guard let activeBindingID else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidAuthentication
|
||||
}
|
||||
revokedPendingBinding = try await pendingRevocations.reconcilePending(
|
||||
accountID: configuration.accountID,
|
||||
beforeRegisteringTag: configuration.tag,
|
||||
activeBindingID: activeBindingID,
|
||||
using: broker
|
||||
)
|
||||
try requireCurrent(revision)
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
do {
|
||||
if let embedded = registration?.discovery,
|
||||
if !revokedPendingBinding,
|
||||
let embedded = registration?.discovery,
|
||||
registration?.embeddedDiscoveryComplete == true {
|
||||
guard let snapshotRevision = embedded.revision,
|
||||
let registrationRevision = registration?.revision,
|
||||
@@ -264,6 +293,7 @@ extension CmxIrohClientRuntime {
|
||||
return try CmxIrohRegistrationPayload(
|
||||
deviceID: configuration.deviceID,
|
||||
appInstanceID: configuration.appInstanceID,
|
||||
clientNamespace: configuration.clientNamespace,
|
||||
tag: configuration.tag,
|
||||
platform: .ios,
|
||||
displayName: configuration.displayName,
|
||||
@@ -287,12 +317,6 @@ extension CmxIrohClientRuntime {
|
||||
}
|
||||
|
||||
func preparePolicyResolution(revision: UInt64) async throws {
|
||||
try await pendingRevocations.revokePending(
|
||||
accountID: configuration.accountID,
|
||||
beforeRegisteringTag: configuration.tag,
|
||||
using: broker
|
||||
)
|
||||
try requireCurrent(revision)
|
||||
try await broker.preflight(operation: .discovery)
|
||||
try requireCurrent(revision)
|
||||
}
|
||||
|
||||
+1
@@ -90,6 +90,7 @@ extension CmxIrohClientRuntime {
|
||||
let expectation = try CmxIrohLocalBindingExpectation(
|
||||
deviceID: binding.deviceID,
|
||||
appInstanceID: binding.appInstanceID,
|
||||
clientNamespace: binding.clientNamespace,
|
||||
tag: binding.tag,
|
||||
platform: binding.platform,
|
||||
endpointID: binding.endpointID,
|
||||
|
||||
+10
@@ -326,6 +326,7 @@ public actor CmxIrohClientRuntime {
|
||||
let expectation = try CmxIrohLocalBindingExpectation(
|
||||
deviceID: configuration.deviceID,
|
||||
appInstanceID: configuration.appInstanceID,
|
||||
clientNamespace: configuration.clientNamespace,
|
||||
tag: configuration.tag,
|
||||
platform: .ios,
|
||||
endpointID: liveEndpointIdentity,
|
||||
@@ -733,6 +734,14 @@ public actor CmxIrohClientRuntime {
|
||||
bindingID: binding.bindingID
|
||||
)
|
||||
}
|
||||
let bindingAuthorization = localBinding.flatMap { binding in
|
||||
try? CmxIrohBindingRequestAuthorization(
|
||||
bindingID: binding.bindingID,
|
||||
clientNamespace: binding.clientNamespace,
|
||||
identity: configuration.identity,
|
||||
endpointID: binding.endpointID
|
||||
)
|
||||
}
|
||||
lifecyclePhase = .signingOut
|
||||
lifecycleRevision &+= 1
|
||||
let revision = lifecycleRevision
|
||||
@@ -745,6 +754,7 @@ public actor CmxIrohClientRuntime {
|
||||
let operation = Task {
|
||||
await self.performSignOut(
|
||||
pendingRevocation: pendingRevocation,
|
||||
bindingAuthorization: bindingAuthorization,
|
||||
revision: revision
|
||||
)
|
||||
}
|
||||
|
||||
+5
@@ -11,6 +11,9 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
|
||||
/// The account-and-build-scoped app-instance UUID.
|
||||
public let appInstanceID: String
|
||||
|
||||
/// Exact installed-app namespace sent to every broker request.
|
||||
public let clientNamespace: String
|
||||
|
||||
/// The release channel or tagged-build scope registered with the broker.
|
||||
public let tag: String
|
||||
|
||||
@@ -61,6 +64,7 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
|
||||
accountID: String,
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
clientNamespace: String,
|
||||
tag: String,
|
||||
displayName: String?,
|
||||
identity: CmxIrohIdentityMaterial,
|
||||
@@ -73,6 +77,7 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
|
||||
self.accountID = accountID
|
||||
self.deviceID = cmxCanonicalDeviceID(deviceID)
|
||||
self.appInstanceID = appInstanceID.lowercased()
|
||||
self.clientNamespace = clientNamespace
|
||||
self.tag = tag
|
||||
self.displayName = displayName
|
||||
self.identity = identity
|
||||
|
||||
+20
-1
@@ -6,6 +6,9 @@ public struct CmxIrohSignOutPreparation: Equatable, Sendable {
|
||||
/// Whether the first device-only persistence attempt succeeded.
|
||||
public let wasPersisted: Bool
|
||||
|
||||
/// In-memory proof retained across local identity deletion for immediate revoke.
|
||||
public let bindingAuthorization: CmxIrohBindingRequestAuthorization?
|
||||
|
||||
/// The broker binding to revoke, or `nil` before registration.
|
||||
public var bindingID: String? { pendingRevocation?.bindingID }
|
||||
|
||||
@@ -14,12 +17,28 @@ public struct CmxIrohSignOutPreparation: Equatable, Sendable {
|
||||
/// - Parameters:
|
||||
/// - pendingRevocation: The validated prior binding, or `nil` before registration.
|
||||
/// - wasPersisted: Whether it was durably queued before local teardown.
|
||||
/// - bindingAuthorization: Ephemeral proof for a fresh captured-token client.
|
||||
public init(
|
||||
pendingRevocation: CmxIrohPendingRevocation?,
|
||||
wasPersisted: Bool
|
||||
wasPersisted: Bool,
|
||||
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil
|
||||
) {
|
||||
self.pendingRevocation = pendingRevocation
|
||||
self.wasPersisted = pendingRevocation == nil || wasPersisted
|
||||
self.bindingAuthorization = bindingAuthorization
|
||||
}
|
||||
|
||||
/// Compares durable state and the authorized binding without exposing key bytes.
|
||||
public static func == (
|
||||
lhs: CmxIrohSignOutPreparation,
|
||||
rhs: CmxIrohSignOutPreparation
|
||||
) -> Bool {
|
||||
lhs.pendingRevocation == rhs.pendingRevocation
|
||||
&& lhs.wasPersisted == rhs.wasPersisted
|
||||
&& lhs.bindingAuthorization?.bindingID
|
||||
== rhs.bindingAuthorization?.bindingID
|
||||
&& lhs.bindingAuthorization?.clientNamespace
|
||||
== rhs.bindingAuthorization?.clientNamespace
|
||||
}
|
||||
|
||||
/// Revokes the captured binding with a broker authenticated from captured tokens.
|
||||
|
||||
+14
-13
@@ -6,25 +6,26 @@ public import Foundation
|
||||
/// access group, so the data-protection Keychain returns
|
||||
/// `errSecMissingEntitlement`. Production compositions must keep using
|
||||
/// ``CmxIrohKeychainIdentityStore``.
|
||||
public final class CmxIrohDevelopmentFileIdentityStore:
|
||||
CmxIrohSecureIdentityStoring,
|
||||
@unchecked Sendable
|
||||
public actor CmxIrohDevelopmentFileIdentityStore:
|
||||
CmxIrohSecureIdentityStoring
|
||||
{
|
||||
private let directory: URL
|
||||
nonisolated private let directory: URL
|
||||
|
||||
/// Creates a store inside a tag-specific application-support directory.
|
||||
public init(directory: URL) {
|
||||
self.directory = directory
|
||||
}
|
||||
|
||||
public func read(account: String) throws -> Data? {
|
||||
/// Loads one development identity record.
|
||||
public func read(account: String) async throws -> Data? {
|
||||
try CmxIrohDevelopmentFileStorage.read(
|
||||
account: account,
|
||||
directory: directory
|
||||
)
|
||||
}
|
||||
|
||||
public func write(_ data: Data, account: String) throws {
|
||||
/// Replaces one development identity record.
|
||||
public func write(_ data: Data, account: String) async throws {
|
||||
try CmxIrohDevelopmentFileStorage.write(
|
||||
data,
|
||||
account: account,
|
||||
@@ -33,11 +34,9 @@ public final class CmxIrohDevelopmentFileIdentityStore:
|
||||
}
|
||||
|
||||
/// Whether ANY identity record file exists, without reading or creating
|
||||
/// one. Development-build counterpart of
|
||||
/// ``CmxIrohKeychainIdentityStore/containsAnyRecord()``; file storage lives
|
||||
/// in the app container (which CAN travel in a backup), an accepted
|
||||
/// dev-only weakening of the continuity signal.
|
||||
public func containsAnyRecord() -> Bool {
|
||||
/// one. File storage lives in the app container (which CAN travel in a
|
||||
/// backup), an accepted dev-only weakening of the continuity signal.
|
||||
public nonisolated func containsAnyRecord() -> Bool {
|
||||
let entries = (try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
@@ -45,14 +44,16 @@ public final class CmxIrohDevelopmentFileIdentityStore:
|
||||
return !entries.isEmpty
|
||||
}
|
||||
|
||||
public func delete(account: String) throws {
|
||||
/// Removes one development identity record.
|
||||
public func delete(account: String) async throws {
|
||||
try CmxIrohDevelopmentFileStorage.delete(
|
||||
account: account,
|
||||
directory: directory
|
||||
)
|
||||
}
|
||||
|
||||
public func deleteAll() throws {
|
||||
/// Removes every development identity record in this store.
|
||||
public func deleteAll() async throws {
|
||||
try CmxIrohDevelopmentFileStorage.deleteAll(in: directory)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -197,6 +197,7 @@ public actor CmxIrohHostPolicyCache {
|
||||
let binding = policy.binding
|
||||
guard binding.deviceID == expectation.deviceID,
|
||||
binding.appInstanceID == expectation.appInstanceID,
|
||||
binding.clientNamespace == expectation.clientNamespace,
|
||||
binding.tag == expectation.tag,
|
||||
binding.platform == .mac,
|
||||
binding.endpointID == expectation.endpointID,
|
||||
@@ -235,7 +236,7 @@ public actor CmxIrohHostPolicyCache {
|
||||
for expectation: CmxIrohHostPolicyExpectation
|
||||
) -> String {
|
||||
let transcript = Data(
|
||||
"cmux/iroh/offline-host-policy-scope/v1\0\(expectation.accountID)\0\(expectation.appInstanceID)".utf8
|
||||
"cmux/iroh/offline-host-policy-scope/v2\0\(expectation.accountID)\0\(expectation.clientNamespace)\0\(expectation.appInstanceID)".utf8
|
||||
)
|
||||
return SHA256.hash(data: transcript)
|
||||
.map { String(format: "%02x", $0) }
|
||||
|
||||
+9
-11
@@ -12,6 +12,9 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
|
||||
/// The current app-instance UUID, which changes when the account or build tag changes.
|
||||
public let appInstanceID: String
|
||||
|
||||
/// The exact Mac app namespace that owns this endpoint.
|
||||
public let clientNamespace: String
|
||||
|
||||
/// The build tag registered with the trust broker.
|
||||
public let tag: String
|
||||
|
||||
@@ -36,6 +39,7 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
|
||||
/// - accountID: The current authenticated account identifier.
|
||||
/// - deviceID: The account device's lowercase UUID.
|
||||
/// - appInstanceID: The installation's lowercase app-instance UUID.
|
||||
/// - clientNamespace: The exact bundle-derived app namespace.
|
||||
/// - tag: The safe build tag used for broker registration.
|
||||
/// - endpointID: The current local Iroh EndpointID.
|
||||
/// - identityGeneration: The positive local identity generation.
|
||||
@@ -46,6 +50,7 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
|
||||
accountID: String,
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
clientNamespace: String = "legacy",
|
||||
tag: String,
|
||||
endpointID: CmxIrohPeerIdentity,
|
||||
identityGeneration: Int,
|
||||
@@ -56,16 +61,18 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
|
||||
accountID.utf8.count <= 1_024,
|
||||
Self.isCanonicalUUID(deviceID),
|
||||
Self.isCanonicalUUID(appInstanceID),
|
||||
Self.isSafeToken(tag),
|
||||
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
|
||||
cmxIrohIsSafeToken(tag),
|
||||
(1 ... Int(Int32.max)).contains(identityGeneration),
|
||||
capabilities.count <= 32,
|
||||
Set(capabilities).count == capabilities.count,
|
||||
capabilities.allSatisfy(Self.isSafeToken) else {
|
||||
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }) else {
|
||||
throw CmxIrohHostPolicyCacheError.invalidExpectation
|
||||
}
|
||||
self.accountID = accountID
|
||||
self.deviceID = deviceID
|
||||
self.appInstanceID = appInstanceID
|
||||
self.clientNamespace = clientNamespace
|
||||
self.tag = tag
|
||||
self.endpointID = endpointID
|
||||
self.identityGeneration = identityGeneration
|
||||
@@ -77,13 +84,4 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
|
||||
UUID(uuidString: value)?.uuidString.lowercased() == value
|
||||
}
|
||||
|
||||
private static func isSafeToken(_ value: String) -> Bool {
|
||||
guard (1 ... 64).contains(value.utf8.count) else { return false }
|
||||
return value.utf8.allSatisfy { byte in
|
||||
(48 ... 57).contains(byte)
|
||||
|| (65 ... 90).contains(byte)
|
||||
|| (97 ... 122).contains(byte)
|
||||
|| [45, 46, 58, 95].contains(byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-15
@@ -7,13 +7,11 @@ extension CmxIrohHostRuntime {
|
||||
expectedEndpointID: CmxIrohPeerIdentity,
|
||||
revision: UInt64
|
||||
) async throws -> ResolvedPolicy {
|
||||
try await revokePendingBeforeRegistration()
|
||||
try requireCurrent(revision)
|
||||
var failureCount = 0
|
||||
while true {
|
||||
try requireCurrent(revision)
|
||||
do {
|
||||
return try await resolvePolicyAfterPendingRevocations(
|
||||
return try await resolvePolicyAfterAuthenticatedRegistration(
|
||||
engine: engine,
|
||||
expectedEndpointID: expectedEndpointID,
|
||||
revision: revision,
|
||||
@@ -21,6 +19,8 @@ extension CmxIrohHostRuntime {
|
||||
)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch let failure as CmxIrohPostRegistrationRevocationFailure {
|
||||
throw failure.underlying
|
||||
} catch {
|
||||
try requireCurrent(revision)
|
||||
guard CmxIrohTrustBrokerClientError
|
||||
@@ -48,25 +48,30 @@ extension CmxIrohHostRuntime {
|
||||
revision: UInt64,
|
||||
allowCachedFallback: Bool
|
||||
) async throws -> ResolvedPolicy {
|
||||
try await revokePendingBeforeRegistration()
|
||||
try requireCurrent(revision)
|
||||
return try await resolvePolicyAfterPendingRevocations(
|
||||
engine: engine,
|
||||
expectedEndpointID: expectedEndpointID,
|
||||
revision: revision,
|
||||
allowCachedFallback: allowCachedFallback
|
||||
)
|
||||
do {
|
||||
return try await resolvePolicyAfterAuthenticatedRegistration(
|
||||
engine: engine,
|
||||
expectedEndpointID: expectedEndpointID,
|
||||
revision: revision,
|
||||
allowCachedFallback: allowCachedFallback
|
||||
)
|
||||
} catch let failure as CmxIrohPostRegistrationRevocationFailure {
|
||||
throw failure.underlying
|
||||
}
|
||||
}
|
||||
|
||||
private func revokePendingBeforeRegistration() async throws {
|
||||
try await pendingRevocations.revokePending(
|
||||
private func reconcilePendingAfterRegistration(
|
||||
activeBindingID: String
|
||||
) async throws -> Bool {
|
||||
try await pendingRevocations.reconcilePending(
|
||||
accountID: configuration.accountID,
|
||||
beforeRegisteringTag: configuration.tag,
|
||||
activeBindingID: activeBindingID,
|
||||
using: broker
|
||||
)
|
||||
}
|
||||
|
||||
private func resolvePolicyAfterPendingRevocations(
|
||||
private func resolvePolicyAfterAuthenticatedRegistration(
|
||||
engine: CmxConnectivityEngine,
|
||||
expectedEndpointID: CmxIrohPeerIdentity,
|
||||
revision: UInt64,
|
||||
@@ -117,9 +122,19 @@ extension CmxIrohHostRuntime {
|
||||
}
|
||||
try requireCurrent(revision)
|
||||
try validateLocalBinding(registration.binding, endpointID: expectedEndpointID)
|
||||
let revokedPendingBinding: Bool
|
||||
do {
|
||||
revokedPendingBinding = try await reconcilePendingAfterRegistration(
|
||||
activeBindingID: registration.binding.bindingID
|
||||
)
|
||||
} catch {
|
||||
throw CmxIrohPostRegistrationRevocationFailure(underlying: error)
|
||||
}
|
||||
try requireCurrent(revision)
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
do {
|
||||
if let embedded = registration.discovery,
|
||||
if !revokedPendingBinding,
|
||||
let embedded = registration.discovery,
|
||||
registration.embeddedDiscoveryComplete {
|
||||
guard let snapshotRevision = embedded.revision,
|
||||
let registrationRevision = registration.revision,
|
||||
@@ -228,6 +243,7 @@ extension CmxIrohHostRuntime {
|
||||
return try CmxIrohRegistrationPayload(
|
||||
deviceID: configuration.deviceID,
|
||||
appInstanceID: configuration.appInstanceID,
|
||||
clientNamespace: configuration.clientNamespace,
|
||||
tag: configuration.tag,
|
||||
platform: .mac,
|
||||
displayName: configuration.displayName,
|
||||
@@ -304,6 +320,7 @@ extension CmxIrohHostRuntime {
|
||||
) throws {
|
||||
guard binding.deviceID == configuration.deviceID,
|
||||
binding.appInstanceID == configuration.appInstanceID,
|
||||
binding.clientNamespace == configuration.clientNamespace,
|
||||
binding.tag == configuration.tag,
|
||||
binding.platform == .mac,
|
||||
binding.endpointID == endpointID,
|
||||
@@ -322,6 +339,7 @@ extension CmxIrohHostRuntime {
|
||||
let binding = policy.binding
|
||||
guard binding.deviceID == configuration.deviceID,
|
||||
binding.appInstanceID == configuration.appInstanceID,
|
||||
binding.clientNamespace == configuration.clientNamespace,
|
||||
binding.tag == configuration.tag,
|
||||
binding.platform == .mac,
|
||||
binding.endpointID == endpointID,
|
||||
|
||||
+9
@@ -232,6 +232,14 @@ extension CmxIrohHostRuntime {
|
||||
bindingID: binding.bindingID
|
||||
)
|
||||
}
|
||||
let bindingAuthorization = localBinding.flatMap { binding in
|
||||
try? CmxIrohBindingRequestAuthorization(
|
||||
bindingID: binding.bindingID,
|
||||
clientNamespace: binding.clientNamespace,
|
||||
identity: configuration.identity,
|
||||
endpointID: binding.endpointID
|
||||
)
|
||||
}
|
||||
lifecyclePhase = .signingOut
|
||||
lifecycleRevision &+= 1
|
||||
let revision = lifecycleRevision
|
||||
@@ -244,6 +252,7 @@ extension CmxIrohHostRuntime {
|
||||
let operation = Task {
|
||||
await self.performSignOut(
|
||||
pendingRevocation: pendingRevocation,
|
||||
bindingAuthorization: bindingAuthorization,
|
||||
requiresNetworkDeactivation: requiresNetworkDeactivation,
|
||||
revision: revision
|
||||
)
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ public import Foundation
|
||||
extension CmxIrohHostRuntime {
|
||||
func performSignOut(
|
||||
pendingRevocation: CmxIrohPendingRevocation?,
|
||||
bindingAuthorization: CmxIrohBindingRequestAuthorization?,
|
||||
requiresNetworkDeactivation: Bool,
|
||||
revision: UInt64
|
||||
) async -> CmxIrohHostSignOutPreparation {
|
||||
@@ -17,7 +18,8 @@ extension CmxIrohHostRuntime {
|
||||
let (persisted, _) = await (wasPersisted, networkTeardown)
|
||||
let preparation = CmxIrohHostSignOutPreparation(
|
||||
pendingRevocation: pendingRevocation,
|
||||
wasPersisted: persisted
|
||||
wasPersisted: persisted,
|
||||
bindingAuthorization: bindingAuthorization
|
||||
)
|
||||
|
||||
guard lifecyclePhase == .signingOut,
|
||||
|
||||
+5
@@ -7,6 +7,8 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
|
||||
|
||||
public let deviceID: String
|
||||
public let appInstanceID: String
|
||||
/// Exact Mac build namespace sent to every broker request.
|
||||
public let clientNamespace: String
|
||||
public let tag: String
|
||||
public let displayName: String?
|
||||
public let identity: CmxIrohIdentityMaterial
|
||||
@@ -29,6 +31,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
|
||||
/// - accountID: The exact account that owns this host binding.
|
||||
/// - deviceID: The account device's lowercase UUID.
|
||||
/// - appInstanceID: The current app-instance UUID.
|
||||
/// - clientNamespace: The exact installed Mac bundle namespace.
|
||||
/// - tag: The broker registration build tag.
|
||||
/// - displayName: The optional user-visible Mac name.
|
||||
/// - identity: The stable Iroh secret and generation.
|
||||
@@ -43,6 +46,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
|
||||
accountID: String,
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
clientNamespace: CmxIrohMacBundleNamespace,
|
||||
tag: String,
|
||||
displayName: String?,
|
||||
identity: CmxIrohIdentityMaterial,
|
||||
@@ -57,6 +61,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
|
||||
self.accountID = accountID
|
||||
self.deviceID = cmxCanonicalDeviceID(deviceID)
|
||||
self.appInstanceID = appInstanceID.lowercased()
|
||||
self.clientNamespace = clientNamespace.rawValue
|
||||
self.tag = tag
|
||||
self.displayName = displayName
|
||||
self.identity = identity
|
||||
|
||||
+69
-15
@@ -6,11 +6,15 @@ public actor CmxIrohIdentityRepository {
|
||||
private static let installMarkerKey = "cmux.iroh.identity.install-marker.v1"
|
||||
private static let activeScopeKey = "cmux.iroh.identity.active-scope.v1"
|
||||
private static let recordVersion: UInt8 = 1
|
||||
private static let maximumQueuedOperations = 64
|
||||
|
||||
private let secureStore: any CmxIrohSecureIdentityStoring
|
||||
private let installState: any CmxIrohInstallStateStoring
|
||||
private let randomBytes: @Sendable () throws -> Data
|
||||
private let marker: @Sendable () -> String
|
||||
private var operationIsActive = false
|
||||
private var operationWaiters: [UUID: CheckedContinuation<Void, any Error>] = [:]
|
||||
private var operationWaiterOrder: [UUID] = []
|
||||
|
||||
/// Creates an identity repository with injectable persistence and entropy.
|
||||
public init(
|
||||
@@ -32,34 +36,84 @@ public actor CmxIrohIdentityRepository {
|
||||
/// A missing install marker removes Keychain material that survived an app
|
||||
/// uninstall. Changing account scope removes the prior account key before
|
||||
/// creating a new EndpointID.
|
||||
public func identity(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial {
|
||||
let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID)
|
||||
if let encoded = try secureStore.read(account: scope) {
|
||||
public func identity(accountID: String, appInstanceID: String) async throws -> CmxIrohIdentityMaterial {
|
||||
try await beginOperation()
|
||||
defer { endOperation() }
|
||||
try Task.checkCancellation()
|
||||
let scope = try await prepareScope(accountID: accountID, appInstanceID: appInstanceID)
|
||||
if let encoded = try await secureStore.read(account: scope) {
|
||||
return try Self.decode(encoded)
|
||||
}
|
||||
return try create(scope: scope, generation: 1)
|
||||
return try await create(scope: scope, generation: 1)
|
||||
}
|
||||
|
||||
/// Replaces the active account key and increments its identity generation.
|
||||
public func rotate(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial {
|
||||
let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID)
|
||||
let current = try secureStore.read(account: scope).map(Self.decode)
|
||||
public func rotate(accountID: String, appInstanceID: String) async throws -> CmxIrohIdentityMaterial {
|
||||
try await beginOperation()
|
||||
defer { endOperation() }
|
||||
try Task.checkCancellation()
|
||||
let scope = try await prepareScope(accountID: accountID, appInstanceID: appInstanceID)
|
||||
let current = try await secureStore.read(account: scope).map(Self.decode)
|
||||
let generation = try current.map { material in
|
||||
guard material.generation < Int(Int32.max) else {
|
||||
throw CmxIrohIdentityRepositoryError.invalidGeneration
|
||||
}
|
||||
return material.generation + 1
|
||||
} ?? 1
|
||||
return try create(scope: scope, generation: generation)
|
||||
return try await create(scope: scope, generation: generation)
|
||||
}
|
||||
|
||||
/// Removes all endpoint identity when signing out or locally revoking it.
|
||||
public func deactivate() throws {
|
||||
try secureStore.deleteAll()
|
||||
public func deactivate() async throws {
|
||||
try await beginOperation()
|
||||
defer { endOperation() }
|
||||
try Task.checkCancellation()
|
||||
try await secureStore.deleteAll()
|
||||
installState.set(nil, forKey: Self.activeScopeKey)
|
||||
}
|
||||
|
||||
private func prepareScope(accountID: String, appInstanceID: String) throws -> String {
|
||||
private func beginOperation() async throws {
|
||||
guard operationIsActive else {
|
||||
operationIsActive = true
|
||||
return
|
||||
}
|
||||
guard operationWaiterOrder.count < Self.maximumQueuedOperations else {
|
||||
throw CmxIrohIdentityRepositoryError.operationLimitExceeded
|
||||
}
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, any Error>) in
|
||||
if Task.isCancelled {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
return
|
||||
}
|
||||
operationWaiters[id] = continuation
|
||||
operationWaiterOrder.append(id)
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelOperationWaiter(id) }
|
||||
}
|
||||
}
|
||||
|
||||
private func endOperation() {
|
||||
guard let id = operationWaiterOrder.first else {
|
||||
operationIsActive = false
|
||||
return
|
||||
}
|
||||
operationWaiterOrder.removeFirst()
|
||||
operationWaiters.removeValue(forKey: id)?.resume()
|
||||
}
|
||||
|
||||
private func cancelOperationWaiter(_ id: UUID) {
|
||||
guard let continuation = operationWaiters.removeValue(forKey: id) else {
|
||||
return
|
||||
}
|
||||
operationWaiterOrder.removeAll { $0 == id }
|
||||
continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
|
||||
private func prepareScope(accountID: String, appInstanceID: String) async throws -> String {
|
||||
guard !accountID.isEmpty,
|
||||
accountID.utf8.count <= 1_024,
|
||||
!appInstanceID.isEmpty,
|
||||
@@ -68,7 +122,7 @@ public actor CmxIrohIdentityRepository {
|
||||
}
|
||||
var clearedSecureStore = false
|
||||
if installState.string(forKey: Self.installMarkerKey) == nil {
|
||||
try secureStore.deleteAll()
|
||||
try await secureStore.deleteAll()
|
||||
clearedSecureStore = true
|
||||
installState.set(nil, forKey: Self.activeScopeKey)
|
||||
installState.set(marker(), forKey: Self.installMarkerKey)
|
||||
@@ -76,17 +130,17 @@ public actor CmxIrohIdentityRepository {
|
||||
let scope = Self.scope(accountID: accountID, appInstanceID: appInstanceID)
|
||||
if installState.string(forKey: Self.activeScopeKey) != scope {
|
||||
if !clearedSecureStore {
|
||||
try secureStore.deleteAll()
|
||||
try await secureStore.deleteAll()
|
||||
}
|
||||
installState.set(scope, forKey: Self.activeScopeKey)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
private func create(scope: String, generation: Int) throws -> CmxIrohIdentityMaterial {
|
||||
private func create(scope: String, generation: Int) async throws -> CmxIrohIdentityMaterial {
|
||||
let secretKey = try CmxIrohSecretKey(bytes: randomBytes())
|
||||
let material = try CmxIrohIdentityMaterial(secretKey: secretKey, generation: generation)
|
||||
try secureStore.write(Self.encode(material), account: scope)
|
||||
try await secureStore.write(Self.encode(material), account: scope)
|
||||
return material
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -9,6 +9,9 @@ public enum CmxIrohIdentityRepositoryError: Error, Equatable, Sendable {
|
||||
/// The identity generation is zero, exhausted, or database-incompatible.
|
||||
case invalidGeneration
|
||||
|
||||
/// Too many identity operations are waiting behind a stalled persistence call.
|
||||
case operationLimitExceeded
|
||||
|
||||
/// Secure random generation failed with the platform status code.
|
||||
case randomGenerationFailed(Int32)
|
||||
}
|
||||
|
||||
+48
-7
@@ -4,12 +4,24 @@ import Security
|
||||
/// Device-only Keychain storage for Iroh relay capabilities.
|
||||
public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
|
||||
private let service: String
|
||||
private let accessGroup: String?
|
||||
private let legacyService: String?
|
||||
|
||||
/// Creates a Keychain store isolated by service name.
|
||||
///
|
||||
/// - Parameter service: The generic-password service identifier.
|
||||
public init(service: String = "com.cmuxterm.iroh.relay-credentials.v1") {
|
||||
/// - Parameters:
|
||||
/// - service: The bundle-scoped generic-password service identifier.
|
||||
/// - accessGroup: The app's exact signed Keychain access group.
|
||||
/// - legacyService: An older service whose item may be adopted only from
|
||||
/// the same exact access group.
|
||||
public init(
|
||||
service: String = "com.cmuxterm.iroh.relay-credentials.v1",
|
||||
accessGroup: String? = nil,
|
||||
legacyService: String? = nil
|
||||
) {
|
||||
self.service = service
|
||||
self.accessGroup = accessGroup
|
||||
self.legacyService = legacyService == service ? nil : legacyService
|
||||
}
|
||||
|
||||
/// Loads one opaque-scope capability from Keychain.
|
||||
@@ -18,7 +30,24 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
|
||||
/// - Returns: The stored capability, or `nil` when none exists.
|
||||
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
|
||||
public func read(account: String) throws -> Data? {
|
||||
var query = baseQuery(account: account)
|
||||
if let current = try read(service: service, account: account) {
|
||||
return current
|
||||
}
|
||||
guard let legacyService,
|
||||
let legacy = try read(service: legacyService, account: account) else {
|
||||
return nil
|
||||
}
|
||||
try write(
|
||||
legacy,
|
||||
account: account,
|
||||
accessibility: .afterFirstUnlockThisDeviceOnly
|
||||
)
|
||||
try delete(query: baseQuery(service: legacyService, account: account))
|
||||
return legacy
|
||||
}
|
||||
|
||||
private func read(service: String, account: String) throws -> Data? {
|
||||
var query = baseQuery(service: service, account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
@@ -44,7 +73,7 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
|
||||
account: String,
|
||||
accessibility: CmxIrohSecureCredentialAccessibility
|
||||
) throws {
|
||||
let query = baseQuery(account: account)
|
||||
let query = baseQuery(service: service, account: account)
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: secAccessibility(accessibility),
|
||||
@@ -83,17 +112,26 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
|
||||
/// - Parameter account: The repository-derived scope.
|
||||
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
|
||||
public func delete(account: String) throws {
|
||||
try delete(query: baseQuery(account: account))
|
||||
try delete(query: baseQuery(service: service, account: account))
|
||||
if let legacyService {
|
||||
try delete(query: baseQuery(service: legacyService, account: account))
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes every relay capability owned by this Keychain service.
|
||||
///
|
||||
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
|
||||
public func deleteAll() throws {
|
||||
try delete(query: baseQuery())
|
||||
try delete(query: baseQuery(service: service))
|
||||
if let legacyService {
|
||||
try delete(query: baseQuery(service: legacyService))
|
||||
}
|
||||
}
|
||||
|
||||
private func baseQuery(account: String? = nil) -> [String: Any] {
|
||||
private func baseQuery(
|
||||
service: String,
|
||||
account: String? = nil
|
||||
) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
@@ -103,6 +141,9 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
|
||||
if let account {
|
||||
query[kSecAttrAccount as String] = account
|
||||
}
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
+57
-28
@@ -2,18 +2,44 @@ public import Foundation
|
||||
import Security
|
||||
|
||||
/// Device-only Keychain storage for Iroh EndpointID secret material.
|
||||
public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable {
|
||||
public actor CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring {
|
||||
private let service: String
|
||||
private let accessGroup: String?
|
||||
private let legacyService: String?
|
||||
|
||||
/// Creates a Keychain store isolated by service name.
|
||||
///
|
||||
/// - Parameter service: The generic-password service identifier.
|
||||
public init(service: String = "com.cmuxterm.iroh.endpoint-identity.v1") {
|
||||
/// - Parameters:
|
||||
/// - service: The bundle-scoped generic-password service identifier.
|
||||
/// - accessGroup: The app's exact signed Keychain access group.
|
||||
/// - legacyService: An older service whose item may be adopted only from
|
||||
/// the same exact access group.
|
||||
public init(
|
||||
service: String = "com.cmuxterm.iroh.endpoint-identity.v1",
|
||||
accessGroup: String? = nil,
|
||||
legacyService: String? = nil
|
||||
) {
|
||||
self.service = service
|
||||
self.accessGroup = accessGroup
|
||||
self.legacyService = legacyService == service ? nil : legacyService
|
||||
}
|
||||
|
||||
public func read(account: String) throws -> Data? {
|
||||
var query = baseQuery(account: account)
|
||||
/// Loads one identity, adopting its same-access-group legacy record when needed.
|
||||
public func read(account: String) async throws -> Data? {
|
||||
if let current = try read(service: service, account: account) {
|
||||
return current
|
||||
}
|
||||
guard let legacyService,
|
||||
let legacy = try read(service: legacyService, account: account) else {
|
||||
return nil
|
||||
}
|
||||
try writeStored(legacy, account: account)
|
||||
try delete(query: baseQuery(service: legacyService, account: account))
|
||||
return legacy
|
||||
}
|
||||
|
||||
private func read(service: String, account: String) throws -> Data? {
|
||||
var query = baseQuery(service: service, account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
@@ -27,8 +53,13 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
|
||||
return data
|
||||
}
|
||||
|
||||
public func write(_ data: Data, account: String) throws {
|
||||
let query = baseQuery(account: account)
|
||||
/// Replaces one identity in the bundle-scoped Keychain service.
|
||||
public func write(_ data: Data, account: String) async throws {
|
||||
try writeStored(data, account: account)
|
||||
}
|
||||
|
||||
private func writeStored(_ data: Data, account: String) throws {
|
||||
let query = baseQuery(service: service, account: account)
|
||||
let updateStatus = SecItemUpdate(
|
||||
query as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
@@ -48,28 +79,20 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
|
||||
}
|
||||
}
|
||||
|
||||
public func delete(account: String) throws {
|
||||
try delete(query: baseQuery(account: account))
|
||||
/// Removes one identity from the current and eligible legacy services.
|
||||
public func delete(account: String) async throws {
|
||||
try delete(query: baseQuery(service: service, account: account))
|
||||
if let legacyService {
|
||||
try delete(query: baseQuery(service: legacyService, account: account))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether ANY identity record exists under this store's service, without
|
||||
/// reading or creating one.
|
||||
///
|
||||
/// Items here are `AfterFirstUnlockThisDeviceOnly`: they never travel in a
|
||||
/// device backup, so a present record is proof the app previously ran (and
|
||||
/// activated iroh) on THIS physical device — the non-migrating continuity
|
||||
/// signal the device-registry mirror adoption gates on. Any error
|
||||
/// (including a locked Keychain) reports `false`: absence of proof, never
|
||||
/// proof of absence, so callers stay fail-safe.
|
||||
public func containsAnyRecord() -> Bool {
|
||||
var query = baseQuery()
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
return status == errSecSuccess
|
||||
}
|
||||
|
||||
public func deleteAll() throws {
|
||||
try delete(query: baseQuery())
|
||||
/// Removes every identity from the current and eligible legacy services.
|
||||
public func deleteAll() async throws {
|
||||
try delete(query: baseQuery(service: service))
|
||||
if let legacyService {
|
||||
try delete(query: baseQuery(service: legacyService))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates one Ed25519 secret using Security.framework.
|
||||
@@ -85,7 +108,10 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
|
||||
return data
|
||||
}
|
||||
|
||||
private func baseQuery(account: String? = nil) -> [String: Any] {
|
||||
private func baseQuery(
|
||||
service: String,
|
||||
account: String? = nil
|
||||
) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
@@ -95,6 +121,9 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
|
||||
if let account {
|
||||
query[kSecAttrAccount as String] = account
|
||||
}
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
+9
-11
@@ -5,6 +5,9 @@ import Foundation
|
||||
public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
|
||||
public let deviceID: String
|
||||
public let appInstanceID: String
|
||||
|
||||
/// The exact bundle-derived app namespace expected in discovery.
|
||||
public let clientNamespace: String
|
||||
public let tag: String
|
||||
public let platform: CmxIrohPlatform
|
||||
public let endpointID: CmxIrohPeerIdentity
|
||||
@@ -15,6 +18,7 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
|
||||
public init(
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
clientNamespace: String = "legacy",
|
||||
tag: String,
|
||||
platform: CmxIrohPlatform,
|
||||
endpointID: CmxIrohPeerIdentity,
|
||||
@@ -24,15 +28,17 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
|
||||
) throws {
|
||||
guard Self.isCanonicalUUID(deviceID),
|
||||
Self.isCanonicalUUID(appInstanceID),
|
||||
Self.isSafeToken(tag),
|
||||
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
|
||||
cmxIrohIsSafeToken(tag),
|
||||
(1 ... Int(Int32.max)).contains(identityGeneration),
|
||||
capabilities.count <= 32,
|
||||
Set(capabilities).count == capabilities.count,
|
||||
capabilities.allSatisfy(Self.isSafeToken) else {
|
||||
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }) else {
|
||||
throw CmxIrohLocalBindingExpectationError.invalidExpectation
|
||||
}
|
||||
self.deviceID = deviceID
|
||||
self.appInstanceID = appInstanceID
|
||||
self.clientNamespace = clientNamespace
|
||||
self.tag = tag
|
||||
self.platform = platform
|
||||
self.endpointID = endpointID
|
||||
@@ -45,6 +51,7 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
|
||||
public func matches(_ binding: CmxIrohBrokerBinding) -> Bool {
|
||||
binding.deviceID == deviceID
|
||||
&& binding.appInstanceID == appInstanceID
|
||||
&& binding.clientNamespace == clientNamespace
|
||||
&& binding.tag == tag
|
||||
&& binding.platform == platform
|
||||
&& binding.endpointID == endpointID
|
||||
@@ -58,13 +65,4 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
|
||||
UUID(uuidString: value)?.uuidString.lowercased() == value
|
||||
}
|
||||
|
||||
private static func isSafeToken(_ value: String) -> Bool {
|
||||
guard (1 ... 64).contains(value.utf8.count) else { return false }
|
||||
return value.utf8.allSatisfy { byte in
|
||||
(48 ... 57).contains(byte)
|
||||
|| (65 ... 90).contains(byte)
|
||||
|| (97 ... 122).contains(byte)
|
||||
|| [45, 46, 58, 95].contains(byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Broker namespace owned by one exact installed macOS app bundle.
|
||||
public struct CmxIrohMacBundleNamespace: Equatable, Hashable, Sendable {
|
||||
/// Canonical `mac:<bundle-id>` value sent to the trust broker.
|
||||
public let rawValue: String
|
||||
|
||||
/// Creates a namespace from one complete macOS bundle identifier.
|
||||
public init?(bundleIdentifier: String?) {
|
||||
guard let bundleIdentifier else { return nil }
|
||||
let trimmed = bundleIdentifier.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
guard trimmed == bundleIdentifier,
|
||||
trimmed.contains("."),
|
||||
trimmed.utf8.count <= 251,
|
||||
trimmed.range(
|
||||
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
|
||||
options: .regularExpression
|
||||
) != nil else {
|
||||
return nil
|
||||
}
|
||||
let value = "mac:\(trimmed.lowercased())"
|
||||
guard cmxIrohIsSafeToken(value, maximumUTF8ByteCount: 255) else {
|
||||
return nil
|
||||
}
|
||||
rawValue = value
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
struct CmxIrohMacForgetRequest: Encodable {
|
||||
let bindingId: String
|
||||
let intent = "forget_mac"
|
||||
}
|
||||
+1
@@ -68,4 +68,5 @@ public struct CmxIrohPendingRevocation: Codable, Equatable, Sendable {
|
||||
private static func isCanonicalUUID(_ value: String) -> Bool {
|
||||
UUID(uuidString: value)?.uuidString.lowercased() == value
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+38
-1
@@ -70,12 +70,14 @@ public actor CmxIrohPendingRevocationOutbox {
|
||||
/// - accountID: The currently authenticated account.
|
||||
/// - tag: The build tag about to register.
|
||||
/// - broker: An authenticated idempotent binding revoker.
|
||||
/// - Returns: Whether at least one pending binding was revoked.
|
||||
/// - Throws: The first broker, validation, decoding, or persistence error.
|
||||
@discardableResult
|
||||
public func revokePending(
|
||||
accountID: String,
|
||||
beforeRegisteringTag tag: String,
|
||||
using broker: any CmxIrohBindingRevoking
|
||||
) async throws {
|
||||
) async throws -> Bool {
|
||||
guard CmxIrohPendingRevocation.isSafeAccountID(accountID),
|
||||
CmxIrohPendingRevocation.isSafeTag(tag) else {
|
||||
throw CmxIrohPendingRevocationError.invalidRecord
|
||||
@@ -88,6 +90,41 @@ public actor CmxIrohPendingRevocationOutbox {
|
||||
|
||||
try await removeConfirmed(revocation)
|
||||
}
|
||||
return !ordered.isEmpty
|
||||
}
|
||||
|
||||
/// Reconciles pending bindings after registration has installed one active
|
||||
/// binding. A broker may reuse the same binding identifier when a sign-out
|
||||
/// was queued and the app signs back in before the queue drained. That
|
||||
/// identifier is already active again, so removing its stale queue entry
|
||||
/// must not send a revoke request for it.
|
||||
///
|
||||
/// - Returns: Whether at least one different pending binding was revoked.
|
||||
public func reconcilePending(
|
||||
accountID: String,
|
||||
beforeRegisteringTag tag: String,
|
||||
activeBindingID: String,
|
||||
using broker: any CmxIrohBindingRevoking
|
||||
) async throws -> Bool {
|
||||
guard UUID(uuidString: activeBindingID)?.uuidString.lowercased() == activeBindingID,
|
||||
CmxIrohPendingRevocation.isSafeAccountID(accountID),
|
||||
CmxIrohPendingRevocation.isSafeTag(tag) else {
|
||||
throw CmxIrohPendingRevocationError.invalidRecord
|
||||
}
|
||||
let snapshot = try await pending(accountID: accountID)
|
||||
let ordered = snapshot.filter { $0.tag == tag }
|
||||
+ snapshot.filter { $0.tag != tag }
|
||||
var revoked = false
|
||||
for revocation in ordered {
|
||||
if revocation.bindingID == activeBindingID {
|
||||
try await removeConfirmed(revocation)
|
||||
continue
|
||||
}
|
||||
try await broker.revokeStale(bindingID: revocation.bindingID)
|
||||
try await removeConfirmed(revocation)
|
||||
revoked = true
|
||||
}
|
||||
return revoked
|
||||
}
|
||||
|
||||
private func removeConfirmed(
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
struct CmxIrohPostRegistrationRevocationFailure: Error {
|
||||
let underlying: any Error
|
||||
}
|
||||
+6
@@ -10,6 +10,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
|
||||
case routeContractVersion = "route_contract_version"
|
||||
case deviceID = "deviceId"
|
||||
case appInstanceID = "appInstanceId"
|
||||
case clientNamespace
|
||||
case tag
|
||||
case platform
|
||||
case displayName
|
||||
@@ -27,6 +28,8 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
|
||||
public let deviceID: String
|
||||
/// Stable app-instance UUID for this installation and tag.
|
||||
public let appInstanceID: String
|
||||
/// Exact app namespace that owns this binding.
|
||||
public let clientNamespace: String
|
||||
/// Safe build or app-instance tag.
|
||||
public let tag: String
|
||||
/// Device role used by grant policy.
|
||||
@@ -53,6 +56,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
|
||||
public init(
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
clientNamespace: String = "legacy",
|
||||
tag: String,
|
||||
platform: CmxIrohPlatform,
|
||||
displayName: String? = nil,
|
||||
@@ -66,6 +70,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
|
||||
) throws {
|
||||
guard Self.isBrokerUUID(deviceID),
|
||||
Self.isBrokerUUID(appInstanceID),
|
||||
Self.isSafeToken(clientNamespace, maximum: 255),
|
||||
Self.isSafeToken(tag, maximum: 64),
|
||||
(try? CmxIrohPeerIdentity(endpointID: endpointID)) != nil,
|
||||
(1...Int(Int32.max)).contains(identityGeneration),
|
||||
@@ -89,6 +94,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
|
||||
routeContractVersion = Self.currentRouteContractVersion
|
||||
self.deviceID = cmxCanonicalDeviceID(deviceID)
|
||||
self.appInstanceID = appInstanceID.lowercased()
|
||||
self.clientNamespace = clientNamespace
|
||||
self.tag = tag
|
||||
self.platform = platform
|
||||
self.displayName = displayName
|
||||
|
||||
+23
@@ -72,6 +72,29 @@ public struct CmxIrohRegistrationSigner: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Signs one authenticated broker request with the registered endpoint key.
|
||||
func signBrokerRequest(
|
||||
bindingID: String,
|
||||
method: String,
|
||||
path: String,
|
||||
timestamp: Int64,
|
||||
body: Data
|
||||
) throws -> String {
|
||||
guard Self.isBrokerUUID(bindingID),
|
||||
!method.isEmpty,
|
||||
method.utf8.allSatisfy({ (65...90).contains($0) }),
|
||||
!path.isEmpty,
|
||||
path.utf8.allSatisfy({ $0 >= 0x21 && $0 <= 0x7e }),
|
||||
timestamp > 0 else {
|
||||
throw CmxIrohRegistrationError.invalidChallenge
|
||||
}
|
||||
let bodySHA256 = Self.hex(Data(SHA256.hash(data: body)))
|
||||
let transcript = Data(
|
||||
"cmux/iroh/binding-request/v1\n\(bindingID.lowercased())\n\(method)\n\(path)\n\(timestamp)\n\(bodySHA256)".utf8
|
||||
)
|
||||
return Self.base64URL(signingKey.sign(message: transcript).toBytes())
|
||||
}
|
||||
|
||||
private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
func cmxIrohIsSafeToken(
|
||||
_ value: String,
|
||||
maximumUTF8ByteCount: Int = 64
|
||||
) -> Bool {
|
||||
guard (1 ... maximumUTF8ByteCount).contains(value.utf8.count) else {
|
||||
return false
|
||||
}
|
||||
return value.utf8.allSatisfy { byte in
|
||||
(48 ... 57).contains(byte)
|
||||
|| (65 ... 90).contains(byte)
|
||||
|| (97 ... 122).contains(byte)
|
||||
|| [45, 46, 58, 95].contains(byte)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -3,14 +3,14 @@ public import Foundation
|
||||
/// Minimal secure-storage boundary used by the Iroh identity repository.
|
||||
public protocol CmxIrohSecureIdentityStoring: Sendable {
|
||||
/// Loads the record for an opaque account scope.
|
||||
func read(account: String) throws -> Data?
|
||||
func read(account: String) async throws -> Data?
|
||||
|
||||
/// Replaces the record for an opaque account scope.
|
||||
func write(_ data: Data, account: String) throws
|
||||
func write(_ data: Data, account: String) async throws
|
||||
|
||||
/// Removes one opaque account scope.
|
||||
func delete(account: String) throws
|
||||
func delete(account: String) async throws
|
||||
|
||||
/// Removes every Iroh identity owned by this app installation.
|
||||
func deleteAll() throws
|
||||
func deleteAll() async throws
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
struct CmxIrohStaleBindingRevocationRequest: Encodable {
|
||||
let bindingId: String
|
||||
let intent = "revoke_stale"
|
||||
}
|
||||
+106
-8
@@ -1,6 +1,23 @@
|
||||
public import CMUXMobileCore
|
||||
public import Foundation
|
||||
|
||||
private func cmxIsSafeClientNamespace(_ value: String) -> Bool {
|
||||
(1 ... 255).contains(value.utf8.count)
|
||||
&& value.utf8.allSatisfy {
|
||||
(48 ... 57).contains($0)
|
||||
|| (65 ... 90).contains($0)
|
||||
|| (97 ... 122).contains($0)
|
||||
|| [45, 46, 58, 95].contains($0)
|
||||
}
|
||||
}
|
||||
|
||||
private func cmxIsSafeBrokerHeaderValue(_ value: String) -> Bool {
|
||||
(1 ... 16 * 1_024).contains(value.utf8.count)
|
||||
&& !value.unicodeScalars.contains(
|
||||
where: { $0.value < 0x20 || $0.value == 0x7f }
|
||||
)
|
||||
}
|
||||
|
||||
/// One access + refresh credential pair captured from a single session snapshot.
|
||||
///
|
||||
/// Assembling a request from one snapshot prevents pairing a stale access token
|
||||
@@ -269,12 +286,16 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
private let transport: any CmxIrohHTTPTransport
|
||||
private let requestTimeout: TimeInterval
|
||||
private let backpressureGate: CmxIrohBrokerBackpressureGate?
|
||||
private let clientNamespace: String
|
||||
private var bindingAuthorization: CmxIrohBindingRequestAuthorization?
|
||||
private let discoveryScope: CmxConnectivityDiscoveryScope?
|
||||
|
||||
/// Creates a client that rejects cleartext non-loopback API origins.
|
||||
public init(
|
||||
baseURL: URL,
|
||||
tokenSource: CmxIrohBrokerTokenSource,
|
||||
clientNamespace: String,
|
||||
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
|
||||
requestTimeout: TimeInterval = 10,
|
||||
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
|
||||
@@ -282,6 +303,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
try self.init(
|
||||
baseURL: baseURL,
|
||||
tokenSource: tokenSource,
|
||||
clientNamespace: clientNamespace,
|
||||
bindingAuthorization: bindingAuthorization,
|
||||
discoveryScope: discoveryScope,
|
||||
transport: CmxIrohURLSessionTransport(),
|
||||
requestTimeout: requestTimeout,
|
||||
@@ -293,18 +316,26 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
init(
|
||||
baseURL: URL,
|
||||
tokenSource: CmxIrohBrokerTokenSource,
|
||||
clientNamespace: String,
|
||||
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
|
||||
transport: any CmxIrohHTTPTransport,
|
||||
requestTimeout: TimeInterval = 10,
|
||||
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
|
||||
) throws {
|
||||
guard Self.isAllowedBaseURL(baseURL), requestTimeout > 0 else {
|
||||
guard Self.isAllowedBaseURL(baseURL),
|
||||
cmxIsSafeClientNamespace(clientNamespace),
|
||||
bindingAuthorization?.clientNamespace == nil
|
||||
|| bindingAuthorization?.clientNamespace == clientNamespace,
|
||||
requestTimeout > 0 else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidBaseURL
|
||||
}
|
||||
self.baseURL = baseURL
|
||||
self.tokenSource = tokenSource
|
||||
self.transport = transport
|
||||
self.requestTimeout = requestTimeout
|
||||
self.clientNamespace = clientNamespace
|
||||
self.bindingAuthorization = bindingAuthorization
|
||||
self.discoveryScope = discoveryScope
|
||||
switch backpressureMode {
|
||||
case .automatic:
|
||||
@@ -322,6 +353,16 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// Reports whether this client retains a signed binding request proof.
|
||||
public func hasBindingAuthorization() async -> Bool {
|
||||
bindingAuthorization != nil
|
||||
}
|
||||
|
||||
/// Returns the binding ID represented by the retained request proof.
|
||||
public func bindingAuthorizationID() async -> String? {
|
||||
bindingAuthorization?.bindingID
|
||||
}
|
||||
|
||||
public func issueChallenge(
|
||||
_ request: CmxIrohChallengeRequest
|
||||
) async throws -> CmxIrohChallengeResponse {
|
||||
@@ -346,7 +387,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
prepared: CmxIrohPreparedRegistration,
|
||||
signer: CmxIrohRegistrationSigner
|
||||
) async throws -> CmxIrohRegistrationResponse {
|
||||
try await withBackpressure(operation: .registration) {
|
||||
let response: CmxIrohRegistrationResponse = try await withBackpressure(
|
||||
operation: .registration
|
||||
) {
|
||||
let challenge: CmxIrohChallengeResponse = try await self.sendUngated(
|
||||
path: "api/devices/iroh/challenge",
|
||||
method: "POST",
|
||||
@@ -355,8 +398,15 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
let request = try signer.sign(prepared: prepared, challenge: challenge)
|
||||
return try await self.registerUngated(request)
|
||||
}
|
||||
bindingAuthorization = CmxIrohBindingRequestAuthorization(
|
||||
bindingID: response.binding.bindingID,
|
||||
clientNamespace: clientNamespace,
|
||||
signer: signer
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
/// Discovers account bindings visible to this client's exact build namespace.
|
||||
public func discover() async throws -> CmxIrohDiscoveryResponse {
|
||||
try await withBackpressure(operation: .discovery) {
|
||||
if self.discoveryScope != nil {
|
||||
@@ -488,6 +538,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// Revokes the caller's own binding.
|
||||
public func revoke(bindingID: String) async throws {
|
||||
let response: RevokeResponse = try await send(
|
||||
path: "api/devices/iroh",
|
||||
@@ -500,6 +551,32 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes an older binding owned by this app namespace and physical device.
|
||||
public func revokeStale(bindingID: String) async throws {
|
||||
let response: RevokeResponse = try await send(
|
||||
path: "api/devices/iroh",
|
||||
method: "DELETE",
|
||||
body: CmxIrohStaleBindingRevocationRequest(bindingId: bindingID),
|
||||
operation: .revocation
|
||||
)
|
||||
guard response.revoked, response.lanRendezvousRotated else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes one same-build Mac through the explicit account-management path.
|
||||
public func forgetMac(bindingID: String) async throws {
|
||||
let response: RevokeResponse = try await send(
|
||||
path: "api/devices/iroh",
|
||||
method: "DELETE",
|
||||
body: CmxIrohMacForgetRequest(bindingId: bindingID),
|
||||
operation: .revocation
|
||||
)
|
||||
guard response.revoked, response.lanRendezvousRotated else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
}
|
||||
|
||||
private func registerUngated(
|
||||
_ request: CmxIrohRegisterRequest
|
||||
) async throws -> CmxIrohRegistrationResponse {
|
||||
@@ -792,7 +869,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
) async throws -> Response {
|
||||
let accessToken = credentials.accessToken
|
||||
let refreshToken = credentials.refreshToken
|
||||
guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else {
|
||||
guard cmxIsSafeBrokerHeaderValue(accessToken),
|
||||
cmxIsSafeBrokerHeaderValue(refreshToken) else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidAuthentication
|
||||
}
|
||||
let pathURL = baseURL.appendingPathComponent(path)
|
||||
@@ -811,6 +889,31 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
request.timeoutInterval = requestTimeout
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
|
||||
request.setValue(clientNamespace, forHTTPHeaderField: "X-Cmux-App-Namespace")
|
||||
if let bindingAuthorization,
|
||||
path != "api/devices/iroh/challenge",
|
||||
path != "api/devices/iroh/register" {
|
||||
let timestamp = Int64(Date().timeIntervalSince1970)
|
||||
let signature = try bindingAuthorization.signer.signBrokerRequest(
|
||||
bindingID: bindingAuthorization.bindingID,
|
||||
method: method,
|
||||
path: path,
|
||||
timestamp: timestamp,
|
||||
body: body ?? Data()
|
||||
)
|
||||
request.setValue(
|
||||
bindingAuthorization.bindingID,
|
||||
forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID"
|
||||
)
|
||||
request.setValue(
|
||||
String(timestamp),
|
||||
forHTTPHeaderField: "X-Cmux-Iroh-Request-Time"
|
||||
)
|
||||
request.setValue(
|
||||
signature,
|
||||
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
|
||||
)
|
||||
}
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
@@ -869,11 +972,6 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
return scheme == "http" && ["127.0.0.1", "::1", "localhost"].contains(host)
|
||||
}
|
||||
|
||||
private static func isSafeHeaderValue(_ value: String) -> Bool {
|
||||
(1 ... 16 * 1_024).contains(value.utf8.count)
|
||||
&& !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f })
|
||||
}
|
||||
|
||||
private static func retryAfterSeconds(_ value: String?) -> Int? {
|
||||
guard let value,
|
||||
!value.isEmpty,
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ struct ClientRuntimeTestFixture {
|
||||
accountID: "account-a",
|
||||
deviceID: binding.deviceID,
|
||||
appInstanceID: binding.appInstanceID,
|
||||
clientNamespace: binding.clientNamespace,
|
||||
tag: binding.tag,
|
||||
displayName: binding.displayName,
|
||||
identity: identity,
|
||||
|
||||
+4
@@ -135,6 +135,10 @@ private actor BackpressuredHostBrokerProbe:
|
||||
throw BackpressuredHostBrokerProbeError.unexpectedCall
|
||||
}
|
||||
|
||||
func revokeStale(bindingID _: String) async throws {
|
||||
throw BackpressuredHostBrokerProbeError.unexpectedCall
|
||||
}
|
||||
|
||||
func issueRelayBootstrap(
|
||||
endpointID _: CmxIrohPeerIdentity
|
||||
) async throws -> CmxIrohRelayBootstrapResponse {
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ struct CmxIrohBrokerCredentialPairTests {
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: tokenSource,
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
|
||||
|
||||
+3
@@ -21,6 +21,7 @@ extension CmxIrohClientRuntimeTests {
|
||||
accountID: "account-a",
|
||||
deviceID: fixture.initiator.deviceID,
|
||||
appInstanceID: discovery.bindings[0].appInstanceID,
|
||||
clientNamespace: discovery.bindings[0].clientNamespace,
|
||||
tag: fixture.initiator.tag,
|
||||
displayName: nil,
|
||||
identity: identity,
|
||||
@@ -92,6 +93,7 @@ extension CmxIrohClientRuntimeTests {
|
||||
accountID: "account-a",
|
||||
deviceID: fixture.initiator.deviceID,
|
||||
appInstanceID: discovery.bindings[0].appInstanceID,
|
||||
clientNamespace: discovery.bindings[0].clientNamespace,
|
||||
tag: fixture.initiator.tag,
|
||||
displayName: nil,
|
||||
identity: identity,
|
||||
@@ -164,6 +166,7 @@ extension CmxIrohClientRuntimeTests {
|
||||
accountID: "account-a",
|
||||
deviceID: fixture.initiator.deviceID,
|
||||
appInstanceID: discovery.bindings[0].appInstanceID,
|
||||
clientNamespace: discovery.bindings[0].clientNamespace,
|
||||
tag: fixture.initiator.tag,
|
||||
displayName: nil,
|
||||
identity: identity,
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests {
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: fixture.configuration.clientNamespace,
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.identity,
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ extension CmxIrohClientRuntimeTests {
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: fixture.configuration.clientNamespace,
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.configuration.identity,
|
||||
|
||||
+218
-2
@@ -3,6 +3,12 @@ import Foundation
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
private extension CmxIrohClientRuntime {
|
||||
func installLocalBindingForSignOutTest(_ binding: CmxIrohBrokerBinding) {
|
||||
localBinding = binding
|
||||
}
|
||||
}
|
||||
|
||||
@Suite
|
||||
struct CmxIrohClientRuntimeTests {
|
||||
@Test
|
||||
@@ -19,6 +25,7 @@ struct CmxIrohClientRuntimeTests {
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: fixture.configuration.clientNamespace,
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.configuration.identity,
|
||||
@@ -149,6 +156,7 @@ struct CmxIrohClientRuntimeTests {
|
||||
accountID: "account-a",
|
||||
deviceID: fixture.initiator.deviceID,
|
||||
appInstanceID: localBinding.appInstanceID,
|
||||
clientNamespace: localBinding.clientNamespace,
|
||||
tag: fixture.initiator.tag,
|
||||
displayName: nil,
|
||||
identity: identity,
|
||||
@@ -216,6 +224,49 @@ struct CmxIrohClientRuntimeTests {
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingRevocationInvalidatesEmbeddedRegistrationDiscovery() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let staleDiscovery = try ClientRuntimeTestFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
revision: 1
|
||||
)
|
||||
let authoritativeDiscovery = try ClientRuntimeTestFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
revision: 2
|
||||
)
|
||||
let pendingRevocations = fixture.pendingRevocations()
|
||||
let pending = try CmxIrohPendingRevocation(
|
||||
accountID: fixture.configuration.accountID,
|
||||
tag: "older-build",
|
||||
bindingID: "123e4567-e89b-42d3-a456-426614174099"
|
||||
)
|
||||
try await pendingRevocations.enqueue(pending)
|
||||
let broker = TestRevisionedClientBroker(
|
||||
binding: fixture.binding,
|
||||
discoveries: [authoritativeDiscovery],
|
||||
relay: fixture.relayResponse(),
|
||||
embeddedRegistrationDiscovery: staleDiscovery,
|
||||
embeddedRegistrationDiscoveryIsComplete: true,
|
||||
registrationRevision: 1
|
||||
)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: pendingRevocations,
|
||||
now: { fixture.now }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.syncCount == 1)
|
||||
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 2)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func startupFetchesPaginatedDiscoveryWhenRegistrationAndSyncSnapshotsAreUnproven() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
@@ -269,6 +320,7 @@ struct CmxIrohClientRuntimeTests {
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: fixture.configuration.clientNamespace,
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.configuration.identity,
|
||||
@@ -594,6 +646,129 @@ struct CmxIrohClientRuntimeTests {
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func rateLimitedRegistrationDrainsPendingRevocationsBeforeDiscovery() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let pendingRevocations = fixture.pendingRevocations()
|
||||
let pending = try CmxIrohPendingRevocation(
|
||||
accountID: fixture.configuration.accountID,
|
||||
tag: "older-build",
|
||||
bindingID: "123e4567-e89b-42d3-a456-426614174099"
|
||||
)
|
||||
try await pendingRevocations.enqueue(pending)
|
||||
let broker = TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse(),
|
||||
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
|
||||
code: "device_registration_hour_quota",
|
||||
retryAfterSeconds: 600
|
||||
)
|
||||
)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: pendingRevocations,
|
||||
now: { fixture.now }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.observedRegistrations().count == 1)
|
||||
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
|
||||
#expect(await broker.observedDiscoveryCount() == 1)
|
||||
#expect(
|
||||
try await pendingRevocations.pending(
|
||||
accountID: fixture.configuration.accountID
|
||||
).isEmpty
|
||||
)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func rateLimitedRegistrationWithoutBindingProofDoesNotDrainOrDiscover() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let pendingRevocations = fixture.pendingRevocations()
|
||||
let pending = try CmxIrohPendingRevocation(
|
||||
accountID: fixture.configuration.accountID,
|
||||
tag: "older-build",
|
||||
bindingID: "123e4567-e89b-42d3-a456-426614174099"
|
||||
)
|
||||
try await pendingRevocations.enqueue(pending)
|
||||
let broker = TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse(),
|
||||
bindingAuthorizationAvailable: false,
|
||||
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
|
||||
code: "device_registration_hour_quota",
|
||||
retryAfterSeconds: 600
|
||||
)
|
||||
)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: pendingRevocations,
|
||||
now: { fixture.now }
|
||||
)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.rateLimited(
|
||||
code: "device_registration_hour_quota",
|
||||
retryAfterSeconds: 600
|
||||
)) {
|
||||
try await runtime.start()
|
||||
}
|
||||
#expect(await broker.observedDiscoveryCount() == 0)
|
||||
#expect(try await pendingRevocations.pending(
|
||||
accountID: fixture.configuration.accountID
|
||||
) == [pending])
|
||||
}
|
||||
|
||||
@Test
|
||||
func rateLimitedRegistrationDoesNotRevokeRetainedAuthorization() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let pendingRevocations = fixture.pendingRevocations()
|
||||
let pending = try CmxIrohPendingRevocation(
|
||||
accountID: fixture.configuration.accountID,
|
||||
tag: fixture.configuration.tag,
|
||||
bindingID: fixture.binding.bindingID
|
||||
)
|
||||
try await pendingRevocations.enqueue(pending)
|
||||
let broker = TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse(),
|
||||
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
|
||||
code: "device_registration_hour_quota",
|
||||
retryAfterSeconds: 600
|
||||
)
|
||||
)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: pendingRevocations,
|
||||
now: { fixture.now }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.observedRevokedBindingIDs().isEmpty)
|
||||
#expect(await broker.observedDiscoveryCount() == 1)
|
||||
#expect(try await pendingRevocations.pending(
|
||||
accountID: fixture.configuration.accountID
|
||||
).isEmpty)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func rateLimitedRegistrationRejectsMissingOrSubstitutedDiscoveryBinding() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
@@ -1005,6 +1180,10 @@ struct CmxIrohClientRuntimeTests {
|
||||
|
||||
#expect(preparation.bindingID == fixture.binding.bindingID)
|
||||
#expect(preparation.wasPersisted)
|
||||
#expect(
|
||||
preparation.bindingAuthorization?.bindingID
|
||||
== fixture.binding.bindingID
|
||||
)
|
||||
#expect(await recorder.observedLocalWipes() == [true])
|
||||
#expect(await offlineStore.deleteAllCount() == 1)
|
||||
#expect(await runtime.snapshot().state == .inactive)
|
||||
@@ -1024,6 +1203,38 @@ struct CmxIrohClientRuntimeTests {
|
||||
#expect(await runtime.snapshot().state == .inactive)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signOutAuthorizationUsesPersistedLegacyBindingNamespace() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let configuration = CmxIrohClientRuntimeConfiguration(
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: "dev.cmux.app.beta",
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.configuration.identity,
|
||||
capabilities: fixture.configuration.capabilities,
|
||||
managedRelayURLs: fixture.configuration.managedRelayURLs
|
||||
)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: []),
|
||||
broker: TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse()
|
||||
),
|
||||
configuration: configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { fixture.now }
|
||||
)
|
||||
await runtime.installLocalBindingForSignOutTest(fixture.binding)
|
||||
|
||||
let preparation = await runtime.deactivateForSignOut()
|
||||
|
||||
#expect(preparation.bindingAuthorization?.clientNamespace == "legacy")
|
||||
}
|
||||
|
||||
@Test
|
||||
func suspendedSignOutPersistenceBlocksRestartUntilLocalTeardownCompletes() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
@@ -1112,7 +1323,7 @@ struct CmxIrohClientRuntimeTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingRevocationFailureBlocksRegistrationAndOfflineFallback() async throws {
|
||||
func pendingRevocationFailureStopsAfterAuthenticatedRegistration() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let store = TestSecureCredentialStore()
|
||||
let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store)
|
||||
@@ -1145,7 +1356,8 @@ struct CmxIrohClientRuntimeTests {
|
||||
try await runtime.start()
|
||||
}
|
||||
|
||||
#expect(await broker.observedRegistrations().isEmpty)
|
||||
#expect(await broker.observedRegistrations().count == 1)
|
||||
#expect(await broker.observedDiscoveryCount() == 0)
|
||||
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
|
||||
#expect(
|
||||
try await pendingRevocations.pending(
|
||||
@@ -1269,6 +1481,10 @@ private actor TestRevisionedClientBroker:
|
||||
|
||||
func revoke(bindingID _: String) {}
|
||||
|
||||
func revokeStale(bindingID _: String) {}
|
||||
|
||||
func forgetMac(bindingID _: String) {}
|
||||
|
||||
func waitUntilSyncCount(_ minimum: Int) async {
|
||||
while syncCount < minimum {
|
||||
await Task.yield()
|
||||
|
||||
+2
-1
@@ -140,7 +140,8 @@ struct CmxIrohCustomRelayLiveTests {
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
clientNamespace: "legacy"
|
||||
)
|
||||
let runTag = "relay-live-\(UUID().uuidString.lowercased())"
|
||||
let firstSecretKey = try randomSecretKey()
|
||||
|
||||
+2
@@ -153,6 +153,7 @@ struct CmxIrohCustomRelayRuntimeTests {
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: fixture.configuration.clientNamespace,
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.identity,
|
||||
@@ -230,6 +231,7 @@ struct CmxIrohCustomRelayRuntimeTests {
|
||||
accountID: fixture.configuration.accountID,
|
||||
deviceID: fixture.configuration.deviceID,
|
||||
appInstanceID: fixture.configuration.appInstanceID,
|
||||
clientNamespace: fixture.configuration.clientNamespace,
|
||||
tag: fixture.configuration.tag,
|
||||
displayName: fixture.configuration.displayName,
|
||||
identity: fixture.identity,
|
||||
|
||||
+6
-6
@@ -4,16 +4,16 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CmxIrohDevelopmentFileStorageTests {
|
||||
@Test func identityRoundTripsWithPrivateFilesystemPermissions() throws {
|
||||
@Test func identityRoundTripsWithPrivateFilesystemPermissions() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.remove() }
|
||||
let store = CmxIrohDevelopmentFileIdentityStore(
|
||||
directory: fixture.directory
|
||||
)
|
||||
|
||||
try store.write(Data([1, 2, 3]), account: "identity-scope")
|
||||
try await store.write(Data([1, 2, 3]), account: "identity-scope")
|
||||
|
||||
#expect(try store.read(account: "identity-scope") == Data([1, 2, 3]))
|
||||
#expect(try await store.read(account: "identity-scope") == Data([1, 2, 3]))
|
||||
#expect(try fixture.permissions(at: fixture.directory) == 0o700)
|
||||
#expect(try fixture.permissions(
|
||||
at: fixture.directory.appendingPathComponent(
|
||||
@@ -52,15 +52,15 @@ struct CmxIrohDevelopmentFileStorageTests {
|
||||
#expect(FileManager.default.fileExists(atPath: unrelated.path))
|
||||
}
|
||||
|
||||
@Test func traversalScopeIsRejected() throws {
|
||||
@Test func traversalScopeIsRejected() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.remove() }
|
||||
let store = CmxIrohDevelopmentFileIdentityStore(
|
||||
directory: fixture.directory
|
||||
)
|
||||
|
||||
#expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) {
|
||||
try store.write(Data([1]), account: "../outside")
|
||||
await #expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) {
|
||||
try await store.write(Data([1]), account: "../outside")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -5,6 +5,14 @@ import Testing
|
||||
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
private extension CmxIrohHostRuntime {
|
||||
func installLocalBindingForSignOutTest(
|
||||
_ binding: CmxIrohBrokerBindingMetadata
|
||||
) {
|
||||
localBinding = binding
|
||||
}
|
||||
}
|
||||
|
||||
extension CmxIrohHostRuntimeTests {
|
||||
@Test
|
||||
func emptyPublicHintsRenewRegistrationBeforePrivatePortFreshnessExpires() async throws {
|
||||
@@ -353,10 +361,45 @@ extension CmxIrohHostRuntimeTests {
|
||||
await store.resumeSuspendedWrite()
|
||||
let preparation = await signOut.value
|
||||
#expect(preparation.wasPersisted)
|
||||
#expect(
|
||||
preparation.bindingAuthorization?.bindingID
|
||||
== fixture.binding.bindingID
|
||||
)
|
||||
#expect(await ordering.values() == ["true:true"])
|
||||
#expect(await runtime.snapshot().state == .inactive)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signOutAuthorizationUsesPersistedLegacyBindingNamespace() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let legacyBinding = try CmxIrohBrokerBindingMetadata(
|
||||
bindingID: fixture.binding.bindingID,
|
||||
deviceID: fixture.binding.deviceID,
|
||||
appInstanceID: fixture.binding.appInstanceID,
|
||||
clientNamespace: "legacy",
|
||||
tag: fixture.binding.tag,
|
||||
platform: fixture.binding.platform,
|
||||
endpointID: fixture.binding.endpointID,
|
||||
identityGeneration: fixture.binding.identityGeneration,
|
||||
pathHints: fixture.binding.pathHints
|
||||
)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: []),
|
||||
broker: TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
await runtime.installLocalBindingForSignOutTest(legacyBinding)
|
||||
|
||||
let preparation = await runtime.deactivateForSignOut()
|
||||
|
||||
#expect(preparation.bindingAuthorization?.clientNamespace == "legacy")
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedSignOutPersistenceClosesHostAndQuarantinesLocalState() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
|
||||
+48
@@ -87,6 +87,54 @@ extension CmxIrohHostRuntimeTests {
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingRevocationInvalidatesEmbeddedRegistrationDiscovery() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let staleDiscovery = try HostRuntimeFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
relays: HostRuntimeFixture.relayURLs,
|
||||
lanGeneration: 1,
|
||||
revision: 1
|
||||
)
|
||||
let authoritativeDiscovery = try HostRuntimeFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
relays: HostRuntimeFixture.relayURLs,
|
||||
lanGeneration: 2,
|
||||
revision: 2
|
||||
)
|
||||
let pendingRevocations = fixture.pendingRevocations()
|
||||
let pending = try CmxIrohPendingRevocation(
|
||||
accountID: fixture.configuration.accountID,
|
||||
tag: "older-build",
|
||||
bindingID: "123e4567-e89b-42d3-a456-426614174099"
|
||||
)
|
||||
try await pendingRevocations.enqueue(pending)
|
||||
let broker = TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: authoritativeDiscovery,
|
||||
embeddedRegistrationDiscovery: staleDiscovery,
|
||||
embeddedRegistrationDiscoveryIsComplete: true,
|
||||
registrationRevision: 1
|
||||
)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: pendingRevocations,
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
|
||||
#expect(await broker.observedDiscoveryCount() == 1)
|
||||
#expect(await runtime.connectivityEngine?.snapshot().routeRevision == 2)
|
||||
#expect(await runtime.lanAdvertisementContext()?.rendezvous.generation == 2)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
|
||||
+2
@@ -231,4 +231,6 @@ private actor TestRevisionedHostBroker:
|
||||
}
|
||||
|
||||
func revoke(bindingID _: String) {}
|
||||
|
||||
func revokeStale(bindingID _: String) {}
|
||||
}
|
||||
|
||||
+10
@@ -1,6 +1,7 @@
|
||||
import CMUXMobileCore
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
struct HostRuntimeFixture {
|
||||
@@ -9,6 +10,7 @@ struct HostRuntimeFixture {
|
||||
let binding: CmxIrohBrokerBinding
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
let managedRelays: Set<String>
|
||||
let clientNamespace: CmxIrohMacBundleNamespace
|
||||
let configuration: CmxIrohHostRuntimeConfiguration
|
||||
|
||||
init(
|
||||
@@ -27,6 +29,11 @@ struct HostRuntimeFixture {
|
||||
.joined()
|
||||
)
|
||||
managedRelays = Set(Self.relayURLs)
|
||||
clientNamespace = try #require(
|
||||
CmxIrohMacBundleNamespace(
|
||||
bundleIdentifier: "com.cmuxterm.tests"
|
||||
)
|
||||
)
|
||||
binding = try Self.binding(
|
||||
endpointID: endpointID.endpointID,
|
||||
lastSeenAt: now,
|
||||
@@ -41,6 +48,7 @@ struct HostRuntimeFixture {
|
||||
accountID: "account-a",
|
||||
deviceID: binding.deviceID,
|
||||
appInstanceID: binding.appInstanceID,
|
||||
clientNamespace: clientNamespace,
|
||||
tag: binding.tag,
|
||||
displayName: binding.displayName,
|
||||
identity: identity,
|
||||
@@ -60,6 +68,7 @@ struct HostRuntimeFixture {
|
||||
accountID: configuration.accountID,
|
||||
deviceID: binding.deviceID,
|
||||
appInstanceID: binding.appInstanceID,
|
||||
clientNamespace: clientNamespace,
|
||||
tag: binding.tag,
|
||||
displayName: binding.displayName,
|
||||
identity: identity,
|
||||
@@ -178,6 +187,7 @@ struct HostRuntimeFixture {
|
||||
"binding_id": bindingID,
|
||||
"device_id": deviceID,
|
||||
"app_instance_id": "123e4567-e89b-42d3-a456-426614174012",
|
||||
"client_namespace": "mac:com.cmuxterm.tests",
|
||||
"tag": "cmux-ios-v0",
|
||||
"platform": "mac",
|
||||
"display_name": "Test Mac",
|
||||
|
||||
+42
-3
@@ -297,7 +297,7 @@ struct CmxIrohHostRuntimeTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingRevocationFailureBlocksHostRegistrationAndCachedFallback() async throws {
|
||||
func pendingRevocationFailureStopsAfterAuthenticatedRegistration() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let pendingRevocations = CmxIrohPendingRevocationOutbox(
|
||||
secureStore: TestSecureCredentialStore()
|
||||
@@ -327,15 +327,50 @@ struct CmxIrohHostRuntimeTests {
|
||||
try await runtime.start()
|
||||
}
|
||||
|
||||
#expect(await broker.observedRegistrationCount() == 0)
|
||||
#expect(await broker.observedRegistrationCount() == 1)
|
||||
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
|
||||
#expect(
|
||||
try await pendingRevocations.pending(
|
||||
accountID: fixture.configuration.accountID
|
||||
) == [pending]
|
||||
) == [pending]
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registrationReconcilesPendingBindingWithoutRevokingFreshBinding() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let pendingRevocations = CmxIrohPendingRevocationOutbox(
|
||||
secureStore: TestSecureCredentialStore()
|
||||
)
|
||||
let pending = try CmxIrohPendingRevocation(
|
||||
accountID: fixture.configuration.accountID,
|
||||
tag: fixture.configuration.tag,
|
||||
bindingID: fixture.binding.bindingID
|
||||
)
|
||||
try await pendingRevocations.enqueue(pending)
|
||||
let broker = TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(
|
||||
endpoints: [TestIrohEndpoint(identity: fixture.endpointID)]
|
||||
),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: pendingRevocations,
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.observedRevokedBindingIDs().isEmpty)
|
||||
#expect(try await pendingRevocations.pending(
|
||||
accountID: fixture.configuration.accountID
|
||||
).isEmpty)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
@@ -489,6 +524,10 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
if let revokeError { throw revokeError }
|
||||
}
|
||||
|
||||
func revokeStale(bindingID: String) throws {
|
||||
try revoke(bindingID: bindingID)
|
||||
}
|
||||
|
||||
func observedRegistrationCount() -> Int { registrationCount }
|
||||
func observedPreflightOperations() -> [CmxIrohBrokerOperation] {
|
||||
preflightOperations
|
||||
|
||||
+68
-17
@@ -14,7 +14,7 @@ struct CmxIrohIdentityRepositoryTests {
|
||||
|
||||
#expect(first == second)
|
||||
#expect(first.generation == 1)
|
||||
#expect(harness.secure.deleteAllCount == 1)
|
||||
#expect(await harness.secure.deleteAllCount() == 1)
|
||||
}
|
||||
|
||||
@Test("account switches rotate and do not resurrect prior keys")
|
||||
@@ -28,7 +28,7 @@ struct CmxIrohIdentityRepositoryTests {
|
||||
|
||||
#expect(accountA.secretKey != accountB.secretKey)
|
||||
#expect(accountA.secretKey != accountAAgain.secretKey)
|
||||
#expect(harness.secure.deleteAllCount == 3)
|
||||
#expect(await harness.secure.deleteAllCount() == 3)
|
||||
}
|
||||
|
||||
@Test("missing install marker rejects a key that survived uninstall")
|
||||
@@ -42,7 +42,7 @@ struct CmxIrohIdentityRepositoryTests {
|
||||
|
||||
#expect(original.secretKey != afterReinstall.secretKey)
|
||||
#expect(afterReinstall.generation == 1)
|
||||
#expect(harness.secure.deleteAllCount == 2)
|
||||
#expect(await harness.secure.deleteAllCount() == 2)
|
||||
}
|
||||
|
||||
@Test("explicit rotation increments generation without changing scope")
|
||||
@@ -84,6 +84,64 @@ struct CmxIrohIdentityRepositoryTests {
|
||||
try await repository.identity(accountID: "user", appInstanceID: "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("concurrent identity loads share one persisted identity")
|
||||
func concurrentIdentityLoadsAreSerialized() async throws {
|
||||
let suiteName = "CmxIrohIdentityRepositoryTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = TestControllableSecureIdentityStore()
|
||||
let entropy = TestIdentityEntropy()
|
||||
let repository = CmxIrohIdentityRepository(
|
||||
secureStore: store,
|
||||
installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults),
|
||||
randomBytes: { entropy.nextBytes() },
|
||||
marker: { entropy.nextMarker() }
|
||||
)
|
||||
await store.suspendNextWrite()
|
||||
let first = Task {
|
||||
try await repository.identity(accountID: "user", appInstanceID: "app")
|
||||
}
|
||||
await store.waitUntilWriteIsSuspended()
|
||||
let second = Task {
|
||||
try await repository.identity(accountID: "user", appInstanceID: "app")
|
||||
}
|
||||
try await ContinuousClock().sleep(for: .milliseconds(50))
|
||||
await store.resumeSuspendedWrite()
|
||||
|
||||
let firstIdentity = try await first.value
|
||||
let secondIdentity = try await second.value
|
||||
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
#expect(await store.recordCount() == 1)
|
||||
}
|
||||
|
||||
@Test("deactivation waits for an in-flight identity write")
|
||||
func deactivationFencesInFlightIdentityWrite() async throws {
|
||||
let suiteName = "CmxIrohIdentityRepositoryTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = TestControllableSecureIdentityStore()
|
||||
let repository = CmxIrohIdentityRepository(
|
||||
secureStore: store,
|
||||
installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults),
|
||||
randomBytes: { Data(repeating: 7, count: 32) },
|
||||
marker: { "install-marker" }
|
||||
)
|
||||
await store.suspendNextWrite()
|
||||
let identity = Task {
|
||||
try await repository.identity(accountID: "user", appInstanceID: "app")
|
||||
}
|
||||
await store.waitUntilWriteIsSuspended()
|
||||
let deactivate = Task { try await repository.deactivate() }
|
||||
try await ContinuousClock().sleep(for: .milliseconds(50))
|
||||
await store.resumeSuspendedWrite()
|
||||
|
||||
_ = try await identity.value
|
||||
try await deactivate.value
|
||||
|
||||
#expect(await store.recordCount() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
private final class IdentityHarness: @unchecked Sendable {
|
||||
@@ -101,32 +159,25 @@ private final class IdentityHarness: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestSecureIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private actor TestSecureIdentityStore: CmxIrohSecureIdentityStoring {
|
||||
private var records: [String: Data] = [:]
|
||||
private var storedDeleteAllCount = 0
|
||||
|
||||
var deleteAllCount: Int {
|
||||
lock.withLock { storedDeleteAllCount }
|
||||
}
|
||||
func deleteAllCount() -> Int { storedDeleteAllCount }
|
||||
|
||||
func read(account: String) -> Data? {
|
||||
lock.withLock { records[account] }
|
||||
}
|
||||
func read(account: String) -> Data? { records[account] }
|
||||
|
||||
func write(_ data: Data, account: String) {
|
||||
lock.withLock { records[account] = data }
|
||||
records[account] = data
|
||||
}
|
||||
|
||||
func delete(account: String) {
|
||||
_ = lock.withLock { records.removeValue(forKey: account) }
|
||||
records.removeValue(forKey: account)
|
||||
}
|
||||
|
||||
func deleteAll() {
|
||||
lock.withLock {
|
||||
records.removeAll()
|
||||
storedDeleteAllCount += 1
|
||||
}
|
||||
records.removeAll()
|
||||
storedDeleteAllCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
@Suite
|
||||
struct CmxIrohMacBundleNamespaceTests {
|
||||
@Test func exactBundlesRemainDistinctEvenWhenTheirTagsMatch() throws {
|
||||
let stable = try #require(
|
||||
CmxIrohMacBundleNamespace(
|
||||
bundleIdentifier: "com.cmuxterm.app"
|
||||
)
|
||||
)
|
||||
let staging = try #require(
|
||||
CmxIrohMacBundleNamespace(
|
||||
bundleIdentifier: "com.cmuxterm.app.staging"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(stable.rawValue == "mac:com.cmuxterm.app")
|
||||
#expect(staging.rawValue == "mac:com.cmuxterm.app.staging")
|
||||
#expect(stable != staging)
|
||||
}
|
||||
|
||||
@Test func invalidOrMissingBundleIdentityFailsClosed() {
|
||||
#expect(CmxIrohMacBundleNamespace(bundleIdentifier: nil) == nil)
|
||||
#expect(CmxIrohMacBundleNamespace(bundleIdentifier: "") == nil)
|
||||
#expect(
|
||||
CmxIrohMacBundleNamespace(
|
||||
bundleIdentifier: "com.cmuxterm.app:other"
|
||||
) == nil
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
@@ -94,6 +94,26 @@ struct CmxIrohPendingRevocationOutboxTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test("reconciliation removes a re-registered binding without revoking it")
|
||||
func reconciliationDoesNotRevokeActiveBinding() async throws {
|
||||
let store = TestSecureCredentialStore()
|
||||
let outbox = CmxIrohPendingRevocationOutbox(secureStore: store)
|
||||
let pending = try revocation()
|
||||
try await outbox.enqueue(pending)
|
||||
let broker = PendingRevocationBroker()
|
||||
|
||||
let revoked = try await outbox.reconcilePending(
|
||||
accountID: accountID,
|
||||
beforeRegisteringTag: tag,
|
||||
activeBindingID: pending.bindingID,
|
||||
using: broker
|
||||
)
|
||||
|
||||
#expect(!revoked)
|
||||
#expect(await broker.revokedBindingIDs().isEmpty)
|
||||
#expect(try await outbox.pending(accountID: accountID).isEmpty)
|
||||
}
|
||||
|
||||
private func revocation() throws -> CmxIrohPendingRevocation {
|
||||
try CmxIrohPendingRevocation(
|
||||
accountID: accountID,
|
||||
@@ -116,5 +136,9 @@ private actor PendingRevocationBroker: CmxIrohBindingRevoking {
|
||||
if let error { throw error }
|
||||
}
|
||||
|
||||
func revokeStale(bindingID: String) throws {
|
||||
try revoke(bindingID: bindingID)
|
||||
}
|
||||
|
||||
func revokedBindingIDs() -> [String] { bindingIDs }
|
||||
}
|
||||
|
||||
+6
@@ -29,6 +29,7 @@ struct CmxIrohRegistrationSignerTests {
|
||||
let payload = try CmxIrohRegistrationPayload(
|
||||
deviceID: "123e4567-e89b-12d3-a456-426614174000",
|
||||
appInstanceID: "123e4567-e89b-12d3-a456-426614174001",
|
||||
clientNamespace: "dev.cmux.app.internal",
|
||||
tag: "stable",
|
||||
platform: .ios,
|
||||
displayName: "Phone",
|
||||
@@ -71,6 +72,11 @@ struct CmxIrohRegistrationSignerTests {
|
||||
)
|
||||
#expect(payloadObject["endpointId"] as? String == endpointID)
|
||||
#expect(payloadObject["endpointID"] == nil)
|
||||
#expect(payloadObject["clientNamespace"] as? String == "dev.cmux.app.internal")
|
||||
#expect(
|
||||
prepared.challengeRequest.clientNamespace
|
||||
== "dev.cmux.app.internal"
|
||||
)
|
||||
let pathHints = try #require(payloadObject["pathHints"] as? [[String: Any]])
|
||||
let encodedHint = try #require(pathHints.first)
|
||||
#expect(encodedHint["observed_at"] is String)
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ struct CmxIrohRelayPolicyBrokerTests {
|
||||
CmxIrohBrokerCredentials(accessToken: "access", refreshToken: "refresh")
|
||||
}
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
+11
-4
@@ -10,8 +10,8 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
|
||||
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
|
||||
let lowercaseUUID = uppercaseUUID.lowercased()
|
||||
|
||||
let uuidHost = hostConfiguration(deviceID: uppercaseUUID, fixture: fixture)
|
||||
let opaqueHost = hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture)
|
||||
let uuidHost = try hostConfiguration(deviceID: uppercaseUUID, fixture: fixture)
|
||||
let opaqueHost = try hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture)
|
||||
let uuidClient = clientConfiguration(deviceID: uppercaseUUID, fixture: fixture)
|
||||
let opaqueClient = clientConfiguration(deviceID: "Legacy-iOS-ID", fixture: fixture)
|
||||
|
||||
@@ -24,11 +24,17 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
|
||||
private func hostConfiguration(
|
||||
deviceID: String,
|
||||
fixture: HostRuntimeFixture
|
||||
) -> CmxIrohHostRuntimeConfiguration {
|
||||
CmxIrohHostRuntimeConfiguration(
|
||||
) throws -> CmxIrohHostRuntimeConfiguration {
|
||||
let clientNamespace = try #require(
|
||||
CmxIrohMacBundleNamespace(
|
||||
bundleIdentifier: "com.cmuxterm.tests"
|
||||
)
|
||||
)
|
||||
return CmxIrohHostRuntimeConfiguration(
|
||||
accountID: "account-a",
|
||||
deviceID: deviceID,
|
||||
appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
clientNamespace: clientNamespace,
|
||||
tag: "test",
|
||||
displayName: nil,
|
||||
identity: fixture.identity,
|
||||
@@ -46,6 +52,7 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
|
||||
accountID: "account-a",
|
||||
deviceID: deviceID,
|
||||
appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
clientNamespace: fixture.clientNamespace.rawValue,
|
||||
tag: "test",
|
||||
displayName: nil,
|
||||
identity: fixture.identity,
|
||||
|
||||
+4
@@ -92,6 +92,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
|
||||
refreshToken: "stale-refresh"
|
||||
)
|
||||
}),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
|
||||
@@ -169,6 +170,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
|
||||
snapshot: { await snapshots.snapshot() },
|
||||
forceRefresh: { await snapshots.forceRefresh() }
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
|
||||
@@ -200,6 +202,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
|
||||
snapshot: { await snapshots.snapshot() },
|
||||
forceRefresh: { await snapshots.forceRefresh() }
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
|
||||
@@ -248,6 +251,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
|
||||
await recorder.recover(rejected)
|
||||
}
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -130,6 +130,7 @@ extension CmxIrohTrustBrokerClientTests {
|
||||
tokenSource: CmxIrohBrokerTokenSource(
|
||||
credentialPair: { nil }
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.missingAuthentication) {
|
||||
@@ -149,6 +150,7 @@ extension CmxIrohTrustBrokerClientTests {
|
||||
tokenSource: CmxIrohBrokerTokenSource(
|
||||
credentialPair: { throw CancellationError() }
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
await #expect(throws: CancellationError.self) {
|
||||
@@ -172,6 +174,7 @@ extension CmxIrohTrustBrokerClientTests {
|
||||
tokenSource: CmxIrohBrokerTokenSource(
|
||||
credentialPair: { throw TransientTokenReadError() }
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) {
|
||||
@@ -186,6 +189,7 @@ extension CmxIrohTrustBrokerClientTests {
|
||||
_ = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "http://cmux.example")),
|
||||
tokenSource: Self.networkTokenSource,
|
||||
clientNamespace: "legacy",
|
||||
transport: RecordingBrokerTransport(responses: [])
|
||||
)
|
||||
}
|
||||
@@ -232,6 +236,7 @@ extension CmxIrohTrustBrokerClientTests {
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: try #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: Self.networkTokenSource,
|
||||
clientNamespace: "legacy",
|
||||
transport: CmxIrohURLSessionTransport(configuration: configuration),
|
||||
requestTimeout: 0.1
|
||||
)
|
||||
@@ -248,6 +253,7 @@ extension CmxIrohTrustBrokerClientTests {
|
||||
try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: Self.networkTokenSource,
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
+134
-2
@@ -72,6 +72,86 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func postRegistrationRequestsCarryExactBindingProof() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 201,
|
||||
body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"#
|
||||
),
|
||||
.json(status: 201, body: Self.registrationResponse),
|
||||
.json(status: 200, body: Self.discoveryResponse),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
let signer = try registrationSigner()
|
||||
let prepared = try signer.prepare(payload: registrationPayload())
|
||||
|
||||
_ = try await client.register(prepared: prepared, signer: signer)
|
||||
_ = try await client.discover()
|
||||
|
||||
let requests = await transport.requests()
|
||||
let discovery = try #require(requests.last)
|
||||
#expect(
|
||||
discovery.value(forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID")
|
||||
== "123e4567-e89b-42d3-a456-426614174010"
|
||||
)
|
||||
#expect(
|
||||
Int64(
|
||||
discovery.value(
|
||||
forHTTPHeaderField: "X-Cmux-Iroh-Request-Time"
|
||||
) ?? ""
|
||||
) != nil
|
||||
)
|
||||
#expect(
|
||||
discovery.value(
|
||||
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
|
||||
)?.count == 86
|
||||
)
|
||||
#expect(
|
||||
requests.dropLast().allSatisfy {
|
||||
$0.value(
|
||||
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
|
||||
) == nil
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func freshManagementClientUsesRetainedBindingProof() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 200, body: Self.discoveryResponse),
|
||||
])
|
||||
let authorization = try CmxIrohBindingRequestAuthorization(
|
||||
bindingID: Self.bindingID,
|
||||
clientNamespace: "dev.cmux.app.internal",
|
||||
identity: identityMaterial(),
|
||||
endpointID: CmxIrohPeerIdentity(endpointID: Self.endpointID)
|
||||
)
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: Self.tokenSource,
|
||||
clientNamespace: "dev.cmux.app.internal",
|
||||
bindingAuthorization: authorization,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
_ = try await client.discover()
|
||||
|
||||
let request = try #require(await transport.requests().first)
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "X-Cmux-App-Namespace")
|
||||
== "dev.cmux.app.internal"
|
||||
)
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID")
|
||||
== Self.bindingID
|
||||
)
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature")?
|
||||
.count == 86
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registrationDecodesEmbeddedAuthoritativeDiscovery() async throws {
|
||||
var responseObject = try #require(
|
||||
@@ -468,6 +548,51 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
)
|
||||
#expect(object["bindingId"] as? String == bindingID)
|
||||
#expect(object["intent"] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forgetMacUsesExplicitAccountManagementIntent() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 200,
|
||||
body: #"{"revoked":true,"lan_rendezvous_rotated":true}"#
|
||||
),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
try await client.forgetMac(bindingID: Self.bindingID)
|
||||
|
||||
let captured = try #require(await transport.requests().first)
|
||||
#expect(captured.url?.path == "/api/devices/iroh")
|
||||
#expect(captured.httpMethod == "DELETE")
|
||||
let body = try #require(captured.httpBody)
|
||||
let object = try #require(
|
||||
JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
)
|
||||
#expect(object["bindingId"] as? String == Self.bindingID)
|
||||
#expect(object["intent"] as? String == "forget_mac")
|
||||
}
|
||||
|
||||
@Test
|
||||
func revokeStaleUsesExplicitStaleCleanupIntent() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 200,
|
||||
body: #"{"revoked":true,"lan_rendezvous_rotated":true}"#
|
||||
),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
try await client.revokeStale(bindingID: Self.bindingID)
|
||||
|
||||
let captured = try #require(await transport.requests().first)
|
||||
let body = try #require(captured.httpBody)
|
||||
let object = try #require(
|
||||
JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
)
|
||||
#expect(object["bindingId"] as? String == Self.bindingID)
|
||||
#expect(object["intent"] as? String == "revoke_stale")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1032,6 +1157,7 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: Self.tokenSource,
|
||||
clientNamespace: "dev.cmux.app.internal",
|
||||
discoveryScope: discoveryScope,
|
||||
transport: transport
|
||||
)
|
||||
@@ -1060,12 +1186,18 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
}
|
||||
|
||||
private func registrationSigner() throws -> CmxIrohRegistrationSigner {
|
||||
try CmxIrohRegistrationSigner(
|
||||
identity: identityMaterial(),
|
||||
endpointID: Self.endpointID
|
||||
)
|
||||
}
|
||||
|
||||
private func identityMaterial() throws -> CmxIrohIdentityMaterial {
|
||||
let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init)))
|
||||
let material = try CmxIrohIdentityMaterial(
|
||||
return try CmxIrohIdentityMaterial(
|
||||
secretKey: secret,
|
||||
generation: 1
|
||||
)
|
||||
return try CmxIrohRegistrationSigner(identity: material, endpointID: Self.endpointID)
|
||||
}
|
||||
|
||||
private func registrationPayload() throws -> CmxIrohRegistrationPayload {
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
actor TestControllableSecureIdentityStore: CmxIrohSecureIdentityStoring {
|
||||
private var records: [String: Data] = [:]
|
||||
private var shouldSuspendNextWrite = false
|
||||
private var suspendedWrite: CheckedContinuation<Void, Never>?
|
||||
private var writeSuspensionWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func read(account: String) -> Data? {
|
||||
records[account]
|
||||
}
|
||||
|
||||
func write(_ data: Data, account: String) async {
|
||||
if shouldSuspendNextWrite {
|
||||
shouldSuspendNextWrite = false
|
||||
await withCheckedContinuation { continuation in
|
||||
suspendedWrite = continuation
|
||||
let waiters = writeSuspensionWaiters
|
||||
writeSuspensionWaiters.removeAll(keepingCapacity: false)
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
}
|
||||
records[account] = data
|
||||
}
|
||||
|
||||
func delete(account: String) {
|
||||
records.removeValue(forKey: account)
|
||||
}
|
||||
|
||||
func deleteAll() {
|
||||
records.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
func suspendNextWrite() {
|
||||
shouldSuspendNextWrite = true
|
||||
}
|
||||
|
||||
func waitUntilWriteIsSuspended() async {
|
||||
guard suspendedWrite == nil else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
writeSuspensionWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func resumeSuspendedWrite() {
|
||||
let continuation = suspendedWrite
|
||||
suspendedWrite = nil
|
||||
continuation?.resume()
|
||||
}
|
||||
|
||||
func recordCount() -> Int {
|
||||
records.count
|
||||
}
|
||||
}
|
||||
+19
@@ -7,6 +7,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
|
||||
private let discoveryResponse: CmxIrohDiscoveryResponse
|
||||
private let relayResponse: CmxIrohRelayTokenResponse
|
||||
private let pairGrantResponse: CmxIrohPairGrantResponse?
|
||||
private let bindingAuthorizationAvailable: Bool
|
||||
private let revokeError: (any Error)?
|
||||
private let registrationHook: (@Sendable (_ count: Int) async -> Void)?
|
||||
private let discoveryHook: (@Sendable (_ count: Int) async -> Void)?
|
||||
@@ -29,6 +30,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
|
||||
discovery: CmxIrohDiscoveryResponse,
|
||||
relay: CmxIrohRelayTokenResponse,
|
||||
pairGrant: CmxIrohPairGrantResponse? = nil,
|
||||
bindingAuthorizationAvailable: Bool = true,
|
||||
issueRelayAtRegistration: Bool = true,
|
||||
registrationError: (any Error)? = nil,
|
||||
discoveryErrorsByCount: [Int: any Error] = [:],
|
||||
@@ -43,6 +45,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
|
||||
discoveryResponse = discovery
|
||||
relayResponse = relay
|
||||
pairGrantResponse = pairGrant
|
||||
self.bindingAuthorizationAvailable = bindingAuthorizationAvailable
|
||||
self.revokeError = revokeError
|
||||
self.registrationError = registrationError
|
||||
self.discoveryErrorsByCount = discoveryErrorsByCount
|
||||
@@ -50,6 +53,14 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
|
||||
self.discoveryHook = discoveryHook
|
||||
}
|
||||
|
||||
func hasBindingAuthorization() async -> Bool {
|
||||
bindingAuthorizationAvailable
|
||||
}
|
||||
|
||||
func bindingAuthorizationID() async -> String? {
|
||||
bindingAuthorizationAvailable ? registration.binding.bindingID : nil
|
||||
}
|
||||
|
||||
func register(
|
||||
prepared: CmxIrohPreparedRegistration,
|
||||
signer _: CmxIrohRegistrationSigner
|
||||
@@ -109,6 +120,14 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
|
||||
if let revokeError { throw revokeError }
|
||||
}
|
||||
|
||||
func revokeStale(bindingID: String) throws {
|
||||
try revoke(bindingID: bindingID)
|
||||
}
|
||||
|
||||
func forgetMac(bindingID: String) throws {
|
||||
try revoke(bindingID: bindingID)
|
||||
}
|
||||
|
||||
func observedRegistrations() -> [CmxIrohPreparedRegistration] {
|
||||
preparedRegistrations
|
||||
}
|
||||
|
||||
+22
@@ -258,6 +258,14 @@ public struct ChatArtifactFailurePresentation: Equatable, Sendable {
|
||||
systemImage: "doc.badge.ellipsis",
|
||||
allowsRetry: false
|
||||
)
|
||||
case .unknown(let code):
|
||||
// The Mac replied, so this copy must not blame connectivity.
|
||||
self = Self(
|
||||
title: Self.localized("chat.artifact.failure.unknown.title", defaultValue: "Unrecognized error"),
|
||||
message: Self.unknownMessage(code: code),
|
||||
systemImage: "questionmark.circle",
|
||||
allowsRetry: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,4 +318,18 @@ public struct ChatArtifactFailurePresentation: Equatable, Sendable {
|
||||
limitText
|
||||
)
|
||||
}
|
||||
|
||||
private static func unknownMessage(code: String?) -> String {
|
||||
guard let code else {
|
||||
return localized(
|
||||
"chat.artifact.failure.unknown.message",
|
||||
defaultValue: "The Mac reported an error this app doesn't recognize. Update cmux on both devices."
|
||||
)
|
||||
}
|
||||
let format = localized(
|
||||
"chat.artifact.failure.unknown.message_with_code",
|
||||
defaultValue: "The Mac reported an error this app doesn't recognize (%@). Update cmux on both devices."
|
||||
)
|
||||
return String.localizedStringWithFormat(format, code)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -165,6 +165,8 @@ public struct ChatArtifactInlineViewer: View {
|
||||
.chat
|
||||
case .terminal:
|
||||
.terminal
|
||||
case .panel:
|
||||
.panel
|
||||
case .workspaceChanges:
|
||||
.workspaceChanges
|
||||
case .unsupported:
|
||||
|
||||
@@ -330,6 +330,27 @@
|
||||
"ja": { "stringUnit": { "state": "translated", "value": "転送が中断されました" } }
|
||||
}
|
||||
},
|
||||
"chat.artifact.failure.unknown.message": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "The Mac reported an error this app doesn't recognize. Update cmux on both devices." } },
|
||||
"ja": { "stringUnit": { "state": "translated", "value": "Macがこのアプリで認識できないエラーを返しました。両方のデバイスでcmuxをアップデートしてください。" } }
|
||||
}
|
||||
},
|
||||
"chat.artifact.failure.unknown.message_with_code": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "The Mac reported an error this app doesn't recognize (%@). Update cmux on both devices." } },
|
||||
"ja": { "stringUnit": { "state": "translated", "value": "Macがこのアプリで認識できないエラーを返しました(%@)。両方のデバイスでcmuxをアップデートしてください。" } }
|
||||
}
|
||||
},
|
||||
"chat.artifact.failure.unknown.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Unrecognized error" } },
|
||||
"ja": { "stringUnit": { "state": "translated", "value": "認識できないエラー" } }
|
||||
}
|
||||
},
|
||||
"chat.artifact.failure.unsupported.message": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
||||
+13
@@ -43,6 +43,7 @@ struct ChatArtifactFailurePresentationTests {
|
||||
(.localStorageUnavailable, "Local storage unavailable", true),
|
||||
(.loadFailed, "Couldn't load file", true),
|
||||
(.tooLarge(limitBytes: 1_024), "File too large to preview", false),
|
||||
(.unknown(code: "artifact_rev_gone"), "Unrecognized error", false),
|
||||
]
|
||||
|
||||
#expect(unreachable.title == "Mac unreachable")
|
||||
@@ -62,6 +63,18 @@ struct ChatArtifactFailurePresentationTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func unknownCopySurfacesTheCodeWithoutBlamingConnectivity() {
|
||||
let coded = ChatArtifactFailurePresentation(error: .unknown(code: "artifact_rev_gone"), scope: .chat)
|
||||
let uncoded = ChatArtifactFailurePresentation(error: .unknown(code: nil), scope: .chat)
|
||||
|
||||
// The Mac replied, so the copy must not send the user to check connectivity.
|
||||
#expect(coded.message.contains("artifact_rev_gone"))
|
||||
#expect(!coded.message.contains("Check the connection"))
|
||||
#expect(!uncoded.message.contains("Check the connection"))
|
||||
#expect(!uncoded.message.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forbiddenCopyMatchesAuthorizationScope() {
|
||||
let chat = ChatArtifactFailurePresentation(error: .forbidden, scope: .chat)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user